davsclaus commented on code in PR #26550:
URL: https://github.com/apache/camel/pull/26550#discussion_r4037640900


##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java:
##########
@@ -999,11 +1014,64 @@ private ChatResponse chatOpenAiFormat(String 
systemPrompt, List<Message> message
     private JsonObject ollamaOptions() {
         JsonObject options = new JsonObject();
         options.put("temperature", temperature);
-        options.put("num_ctx", ollamaNumCtx());
+        options.put("num_ctx", ollamaContextWindow());
         return options;
     }
 
-    static int ollamaNumCtx() {
+    // ---- Ollama context window ----
+
+    private Integer resolvedOllamaContext;
+    private String resolvedOllamaContextModel;
+    private String resolvedOllamaContextUrl;
+
+    /**
+     * The context window ({@code num_ctx}) this client asks Ollama for with 
the current model, resolved once per model
+     * and endpoint:
+     * <ol>
+     * <li>{@code OLLAMA_CONTEXT_LENGTH} in the environment wins when set.</li>
+     * <li>If Ollama already has the model loaded, its window is adopted 
(raised to {@link #OLLAMA_MIN_CONTEXT} when
+     * smaller), because a request with a different {@code num_ctx} makes 
Ollama reload the model, which costs a cold
+     * start and throws away the prompt cache of every other client.</li>
+     * <li>Otherwise {@link #OLLAMA_MAX_CONTEXT} when the model's weights plus 
the KV cache of that window fit the
+     * machine's memory, else {@link #OLLAMA_MIN_CONTEXT}. Overshooting is 
worse than being conservative: Ollama then
+     * moves layers to the CPU and generation slows to a crawl.</li>
+     * </ol>
+     * Callers that manage a conversation should budget their history against 
{@code min(window, OLLAMA_MAX_CONTEXT)}
+     * even when a larger window was adopted, so a cache loss never means 
minutes of prefill.
+     */
+    public synchronized int ollamaContextWindow() {
+        if (resolvedOllamaContext != null && 
Objects.equals(resolvedOllamaContextModel, model)
+                && Objects.equals(resolvedOllamaContextUrl, url)) {
+            return resolvedOllamaContext;
+        }
+        int window = resolveOllamaContextWindow(totalPhysicalMemory());

Review Comment:
   Fixed in af10968db134: the window is now resolved on a daemon thread when 
the panel connects to an Ollama client and again after `/model`, so the cache 
is warm before the first question 
(`LlmClient.resolveOllamaContextWindowInBackground()`, covered by 
`theWindowCanBeResolvedAheadOfTheFirstRequest`). A request that arrives earlier 
still resolves synchronously, as before.
   
   For scale: the three probes go to the same local server that answers the 
chat and take milliseconds; the 5 s timeouts only apply when Ollama is 
unreachable, and then the chat request fails as well. The stall was real in 
shape but small in practice; it is gone either way.
   
   _Claude Code on behalf of davsclaus_



##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java:
##########
@@ -1012,10 +1080,155 @@ static int ollamaNumCtx() {
                     return value;
                 }
             } catch (NumberFormatException e) {
-                // fall through to the default
+                // fall through to the policy
+            }
+        }
+        return null;
+    }
+
+    /** Context length of the current model if Ollama already has it loaded 
({@code /api/ps}), 0 otherwise. */
+    long loadedOllamaContext() {
+        JsonObject ps = sendGetRequest(url + "/api/ps", Map.of());
+        if (ps == null) {
+            return 0;
+        }
+        Collection<?> loaded = ps.getCollection("models");
+        if (loaded == null) {
+            return 0;
+        }
+        for (Object o : loaded) {
+            if (o instanceof JsonObject m && ollamaModelMatches(model, 
m.getString("name"))) {
+                return getLongValue(m, "context_length");
             }
         }
-        return OLLAMA_NUM_CTX;
+        return 0;
+    }
+
+    /** {@code llama3.2} matches {@code llama3.2:latest}; a name with a tag 
must match exactly. */
+    static boolean ollamaModelMatches(String wanted, String candidate) {
+        if (wanted == null || candidate == null) {
+            return false;
+        }
+        if (wanted.equals(candidate)) {
+            return true;
+        }
+        return !wanted.contains(":") && candidate.startsWith(wanted + ":");
+    }
+
+    /**
+     * Whether the current model's weights plus the KV cache of a {@code 
contextTokens} window fit in
+     * {@code totalMemoryBytes}: weights from {@code /api/tags}, the KV cost 
per token from the architecture facts in
+     * {@code /api/show}. Unknown facts count as "does not fit".
+     */
+    boolean ollamaContextFits(int contextTokens, long totalMemoryBytes) {
+        if (totalMemoryBytes <= 0) {
+            return false;
+        }
+        long weights = ollamaWeightBytes();
+        if (weights <= 0) {
+            return false;
+        }
+        JsonObject body = new JsonObject();
+        body.put("model", model);
+        JsonObject show = postJsonQuietly(url + "/api/show", body);
+        long perToken = ollamaKvBytesPerToken(show);
+        if (perToken <= 0) {
+            return false;
+        }
+        return ollamaContextFits(weights, perToken, contextTokens, 
totalMemoryBytes);
+    }
+
+    /**
+     * Four fifths of the memory less a gibibyte for compute buffers: roughly 
what Apple silicon lets the GPU wire and a
+     * fair share of a machine that also runs the integration and the TUI. A 
heuristic, deliberately on the safe side.
+     */
+    static boolean ollamaContextFits(long weightBytes, long kvBytesPerToken, 
int contextTokens, long totalMemoryBytes) {
+        long budget = totalMemoryBytes / 5 * 4 - (1L << 30);
+        return weightBytes + kvBytesPerToken * contextTokens <= budget;
+    }
+
+    /**
+     * Bytes of KV cache per context token: 16-bit keys and values for each KV 
head of each layer that keeps a full
+     * attention cache. Hybrid models (Qwen 3.5/3.6 MoE) report {@code 
full_attention_interval}; only every n-th layer
+     * holds a cache, the others carry a fixed-size state. Head dimensions 
come from {@code attention.key_length} and
+     * {@code attention.value_length} when reported, else from the embedding 
size over the head count.
+     */
+    static long ollamaKvBytesPerToken(JsonObject show) {
+        if (show == null || !(show.get("model_info") instanceof JsonObject 
info)) {
+            return 0;
+        }
+        String arch = info.getString("general.architecture");
+        if (arch == null) {
+            return 0;
+        }
+        long layers = getLongValue(info, arch + ".block_count");
+        long heads = getLongValue(info, arch + ".attention.head_count");
+        long kvHeads = getLongValue(info, arch + ".attention.head_count_kv");
+        long embedding = getLongValue(info, arch + ".embedding_length");
+        long keyLength = getLongValue(info, arch + ".attention.key_length");
+        long valueLength = getLongValue(info, arch + 
".attention.value_length");
+        long interval = getLongValue(info, arch + ".full_attention_interval");
+        if (kvHeads <= 0) {
+            kvHeads = heads;
+        }
+        if (keyLength <= 0) {
+            keyLength = heads > 0 ? embedding / heads : 0;
+        }
+        if (valueLength <= 0) {
+            valueLength = keyLength;
+        }
+        long cacheLayers = interval > 1 ? layers / interval : layers;
+        if (cacheLayers <= 0 || kvHeads <= 0 || keyLength <= 0) {
+            return 0;
+        }
+        return cacheLayers * kvHeads * (keyLength + valueLength) * 2L;
+    }
+
+    /** A short POST with the health-check timeout and no console output, for 
probes outside a chat request. */
+    private JsonObject postJsonQuietly(String requestUrl, JsonObject body) {
+        try {
+            HttpRequest request = HttpRequest.newBuilder()
+                    .uri(URI.create(requestUrl))
+                    .timeout(Duration.ofSeconds(HEALTH_CHECK_TIMEOUT_SECONDS))
+                    .header("Content-Type", "application/json")
+                    .POST(HttpRequest.BodyPublishers.ofString(body.toJson()))
+                    .build();
+            HttpResponse<String> response = httpClient.send(request, 
HttpResponse.BodyHandlers.ofString());
+            if (response.statusCode() == 200) {
+                return (JsonObject) Jsoner.deserialize(response.body());
+            }
+        } catch (Exception e) {
+            // the caller treats an unknown answer as "does not fit"
+        }
+        return null;
+    }
+
+    private long ollamaWeightBytes() {
+        JsonObject tags = sendGetRequest(url + "/api/tags", Map.of());
+        if (tags == null) {
+            return 0;
+        }
+        Collection<?> models = tags.getCollection("models");
+        if (models == null) {
+            return 0;
+        }
+        for (Object o : models) {
+            if (o instanceof JsonObject m && ollamaModelMatches(model, 
m.getString("name"))) {
+                return getLongValue(m, "size");
+            }
+        }
+        return 0;
+    }
+
+    static long totalPhysicalMemory() {
+        try {
+            if (ManagementFactory.getOperatingSystemMXBean() instanceof 
OperatingSystemMXBean os) {

Review Comment:
   Keeping this one. `com.sun.management` is a supported, exported API of the 
`jdk.management` module (it is listed in the module's `exports`, not 
`jdk.internal.*`), so `--release 17` compiles it without a warning and there is 
no `restriction` category to suppress in javac. Camel already uses the same 
interface in production code: `HeapDumpDevConsole` and 
`HeapHistogramDevConsole` in camel-console. The `instanceof` plus `Throwable` 
guard already covers JVMs that do not offer it (the fit check then falls back 
to the 32k minimum).
   
   _Claude Code on behalf of davsclaus_



##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java:
##########
@@ -1996,11 +2208,26 @@ private void recordUsage(LlmClient.ChatResponse 
response, long latencyMs) {
                 response.usage().totalTokens(), latencyMs,
                 response.stopReason(), Instant.now(),
                 AiUsageSource.TUI, null, questionCounter));
+        noteMeasuredPrompt(response.usage());
         if (ctx != null && ctx.ollamaMonitor != null && client.apiType() == 
LlmClient.ApiType.ollama) {
             // the Ollama tab shows this request with the timings Ollama 
returned
             ctx.ollamaMonitor.adoptEndpoint(client.endpointUrl());
-            ctx.ollamaMonitor.recordRequest(model, response.usage(), 
latencyMs, response.stopReason(),
-                    questionCounter, currentQuestion);
+            ctx.ollamaMonitor.recordRequest(model, response.usage(), latencyMs,
+                    reasonOverride != null ? reasonOverride : 
response.stopReason(), questionCounter, currentQuestion);
+            ctx.ollamaMonitor.setPanelContext(knownContextWindow, 
compactionBudgetTokens(true, knownContextWindow));
+        }
+    }
+
+    /** Keeps what the provider measured: the prompt size, the cold prefill 
speed and, for Ollama, the window. */
+    private void noteMeasuredPrompt(LlmClient.TokenUsage usage) {
+        if (usage.inputTokens() > 0) {
+            lastMeasuredPromptTokens = usage.inputTokens();
+        }
+        if (usage.cachedTokens() == 0 && usage.inputTokens() >= 1000 && 
usage.prefillMillis() > 0) {
+            coldPrefillTokensPerSecond = usage.inputTokens() * 1000.0 / 
usage.prefillMillis();
+        }
+        if (client != null && client.apiType() == LlmClient.ApiType.ollama) {
+            knownContextWindow = client.ollamaContextWindow();

Review Comment:
   Done in af10968db134: the resolved window, model and URL now live in one 
immutable record behind a `volatile` field, read outside the lock; 
`ollamaContextWindow()` only synchronizes when it has to resolve, so the 
per-response call from `noteMeasuredPrompt` is a volatile read.
   
   _Claude Code on behalf of davsclaus_



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to