From 9857c9861e16f583971fc29c98962bfb278419f2 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 2 Jul 2026 15:24:24 +0200 Subject: [PATCH 1/4] fix(cli): preserve output capacity for encoded image requests capOutputTokens estimated input tokens with the raw byte size of encoded images, which providers do not charge against context (vision input is accounted separately). For large attachments this drove available context to zero and capped maxOutputTokens down to a tiny value unnecessarily. Use the normalized token count, which replaces encoded media with a small placeholder, so output capacity is preserved when requests contain images. --- .changeset/preserve-image-output-capacity.md | 5 ++++ packages/opencode/src/kilocode/session/llm.ts | 9 +++++--- packages/opencode/src/session/llm.ts | 2 +- .../test/kilocode/session-overflow.test.ts | 23 +++++++++++++++++++ 4 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 .changeset/preserve-image-output-capacity.md diff --git a/.changeset/preserve-image-output-capacity.md b/.changeset/preserve-image-output-capacity.md new file mode 100644 index 00000000000..affc86a6b1b --- /dev/null +++ b/.changeset/preserve-image-output-capacity.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Preserve model output capacity when requests contain encoded images. diff --git a/packages/opencode/src/kilocode/session/llm.ts b/packages/opencode/src/kilocode/session/llm.ts index 0dd6811d9e8..b50e41f3e12 100644 --- a/packages/opencode/src/kilocode/session/llm.ts +++ b/packages/opencode/src/kilocode/session/llm.ts @@ -28,7 +28,8 @@ export namespace KiloLLM { /** * Caps `maxOutputTokens` to fit within the model's context window after - * accounting for the actual estimated input tokens (messages + tool schemas). + * accounting for estimated text and tool schema tokens. Encoded media bytes + * are excluded because providers account for images as vision input. * * Many small models (e.g. qwen 7B, 32K context) ship with a default * max_output of 32K, leaving no room for input once tools are included. @@ -40,14 +41,16 @@ export namespace KiloLLM { messages: ModelMessage[] tools: Record configured: number | undefined - tokens?: number + usage?: ReturnType }): number | undefined { if (input.configured == null) return input.configured if (input.configured <= 0) return undefined const { context } = input.model.limit if (!context) return input.configured - const tokens = input.tokens ?? KiloSessionOverflow.measure({ messages: input.messages, tools: input.tools }).raw + const tokens = + input.usage?.normalized ?? + KiloSessionOverflow.measure({ messages: input.messages, tools: input.tools }).normalized const available = context - tokens - SAFETY // If available is ≤0 the input alone exceeds context — return the original // value so the provider returns a natural overflow error which triggers diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index aa2dc6c56fa..32afa732b5a 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -288,7 +288,7 @@ const live: Layer.Layer< messages: estimated, tools: sortedTools, configured: params.maxOutputTokens, - tokens: usage?.raw, + usage, }) if ( preflight && diff --git a/packages/opencode/test/kilocode/session-overflow.test.ts b/packages/opencode/test/kilocode/session-overflow.test.ts index f2a9df0434c..6818cfee443 100644 --- a/packages/opencode/test/kilocode/session-overflow.test.ts +++ b/packages/opencode/test/kilocode/session-overflow.test.ts @@ -134,6 +134,29 @@ describe("Kilo request estimation", () => { expect(KiloLLM.needsEstimate({ model: model({ context: 0, output: 32_000 }), configured: 32_000 })).toBe(false) expect(KiloLLM.needsEstimate({ model: mdl, configured: 32_000 })).toBe(true) }) + + test("does not reduce output for encoded media payload size", () => { + const mdl = model({ context: 200_000, output: 32_000 }) + const messages = [ + { + role: "user", + content: [{ type: "image", image: `data:image/png;base64,${"x".repeat(600_000)}` }], + }, + ] satisfies ModelMessage[] + const usage = KiloSessionOverflow.measure({ messages, tools: {} }) + + expect(usage.raw).toBeGreaterThan(usage.normalized) + expect(KiloLLM.capOutputTokens({ model: mdl, messages, tools: {}, configured: 32_000, usage })).toBe(32_000) + }) + + test("still reduces output for oversized text", () => { + const mdl = model({ context: 200_000, output: 32_000 }) + const messages = [{ role: "user" as const, content: "x".repeat(600_000) }] + + const cap = KiloLLM.capOutputTokens({ model: mdl, messages, tools: {}, configured: 32_000 }) + expect(cap).toBeGreaterThanOrEqual(1_024) + expect(cap).toBeLessThan(32_000) + }) }) describe("Kilo preflight compaction", () => { From ca055f0bfc970bc1b6e92944338e5538fad3a6c5 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 3 Jul 2026 11:54:48 +0200 Subject: [PATCH 2/4] feat(cli): cap output from provider-reported context Prefer the previous finished turn's provider-reported context size (input + output + cache) when capping maxOutputTokens, falling back to the media- normalized estimate only when no usage has been reported yet. The provider's own tokenization already accounts for image/vision input, so encoded payload bytes no longer distort the output allowance. WIP: tests still to be added. --- packages/opencode/src/kilocode/session/llm.ts | 15 ++++++++++++--- packages/opencode/src/session/llm.ts | 2 ++ packages/opencode/src/session/prompt.ts | 5 +++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/kilocode/session/llm.ts b/packages/opencode/src/kilocode/session/llm.ts index b50e41f3e12..62c798bf054 100644 --- a/packages/opencode/src/kilocode/session/llm.ts +++ b/packages/opencode/src/kilocode/session/llm.ts @@ -28,8 +28,15 @@ export namespace KiloLLM { /** * Caps `maxOutputTokens` to fit within the model's context window after - * accounting for estimated text and tool schema tokens. Encoded media bytes - * are excluded because providers account for images as vision input. + * accounting for the context the outgoing request will consume. + * + * Like opencode, the provider is the source of truth: when the last finished + * turn reported usage, `reported` carries that provider-tokenized context size + * (input + output + cache), which already accounts for image/vision input the + * client cannot see. The client-side media-normalized estimate (encoded bytes + * excluded) is used as a floor so newly added text or tool schemas still cap + * output, and as the sole basis on the first turn before any usage is reported. + * The larger of the two is used so the cap never under-counts. * * Many small models (e.g. qwen 7B, 32K context) ship with a default * max_output of 32K, leaving no room for input once tools are included. @@ -42,15 +49,17 @@ export namespace KiloLLM { tools: Record configured: number | undefined usage?: ReturnType + reported?: number }): number | undefined { if (input.configured == null) return input.configured if (input.configured <= 0) return undefined const { context } = input.model.limit if (!context) return input.configured - const tokens = + const estimated = input.usage?.normalized ?? KiloSessionOverflow.measure({ messages: input.messages, tools: input.tools }).normalized + const tokens = Math.max(input.reported ?? 0, estimated) const available = context - tokens - SAFETY // If available is ≤0 the input alone exceeds context — return the original // value so the provider returns a natural overflow error which triggers diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 32afa732b5a..ee862e258d0 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -73,6 +73,7 @@ export type StreamInput = { retries?: number toolChoice?: "auto" | "required" | "none" preflight?: boolean // kilocode_change - enable proactive threshold compaction for normal session turns + reportedContextTokens?: number // kilocode_change - provider-reported context size from the last finished turn, source of truth for the output cap } export type StreamRequest = StreamInput & { @@ -289,6 +290,7 @@ const live: Layer.Layer< tools: sortedTools, configured: params.maxOutputTokens, usage, + reported: input.reportedContextTokens, }) if ( preflight && diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ebb8c652be9..2c55e137e71 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -7,6 +7,7 @@ import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" // kilo import { KiloSession } from "@/kilocode/session" // kilocode_change import { KiloCostPropagation } from "@/kilocode/session/cost-propagation" // kilocode_change import { KiloSessionProcessor } from "@/kilocode/session/processor" // kilocode_change +import { KiloSessionOverflow } from "@/kilocode/session/overflow" // kilocode_change import { CommandTimeout } from "@/kilocode/command-timeout" // kilocode_change import { Suggestion } from "@/kilocode/suggestion" // kilocode_change import { Question } from "@/question" // kilocode_change @@ -1983,6 +1984,10 @@ export const layer = Layer.effect( tools, model, toolChoice: format.type === "json_schema" ? "required" : undefined, + // kilocode_change - feed the provider-reported context size from the last finished + // turn into the output-token cap, so image/vision input is measured by the provider + // rather than by encoded payload bytes (see KiloLLM.capOutputTokens) + reportedContextTokens: lastFinished ? KiloSessionOverflow.count(lastFinished.tokens) : undefined, }) if (structured !== undefined) { From a909efe2461fb588c4647f8bbdb765e5705bb280 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 3 Jul 2026 12:02:06 +0200 Subject: [PATCH 3/4] test(cli): cover provider-reported context output cap Add regression coverage for capOutputTokens preferring the provider-reported context size (image/vision input priced by the provider) and falling back to the media-normalized floor when reported usage is smaller or absent. --- .changeset/preserve-image-output-capacity.md | 2 +- packages/opencode/src/session/prompt.ts | 3 +- .../test/kilocode/session-overflow.test.ts | 36 +++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/.changeset/preserve-image-output-capacity.md b/.changeset/preserve-image-output-capacity.md index affc86a6b1b..e3c196e64c5 100644 --- a/.changeset/preserve-image-output-capacity.md +++ b/.changeset/preserve-image-output-capacity.md @@ -2,4 +2,4 @@ "@kilocode/cli": patch --- -Preserve model output capacity when requests contain encoded images. +Preserve model output capacity when requests contain encoded images. The output token cap now uses the provider-reported context size from the previous turn, so image and vision input is measured by the provider instead of by encoded payload size. diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1dbc56cbfb1..0a783ab3c7e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1685,10 +1685,11 @@ export const layer = Layer.effect( tools, model, toolChoice: format.type === "json_schema" ? "required" : undefined, - // kilocode_change - feed the provider-reported context size from the last finished + // kilocode_change start - feed the provider-reported context size from the last finished // turn into the output-token cap, so image/vision input is measured by the provider // rather than by encoded payload bytes (see KiloLLM.capOutputTokens) reportedContextTokens: lastFinished ? KiloSessionOverflow.count(lastFinished.tokens) : undefined, + // kilocode_change end }) if (structured !== undefined) { diff --git a/packages/opencode/test/kilocode/session-overflow.test.ts b/packages/opencode/test/kilocode/session-overflow.test.ts index 6818cfee443..1c5075c0085 100644 --- a/packages/opencode/test/kilocode/session-overflow.test.ts +++ b/packages/opencode/test/kilocode/session-overflow.test.ts @@ -157,6 +157,42 @@ describe("Kilo request estimation", () => { expect(cap).toBeGreaterThanOrEqual(1_024) expect(cap).toBeLessThan(32_000) }) + + test("prefers provider-reported context over the client estimate for images", () => { + // The client cannot price encoded image bytes, but the provider reported a + // large vision-token cost for the last turn. + const mdl = model({ context: 300_000, output: 32_000 }) + const messages = [ + { + role: "user", + content: [{ type: "image", image: `data:image/png;base64,${"x".repeat(600_000)}` }], + }, + ] satisfies ModelMessage[] + + // Without reported usage the media-normalized estimate leaves output untouched. + expect(KiloLLM.capOutputTokens({ model: mdl, messages, tools: {}, configured: 32_000 })).toBe(32_000) + + // With the provider-reported context size, output is capped to fit real usage. + expect(KiloLLM.capOutputTokens({ model: mdl, messages, tools: {}, configured: 32_000, reported: 280_000 })).toBe( + 17_952, + ) + }) + + test("uses the media-normalized floor when reported usage is smaller", () => { + const mdl = model({ context: 200_000, output: 32_000 }) + const messages = [{ role: "user" as const, content: "x".repeat(600_000) }] + + const withoutReported = KiloLLM.capOutputTokens({ model: mdl, messages, tools: {}, configured: 32_000 }) + const withStaleReported = KiloLLM.capOutputTokens({ + model: mdl, + messages, + tools: {}, + configured: 32_000, + reported: 1_000, + }) + expect(withStaleReported).toBe(withoutReported) + expect(withStaleReported).toBeLessThan(32_000) + }) }) describe("Kilo preflight compaction", () => { From a0a6a29e342f3ec28367874c6fba9ee4d1af8327 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 3 Jul 2026 12:19:37 +0200 Subject: [PATCH 4/4] fix(cli): skip summary messages for reported context cap The compaction summary's reported input tokens reflect the pre-compaction history, not the trimmed context of the next request. Guard like the adjacent isOverflow check so output is not collapsed right after auto-compaction. --- packages/opencode/src/session/prompt.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0a783ab3c7e..12c8f5bb738 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1687,8 +1687,13 @@ export const layer = Layer.effect( toolChoice: format.type === "json_schema" ? "required" : undefined, // kilocode_change start - feed the provider-reported context size from the last finished // turn into the output-token cap, so image/vision input is measured by the provider - // rather than by encoded payload bytes (see KiloLLM.capOutputTokens) - reportedContextTokens: lastFinished ? KiloSessionOverflow.count(lastFinished.tokens) : undefined, + // rather than by encoded payload bytes (see KiloLLM.capOutputTokens). Summary messages + // are skipped like in the isOverflow check above: their reported input reflects the + // pre-compaction history, not the trimmed context of the next request. + reportedContextTokens: + lastFinished && lastFinished.summary !== true + ? KiloSessionOverflow.count(lastFinished.tokens) + : undefined, // kilocode_change end })