From 46e3745556af714eb71ab2c6fe45be3f6a10edd8 Mon Sep 17 00:00:00 2001 From: coseung2 <120152615+coseung2@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:11:45 +0900 Subject: [PATCH 1/7] fix(kiro): report context pressure for compaction --- src/adapters/kiro.ts | 27 +++++++++++++++++++++++++-- src/bridge.ts | 10 +++++++--- src/types.ts | 7 +++++++ tests/bridge.test.ts | 21 +++++++++++++++++++++ tests/kiro-stream.test.ts | 6 +++++- 5 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index 9cb55e0625..c5ae8e747e 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -491,6 +491,9 @@ function mergeKiroUsage(first: OcxUsage | undefined, second: OcxUsage | undefine return { inputTokens: first.inputTokens + second.inputTokens, outputTokens: first.outputTokens + second.outputTokens, + ...(typeof first.contextInputTokens === "number" || typeof second.contextInputTokens === "number" + ? { contextInputTokens: Math.max(first.contextInputTokens ?? 0, second.contextInputTokens ?? 0) } + : {}), ...(totalTokens !== undefined ? { totalTokens } : {}), ...(sumOptional("cachedInputTokens") !== undefined ? { cachedInputTokens: sumOptional("cachedInputTokens") } : {}), ...(sumOptional("cacheReadInputTokens") !== undefined ? { cacheReadInputTokens: sumOptional("cacheReadInputTokens") } : {}), @@ -534,6 +537,7 @@ async function* parseKiroAttempt( nameMap: Map | undefined, conversationId: string | undefined, previousAssistantText?: string, + contextInputTokens?: number, ): AsyncGenerator { const emptyResult = (): KiroAttemptResult => ({ assistantText: "", sawReasoning: false }); if (!response.body) { @@ -560,11 +564,20 @@ async function* parseKiroAttempt( const providerState = (): { kiro: { conversationId: string } } | undefined => returnedConversationId ? { kiro: { conversationId: returnedConversationId } } : undefined; - const usage = (): OcxUsage => authoritativeUsage ?? ({ + const contextUsageInputFloor = (): number | undefined => { + if (contextUsagePercentage === undefined || !contextWindow) return undefined; + const floor = Math.ceil(contextWindow * Math.min(contextUsagePercentage, 100) / 100); + return Number.isFinite(floor) && floor > 0 ? floor : undefined; + }; + const usage = (): OcxUsage => { + const contextFloor = Math.max(contextInputTokens ?? 0, contextUsageInputFloor() ?? 0); + const base = authoritativeUsage ?? { inputTokens, outputTokens: estimateTokens(outputChars, modelId), estimated: true, - }); + }; + return contextFloor > 0 ? { ...base, contextInputTokens: contextFloor } : base; + }; const classifiedTerminal = (failure: KiroErrorClassification): AdapterEvent => ({ type: "error", @@ -970,6 +983,7 @@ export async function* parseKiroStream( conversationId?: string, completionMode: KiroCompletionMode = "disabled", fallbackFactory?: KiroFallbackFactory, + contextInputTokens?: number, ): AsyncGenerator { const first = parseKiroAttempt( response, @@ -979,6 +993,8 @@ export async function* parseKiroStream( contextWindow, nameMap, conversationId, + undefined, + contextInputTokens, ); let firstNext = await first.next(); while (!firstNext.done) { @@ -1043,6 +1059,7 @@ export async function* parseKiroStream( fallback.nameMap, fallback.conversationId, firstResult.assistantText, + contextInputTokens, ); let secondNext = await second.next(); while (!secondNext.done) { @@ -1080,6 +1097,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter // Per-request closure (resolveAdapter builds a fresh adapter per request — server.ts:440 — so this // is race-free) carrying the heuristic input-token estimate from buildRequest into the stream. let inputTokens = 0; + let contextInputTokens = 0; let modelId: string | undefined; let contextWindow: number | undefined; let toolNameMap: Map | undefined; @@ -1097,6 +1115,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter conversationId: string; completionMode: KiroCompletionMode; inputTokens: number; + contextInputTokens: number; }> => { if (typeof provider.apiKey !== "string" || provider.apiKey.trim() === "") { throw new Error("kiro token missing — run ocx login kiro"); @@ -1141,6 +1160,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter conversationId: built.conversationId, completionMode: built.completionMode, inputTokens: estimateKiroInputTokens(parsed), + contextInputTokens: estimateKiroLogInputTokens(parsed), }; }; @@ -1191,6 +1211,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter modelId = parsed.modelId; contextWindow = configuredKiroContextWindow(provider, parsed.modelId); inputTokens = built.inputTokens; + contextInputTokens = built.contextInputTokens; toolNameMap = built.nameMap; conversationId = built.conversationId; completionMode = built.completionMode; @@ -1209,6 +1230,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter conversationId, completionMode, completionMode === "required" ? fallbackFactory : undefined, + contextInputTokens, ); }, @@ -1239,6 +1261,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter conversationId, completionMode, completionMode === "required" ? fallbackFactory : undefined, + contextInputTokens, )) events.push(e); return events; }, diff --git a/src/bridge.ts b/src/bridge.ts index df759900e4..218003a7ab 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -15,12 +15,16 @@ function sseEvent(name: string, data: Record): string { function responsesUsage(usage: OcxUsage | undefined): Record { if (!usage) return { input_tokens: 0, output_tokens: 0, total_tokens: 0 }; - // inputTokens is already inclusive of cache read/write (types.ts convention). - const inputTokens = usage.inputTokens; + // Stateful providers may report per-turn billing usage separately from whole-conversation + // context pressure. Responses clients use input_tokens to trigger compaction, so prefer the + // latter on the wire while persistence/cost accounting keeps using inputTokens. + const inputTokens = Math.max(usage.inputTokens, usage.contextInputTokens ?? 0); const out: Record = { input_tokens: inputTokens, output_tokens: usage.outputTokens, - total_tokens: usageDisplayTotalTokens(usage) ?? inputTokens + usage.outputTokens, + total_tokens: usage.contextInputTokens !== undefined + ? inputTokens + usage.outputTokens + : usageDisplayTotalTokens(usage) ?? inputTokens + usage.outputTokens, }; const inputDetails: Record = {}; if (usage.cachedInputTokens !== undefined) { diff --git a/src/types.ts b/src/types.ts index 2295af30d3..9e01faf119 100644 --- a/src/types.ts +++ b/src/types.ts @@ -306,6 +306,13 @@ export interface OcxUrlCitation { export interface OcxUsage { inputTokens: number; outputTokens: number; + /** + * Provider-private context pressure for clients that trigger compaction from Responses usage. + * This is intentionally separate from `inputTokens`: stateful providers such as Kiro bill/report + * only the current turn while also exposing the whole conversation's context occupancy. + * Persistence and cost accounting must continue to use `inputTokens`. + */ + contextInputTokens?: number; totalTokens?: number; cachedInputTokens?: number; cacheReadInputTokens?: number; diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index 13f60ee414..f46b4faaf8 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -129,6 +129,27 @@ describe("Responses bridge reasoning and usage parity", () => { }); }); + test("context input override drives Responses compaction without changing billing usage", async () => { + const frames = await collectSse(bridgeToResponsesSSE(replay([ + { + type: "done", + usage: { + inputTokens: 58, + contextInputTokens: 226_000, + outputTokens: 12, + estimated: true, + }, + }, + ]), "kiro/claude-opus-5")); + + const completed = frames.find(f => f.event === "response.completed")?.data.response as Record; + expect(completed.usage).toEqual({ + input_tokens: 226_000, + output_tokens: 12, + total_tokens: 226_012, + }); + }); + test("Anthropic cache read and write tokens pass through Responses usage without re-adding", async () => { const frames = await collectSse(bridgeToResponsesSSE(replay([ { diff --git a/tests/kiro-stream.test.ts b/tests/kiro-stream.test.ts index 5dcc624526..15d127608e 100644 --- a/tests/kiro-stream.test.ts +++ b/tests/kiro-stream.test.ts @@ -777,6 +777,7 @@ describe("kiro adapter — parseStream", () => { ); expect(done).toEqual({ inputTokens: 15, + contextInputTokens: 200, cachedInputTokens: 3, cacheReadInputTokens: 3, cacheCreationInputTokens: 2, @@ -823,7 +824,7 @@ describe("kiro adapter — parseStream", () => { expect((events[0] as { message: string }).message).toContain("Compact or reduce the history"); }); - test("Kiro contextUsagePercentage remains diagnostic and does not override totals", async () => { + test("Kiro contextUsagePercentage drives context pressure without overriding turn totals", async () => { const adapter = createKiroAdapter(provider); await adapter.buildRequest(parsedWith([{ role: "user", content: "x".repeat(700) }])); const done = await doneUsage( @@ -836,6 +837,7 @@ describe("kiro adapter — parseStream", () => { expect(done.outputTokens).toBe(100); expect(done.totalTokens).toBeUndefined(); expect(done.estimated).toBe(true); + expect(done.contextInputTokens).toBe(50_000); }); test("Kiro auto ignores provider-level context window and falls back to heuristic totals", async () => { @@ -875,6 +877,7 @@ describe("kiro adapter — parseStream", () => { expect(longBody.length).toBeGreaterThan(shortBody.length + 10_000); expect(longUsage.inputTokens).toBe(shortUsage.inputTokens); expect(longUsage.inputTokens).toBe(estimateTokens(latest, "claude-sonnet-4.5")); + expect(longUsage.contextInputTokens).toBeGreaterThan(shortUsage.contextInputTokens ?? 0); }); test("request log usage estimates the full Codex context while SSE usage stays current-turn", async () => { @@ -891,6 +894,7 @@ describe("kiro adapter — parseStream", () => { expect(usage.inputTokens).toBe(estimateTokens(latest, "claude-sonnet-4.5")); expect(request.usageLog?.estimated).toBe(true); expect(request.usageLog?.inputTokens).toBeGreaterThan(usage.inputTokens + 4000); + expect(usage.contextInputTokens).toBe(request.usageLog?.inputTokens); }); test("resumed payload preserves the complete locally expanded history", async () => { From 0c0b4f89da80d8ad099aa725b21c4b40e18c94fd Mon Sep 17 00:00:00 2001 From: coseung2 <120152615+coseung2@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:58:40 +0900 Subject: [PATCH 2/7] fix(kiro): model context pressure as absolute total --- src/adapters/kiro.ts | 35 ++++++++++++++++++++--------------- src/bridge.ts | 13 +++++++------ src/types.ts | 9 ++++----- tests/bridge.test.ts | 24 ++++++++++++++++++++---- tests/kiro-stream.test.ts | 32 ++++++++++++++++++++++++++++---- tests/request-log.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 6 files changed, 115 insertions(+), 34 deletions(-) diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index c5ae8e747e..3b3b22da5d 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -465,6 +465,7 @@ interface KiroAttemptResult { interface KiroFallbackAttempt { response: Response; inputTokens: number; + contextInputEstimate: number; nameMap: Map; conversationId: string; } @@ -491,8 +492,8 @@ function mergeKiroUsage(first: OcxUsage | undefined, second: OcxUsage | undefine return { inputTokens: first.inputTokens + second.inputTokens, outputTokens: first.outputTokens + second.outputTokens, - ...(typeof first.contextInputTokens === "number" || typeof second.contextInputTokens === "number" - ? { contextInputTokens: Math.max(first.contextInputTokens ?? 0, second.contextInputTokens ?? 0) } + ...(typeof first.contextTotalTokens === "number" || typeof second.contextTotalTokens === "number" + ? { contextTotalTokens: Math.max(first.contextTotalTokens ?? 0, second.contextTotalTokens ?? 0) } : {}), ...(totalTokens !== undefined ? { totalTokens } : {}), ...(sumOptional("cachedInputTokens") !== undefined ? { cachedInputTokens: sumOptional("cachedInputTokens") } : {}), @@ -537,7 +538,7 @@ async function* parseKiroAttempt( nameMap: Map | undefined, conversationId: string | undefined, previousAssistantText?: string, - contextInputTokens?: number, + contextInputEstimate?: number, ): AsyncGenerator { const emptyResult = (): KiroAttemptResult => ({ assistantText: "", sawReasoning: false }); if (!response.body) { @@ -564,19 +565,22 @@ async function* parseKiroAttempt( const providerState = (): { kiro: { conversationId: string } } | undefined => returnedConversationId ? { kiro: { conversationId: returnedConversationId } } : undefined; - const contextUsageInputFloor = (): number | undefined => { + const contextUsageTotalFloor = (): number | undefined => { if (contextUsagePercentage === undefined || !contextWindow) return undefined; const floor = Math.ceil(contextWindow * Math.min(contextUsagePercentage, 100) / 100); return Number.isFinite(floor) && floor > 0 ? floor : undefined; }; const usage = (): OcxUsage => { - const contextFloor = Math.max(contextInputTokens ?? 0, contextUsageInputFloor() ?? 0); const base = authoritativeUsage ?? { inputTokens, outputTokens: estimateTokens(outputChars, modelId), estimated: true, }; - return contextFloor > 0 ? { ...base, contextInputTokens: contextFloor } : base; + const estimatedContextTotal = contextInputEstimate !== undefined + ? contextInputEstimate + base.outputTokens + : undefined; + const contextTotal = Math.max(estimatedContextTotal ?? 0, contextUsageTotalFloor() ?? 0); + return contextTotal > 0 ? { ...base, contextTotalTokens: contextTotal } : base; }; const classifiedTerminal = (failure: KiroErrorClassification): AdapterEvent => ({ @@ -983,7 +987,7 @@ export async function* parseKiroStream( conversationId?: string, completionMode: KiroCompletionMode = "disabled", fallbackFactory?: KiroFallbackFactory, - contextInputTokens?: number, + contextInputEstimate?: number, ): AsyncGenerator { const first = parseKiroAttempt( response, @@ -994,7 +998,7 @@ export async function* parseKiroStream( nameMap, conversationId, undefined, - contextInputTokens, + contextInputEstimate, ); let firstNext = await first.next(); while (!firstNext.done) { @@ -1059,7 +1063,7 @@ export async function* parseKiroStream( fallback.nameMap, fallback.conversationId, firstResult.assistantText, - contextInputTokens, + fallback.contextInputEstimate, ); let secondNext = await second.next(); while (!secondNext.done) { @@ -1097,7 +1101,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter // Per-request closure (resolveAdapter builds a fresh adapter per request — server.ts:440 — so this // is race-free) carrying the heuristic input-token estimate from buildRequest into the stream. let inputTokens = 0; - let contextInputTokens = 0; + let contextInputEstimate = 0; let modelId: string | undefined; let contextWindow: number | undefined; let toolNameMap: Map | undefined; @@ -1115,7 +1119,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter conversationId: string; completionMode: KiroCompletionMode; inputTokens: number; - contextInputTokens: number; + contextInputEstimate: number; }> => { if (typeof provider.apiKey !== "string" || provider.apiKey.trim() === "") { throw new Error("kiro token missing — run ocx login kiro"); @@ -1160,7 +1164,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter conversationId: built.conversationId, completionMode: built.completionMode, inputTokens: estimateKiroInputTokens(parsed), - contextInputTokens: estimateKiroLogInputTokens(parsed), + contextInputEstimate: estimateKiroLogInputTokens(parsed), }; }; @@ -1199,6 +1203,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter return { response, inputTokens: retry.inputTokens, + contextInputEstimate: retry.contextInputEstimate, nameMap: retry.nameMap, conversationId: retry.conversationId, }; @@ -1211,7 +1216,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter modelId = parsed.modelId; contextWindow = configuredKiroContextWindow(provider, parsed.modelId); inputTokens = built.inputTokens; - contextInputTokens = built.contextInputTokens; + contextInputEstimate = built.contextInputEstimate; toolNameMap = built.nameMap; conversationId = built.conversationId; completionMode = built.completionMode; @@ -1230,7 +1235,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter conversationId, completionMode, completionMode === "required" ? fallbackFactory : undefined, - contextInputTokens, + contextInputEstimate, ); }, @@ -1261,7 +1266,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter conversationId, completionMode, completionMode === "required" ? fallbackFactory : undefined, - contextInputTokens, + contextInputEstimate, )) events.push(e); return events; }, diff --git a/src/bridge.ts b/src/bridge.ts index eedd3bcbb1..6f38d92042 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -15,15 +15,16 @@ function sseEvent(name: string, data: Record): string { function responsesUsage(usage: OcxUsage | undefined): Record { if (!usage) return { input_tokens: 0, output_tokens: 0, total_tokens: 0 }; - // Stateful providers may report per-turn billing usage separately from whole-conversation - // context pressure. Responses clients use input_tokens to trigger compaction, so prefer the - // latter on the wire while persistence/cost accounting keeps using inputTokens. - const inputTokens = Math.max(usage.inputTokens, usage.contextInputTokens ?? 0); + // Stateful providers may report an absolute active-context checkpoint separately from their + // per-attempt usage. Split that checkpoint into input + output without adding output twice. + const inputTokens = usage.contextTotalTokens !== undefined + ? Math.max(0, usage.contextTotalTokens - usage.outputTokens) + : usage.inputTokens; const out: Record = { input_tokens: inputTokens, output_tokens: usage.outputTokens, - total_tokens: usage.contextInputTokens !== undefined - ? inputTokens + usage.outputTokens + total_tokens: usage.contextTotalTokens !== undefined + ? usage.contextTotalTokens : usageDisplayTotalTokens(usage) ?? inputTokens + usage.outputTokens, }; const inputDetails: Record = {}; diff --git a/src/types.ts b/src/types.ts index 773672be03..9df4c114c3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -309,12 +309,11 @@ export interface OcxUsage { inputTokens: number; outputTokens: number; /** - * Provider-private context pressure for clients that trigger compaction from Responses usage. - * This is intentionally separate from `inputTokens`: stateful providers such as Kiro bill/report - * only the current turn while also exposing the whole conversation's context occupancy. - * Persistence and cost accounting must continue to use `inputTokens`. + * Absolute active-context size after the response. Stateful providers can expose this separately + * from their per-attempt usage. Responses serialization derives the input side from + * `contextTotalTokens - outputTokens` so output is never added to an absolute checkpoint twice. */ - contextInputTokens?: number; + contextTotalTokens?: number; totalTokens?: number; cachedInputTokens?: number; cacheReadInputTokens?: number; diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index f46b4faaf8..831391a7aa 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -129,13 +129,13 @@ describe("Responses bridge reasoning and usage parity", () => { }); }); - test("context input override drives Responses compaction without changing billing usage", async () => { + test("absolute context total drives Responses compaction without double-counting output", async () => { const frames = await collectSse(bridgeToResponsesSSE(replay([ { type: "done", usage: { inputTokens: 58, - contextInputTokens: 226_000, + contextTotalTokens: 226_000, outputTokens: 12, estimated: true, }, @@ -144,12 +144,28 @@ describe("Responses bridge reasoning and usage parity", () => { const completed = frames.find(f => f.event === "response.completed")?.data.response as Record; expect(completed.usage).toEqual({ - input_tokens: 226_000, + input_tokens: 225_988, output_tokens: 12, - total_tokens: 226_012, + total_tokens: 226_000, }); }); + test("consecutive context checkpoints remain absolute instead of accumulating in the bridge", async () => { + const totals: number[] = []; + for (const [contextTotalTokens, outputTokens] of [[10_000, 42], [10_300, 20]] as const) { + const frames = await collectSse(bridgeToResponsesSSE(replay([{ + type: "done", + usage: { inputTokens: 1, contextTotalTokens, outputTokens, estimated: true }, + }]), "kiro/claude-opus-5")); + const completed = frames.find(f => f.event === "response.completed")?.data.response as Record; + const usage = completed.usage as Record; + expect(usage.input_tokens).toBe(contextTotalTokens - outputTokens); + expect(usage.total_tokens).toBe(contextTotalTokens); + totals.push(usage.total_tokens); + } + expect(totals).toEqual([10_000, 10_300]); + }); + test("Anthropic cache read and write tokens pass through Responses usage without re-adding", async () => { const frames = await collectSse(bridgeToResponsesSSE(replay([ { diff --git a/tests/kiro-stream.test.ts b/tests/kiro-stream.test.ts index 15d127608e..8d9aaa71a1 100644 --- a/tests/kiro-stream.test.ts +++ b/tests/kiro-stream.test.ts @@ -312,6 +312,29 @@ describe("kiro adapter — parseStream", () => { }); }); + test("bounded fallback uses its rebuilt context estimate for the final absolute checkpoint", async () => { + const firstText = "p".repeat(7000); + const finalText = "f".repeat(3500); + globalThis.fetch = (async () => new Response(streamOf(eventFrame({ content: finalText })))) as typeof fetch; + const adapter = createKiroAdapter(provider); + const request = await adapter.buildRequest(parsedWith([{ role: "user", content: "do it" }], [bashTool])); + const initialContextEstimate = request.usageLog?.inputTokens ?? 0; + + const events = await collectAdapterEvents(adapter.parseStream(new Response(streamOf( + eventFrame({ content: firstText }), + )))); + const done = events.at(-1); + expect(done?.type).toBe("done"); + const usage = done?.type === "done" ? done.usage : undefined; + expect(usage?.outputTokens).toBe(estimateTokens(firstText, "claude-sonnet-4.5") + estimateTokens(finalText, "claude-sonnet-4.5")); + expect(usage?.contextTotalTokens).toBeGreaterThan( + initialContextEstimate + Math.max( + estimateTokens(firstText, "claude-sonnet-4.5"), + estimateTokens(finalText, "claude-sonnet-4.5"), + ), + ); + }); + test("keeps a private-completion fallback after reasoning-only output as the final answer", async () => { globalThis.fetch = (async () => new Response(streamOf(...completionFrames("Done.")))) as typeof fetch; const adapter = createKiroAdapter(provider); @@ -777,7 +800,7 @@ describe("kiro adapter — parseStream", () => { ); expect(done).toEqual({ inputTokens: 15, - contextInputTokens: 200, + contextTotalTokens: 204, cachedInputTokens: 3, cacheReadInputTokens: 3, cacheCreationInputTokens: 2, @@ -837,7 +860,7 @@ describe("kiro adapter — parseStream", () => { expect(done.outputTokens).toBe(100); expect(done.totalTokens).toBeUndefined(); expect(done.estimated).toBe(true); - expect(done.contextInputTokens).toBe(50_000); + expect(done.contextTotalTokens).toBe(50_000); }); test("Kiro auto ignores provider-level context window and falls back to heuristic totals", async () => { @@ -852,6 +875,7 @@ describe("kiro adapter — parseStream", () => { expect(done.inputTokens).toBe(200); expect(done.outputTokens).toBe(100); expect(done.totalTokens).toBeUndefined(); + expect(done.contextTotalTokens).toBe(300); }); test("fresh payload includes history while usage counts only the current turn", async () => { @@ -877,7 +901,7 @@ describe("kiro adapter — parseStream", () => { expect(longBody.length).toBeGreaterThan(shortBody.length + 10_000); expect(longUsage.inputTokens).toBe(shortUsage.inputTokens); expect(longUsage.inputTokens).toBe(estimateTokens(latest, "claude-sonnet-4.5")); - expect(longUsage.contextInputTokens).toBeGreaterThan(shortUsage.contextInputTokens ?? 0); + expect(longUsage.contextTotalTokens).toBeGreaterThan(shortUsage.contextTotalTokens ?? 0); }); test("request log usage estimates the full Codex context while SSE usage stays current-turn", async () => { @@ -894,7 +918,7 @@ describe("kiro adapter — parseStream", () => { expect(usage.inputTokens).toBe(estimateTokens(latest, "claude-sonnet-4.5")); expect(request.usageLog?.estimated).toBe(true); expect(request.usageLog?.inputTokens).toBeGreaterThan(usage.inputTokens + 4000); - expect(usage.contextInputTokens).toBe(request.usageLog?.inputTokens); + expect(usage.contextTotalTokens).toBe((request.usageLog?.inputTokens ?? 0) + usage.outputTokens); }); test("resumed payload preserves the complete locally expanded history", async () => { diff --git a/tests/request-log.test.ts b/tests/request-log.test.ts index e7f86a3929..cf7689fc13 100644 --- a/tests/request-log.test.ts +++ b/tests/request-log.test.ts @@ -22,8 +22,14 @@ import { sealRequestAttemptIdentity, type RequestLogContext, } from "../src/server/request-log"; +import { bridgeToResponsesSSE } from "../src/bridge"; +import type { AdapterEvent } from "../src/types"; import type { PersistedUsageEntry } from "../src/usage/log"; +async function* replayAdapterEvents(events: AdapterEvent[]): AsyncGenerator { + for (const event of events) yield event; +} + function log(overrides: Partial): RequestLogEntry { return { requestId: "ocx-test", @@ -746,6 +752,36 @@ describe("request log metadata", () => { }); }); + test("deferred logging preserves a bridged Kiro absolute context checkpoint", async () => { + const entries: RequestLogEntry[] = []; + const body = bridgeToResponsesSSE(replayAdapterEvents([{ + type: "done", + usage: { + inputTokens: 58, + outputTokens: 100, + contextTotalTokens: 50_000, + estimated: true, + }, + }]), "kiro/claude-opus-5"); + const response = responseWithDeferredRequestLog( + new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }), + "ocx-test-kiro-context-checkpoint", + Date.now(), + { model: "kiro/claude-opus-5", provider: "kiro-p9d8524", usageLogInputTokens: 200 }, + entry => entries.push(entry), + ); + + const text = await response.text(); + expect(text).toContain('"input_tokens":49900'); + expect(text).toContain('"total_tokens":50000'); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + usageStatus: "estimated", + totalTokens: 50_000, + usage: { inputTokens: 49_900, outputTokens: 100, totalTokens: 50_000, estimated: true }, + }); + }); + test("final logging shows numeric Kiro estimates even when SSE usage is absent", async () => { const entries: RequestLogEntry[] = []; const response = responseWithDeferredRequestLog( From aec4df83392a53bc29f212062383949be0335706 Mon Sep 17 00:00:00 2001 From: coseung2 <120152615+coseung2@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:26:03 +0900 Subject: [PATCH 3/7] fix(kiro): estimate context from normalized payload --- src/adapters/kiro.ts | 35 ++++++++++++++++++++++++++++++++++- tests/kiro-stream.test.ts | 15 +++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index 3b3b22da5d..a90e9c7c29 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -134,6 +134,38 @@ function messageLogText(msg: OcxMessage): string { }).filter(Boolean).join("\n"); } +function estimateKiroPayloadInputTokens(payload: Record, modelId: string): number { + const conversationState = (payload as { + conversationState?: { + history?: KiroHistoryEntry[]; + currentMessage?: KiroHistoryEntry; + }; + }).conversationState; + if (!conversationState) return 0; + + const parts: string[] = []; + const entries = [ + ...(conversationState.history ?? []), + ...(conversationState.currentMessage ? [conversationState.currentMessage] : []), + ]; + for (const entry of entries) { + const user = entry.userInputMessage; + if (user) { + if (user.content) parts.push(user.content); + if (user.images?.length) parts.push(`[images:${user.images.length}]`); + const context = user.userInputMessageContext; + if (context?.tools?.length) parts.push(serializeForUsage(context.tools)); + if (context?.toolResults?.length) parts.push(serializeForUsage(context.toolResults)); + } + const assistant = entry.assistantResponseMessage; + if (assistant) { + if (assistant.content) parts.push(assistant.content); + if (assistant.toolUses?.length) parts.push(serializeForUsage(assistant.toolUses)); + } + } + return estimateTokens(parts.join("\n"), modelId); +} + function shouldCountStablePromptOverhead(parsed: OcxParsedRequest): boolean { return !parsed.previousResponseId && !parsed.context.messages.some(m => m.role === "assistant"); } @@ -1141,6 +1173,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter if (profileArn) headers["x-amzn-kiro-profile-arn"] = profileArn; const built = buildKiroPayload(parsed, profileArn, forcedCompletionMode); await normalizeKiroImages(built.payload); + const contextInputEstimate = estimateKiroPayloadInputTokens(built.payload, parsed.modelId); const body = JSON.stringify(built.payload); debugProviderDiagnostic("kiro", "request", { region, @@ -1164,7 +1197,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter conversationId: built.conversationId, completionMode: built.completionMode, inputTokens: estimateKiroInputTokens(parsed), - contextInputEstimate: estimateKiroLogInputTokens(parsed), + contextInputEstimate, }; }; diff --git a/tests/kiro-stream.test.ts b/tests/kiro-stream.test.ts index 8d9aaa71a1..930ec9858c 100644 --- a/tests/kiro-stream.test.ts +++ b/tests/kiro-stream.test.ts @@ -904,6 +904,21 @@ describe("kiro adapter — parseStream", () => { expect(longUsage.contextTotalTokens).toBeGreaterThan(shortUsage.contextTotalTokens ?? 0); }); + test("context pressure follows the normalized Kiro payload while logs retain dropped reasoning", async () => { + const privateReasoning = "private-plan-".repeat(1000); + const adapter = createKiroAdapter(provider); + const request = await adapter.buildRequest(parsedWith([ + { role: "user", content: "old question" }, + { role: "assistant", content: [{ type: "thinking", thinking: privateReasoning }] }, + { role: "user", content: "latest question" }, + ])); + const usage = await doneUsage(adapter, eventFrame({ content: "ok" })); + + expect(request.body).not.toContain(privateReasoning); + expect(request.usageLog?.inputTokens).toBeGreaterThan((usage.contextTotalTokens ?? 0) + 1000); + expect(usage.contextTotalTokens).toBeLessThan(1000); + }); + test("request log usage estimates the full Codex context while SSE usage stays current-turn", async () => { const latest = "please summarize recent commits"; const messages = [ From f44da01a9536b9b039f9ddfd4b82b476cf8b8a9b Mon Sep 17 00:00:00 2001 From: coseung2 <120152615+coseung2@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:40:04 +0900 Subject: [PATCH 4/7] fix(kiro): harden context usage bounds --- src/adapters/kiro.ts | 32 ++++++++++++++++++++--------- src/bridge.ts | 8 ++++++-- tests/bridge.test.ts | 22 ++++++++++++++++++++ tests/kiro-stream.test.ts | 43 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 12 deletions(-) diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index a90e9c7c29..9099bd2910 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -32,6 +32,7 @@ import type { import type { ProviderAdapter } from "./base"; import type { AdapterFetchContext, AdapterRequest } from "./base"; import { extractKiroImages, normalizeKiroImages, type KiroImage } from "./kiro-images"; +import { sniffImageDimensions } from "./anthropic-image-guard"; import { fetchKiroWithRetry } from "./kiro-retry"; import { convertKiroToolContext } from "./kiro-tools"; import { neutralizeIdentity } from "./identity"; @@ -134,6 +135,15 @@ function messageLogText(msg: OcxMessage): string { }).filter(Boolean).join("\n"); } +function estimateKiroImageTokens(image: KiroImage): number { + const dimensions = sniffImageDimensions(image.source.bytes); + if (dimensions) { + return Math.max(256, Math.ceil(dimensions.width * dimensions.height / 750)); + } + const decodedBytes = Math.floor(image.source.bytes.length * 3 / 4); + return Math.max(256, Math.ceil(decodedBytes / 512)); +} + function estimateKiroPayloadInputTokens(payload: Record, modelId: string): number { const conversationState = (payload as { conversationState?: { @@ -144,6 +154,7 @@ function estimateKiroPayloadInputTokens(payload: Record, modelI if (!conversationState) return 0; const parts: string[] = []; + let imageTokens = 0; const entries = [ ...(conversationState.history ?? []), ...(conversationState.currentMessage ? [conversationState.currentMessage] : []), @@ -152,7 +163,7 @@ function estimateKiroPayloadInputTokens(payload: Record, modelI const user = entry.userInputMessage; if (user) { if (user.content) parts.push(user.content); - if (user.images?.length) parts.push(`[images:${user.images.length}]`); + for (const image of user.images ?? []) imageTokens += estimateKiroImageTokens(image); const context = user.userInputMessageContext; if (context?.tools?.length) parts.push(serializeForUsage(context.tools)); if (context?.toolResults?.length) parts.push(serializeForUsage(context.toolResults)); @@ -163,7 +174,7 @@ function estimateKiroPayloadInputTokens(payload: Record, modelI if (assistant.toolUses?.length) parts.push(serializeForUsage(assistant.toolUses)); } } - return estimateTokens(parts.join("\n"), modelId); + return estimateTokens(parts.join("\n"), modelId) + imageTokens; } function shouldCountStablePromptOverhead(parsed: OcxParsedRequest): boolean { @@ -190,15 +201,11 @@ function estimateKiroLogInputTokens(parsed: OcxParsedRequest): number { return Math.max(estimateKiroInputTokens(parsed), estimateTokens(parts.join("\n"), parsed.modelId)); } -function configuredKiroContextWindow(provider: OcxProviderConfig, modelId: string | undefined): number | undefined { +function kiroUpstreamContextWindow(modelId: string | undefined): number | undefined { if (!modelId) return undefined; const normalizedModelId = normalizeKiroModelId(modelId); if (normalizedModelId === "auto") return undefined; - const window = - modelRecordValue(provider.modelContextWindows, modelId) - ?? modelRecordValue(provider.modelContextWindows, normalizedModelId) - ?? provider.contextWindow - ?? modelRecordValue(KIRO_MODEL_CONTEXT_WINDOWS, modelId) + const window = modelRecordValue(KIRO_MODEL_CONTEXT_WINDOWS, modelId) ?? modelRecordValue(KIRO_MODEL_CONTEXT_WINDOWS, normalizedModelId); return typeof window === "number" && Number.isFinite(window) && window > 0 ? window : undefined; } @@ -611,7 +618,12 @@ async function* parseKiroAttempt( const estimatedContextTotal = contextInputEstimate !== undefined ? contextInputEstimate + base.outputTokens : undefined; - const contextTotal = Math.max(estimatedContextTotal ?? 0, contextUsageTotalFloor() ?? 0); + const authoritativeTurnTotal = base.inputTokens + base.outputTokens; + const contextTotal = Math.max( + estimatedContextTotal ?? 0, + contextUsageTotalFloor() ?? 0, + authoritativeTurnTotal, + ); return contextTotal > 0 ? { ...base, contextTotalTokens: contextTotal } : base; }; @@ -1247,7 +1259,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter async buildRequest(parsed: OcxParsedRequest, incoming) { const built = await build(parsed); modelId = parsed.modelId; - contextWindow = configuredKiroContextWindow(provider, parsed.modelId); + contextWindow = kiroUpstreamContextWindow(parsed.modelId); inputTokens = built.inputTokens; contextInputEstimate = built.contextInputEstimate; toolNameMap = built.nameMap; diff --git a/src/bridge.ts b/src/bridge.ts index 6f38d92042..6e7025f1e6 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -30,10 +30,14 @@ function responsesUsage(usage: OcxUsage | undefined): Record { const inputDetails: Record = {}; if (usage.cachedInputTokens !== undefined) { // cached_tokens carries cache READS only, matching OpenAI semantics. - inputDetails.cached_tokens = usage.cachedInputTokens; + inputDetails.cached_tokens = Math.min(usage.cachedInputTokens, inputTokens); } if (usage.cacheCreationInputTokens !== undefined) { - inputDetails.cache_write_tokens = usage.cacheCreationInputTokens; + const cacheRead = inputDetails.cached_tokens ?? 0; + inputDetails.cache_write_tokens = Math.min( + usage.cacheCreationInputTokens, + Math.max(0, inputTokens - cacheRead), + ); } if (Object.keys(inputDetails).length > 0) { out.input_tokens_details = inputDetails; diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index 831391a7aa..00b419f187 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -190,6 +190,28 @@ describe("Responses bridge reasoning and usage parity", () => { }); }); + test("absolute context projection keeps cache details within derived input", async () => { + const frames = await collectSse(bridgeToResponsesSSE(replay([{ + type: "done", + usage: { + inputTokens: 200, + outputTokens: 10, + contextTotalTokens: 100, + cachedInputTokens: 150, + cacheReadInputTokens: 150, + cacheCreationInputTokens: 50, + }, + }]), "kiro/claude-opus-5")); + + const completed = frames.find(f => f.event === "response.completed")?.data.response as Record; + expect(completed.usage).toMatchObject({ + input_tokens: 90, + output_tokens: 10, + total_tokens: 100, + input_tokens_details: { cached_tokens: 90, cache_write_tokens: 0 }, + }); + }); + test("adapter heartbeat is non-visual in streaming and non-streaming responses", async () => { const events: AdapterEvent[] = [ { type: "heartbeat" }, diff --git a/tests/kiro-stream.test.ts b/tests/kiro-stream.test.ts index 930ec9858c..70fcf9dd46 100644 --- a/tests/kiro-stream.test.ts +++ b/tests/kiro-stream.test.ts @@ -809,6 +809,26 @@ describe("kiro adapter — parseStream", () => { }); }); + test("authoritative turn usage floors a smaller payload context estimate", async () => { + const adapter = createKiroAdapter(provider); + await adapter.buildRequest(parsedWith([{ role: "user", content: "hi" }])); + const done = await doneUsage( + adapter, + eventFrame({ content: "answer" }), + eventFrame({ + tokenUsage: { + uncachedInputTokens: 500, + outputTokens: 4, + totalTokens: 504, + }, + }, "metadataEvent"), + ); + + expect(done.inputTokens).toBe(500); + expect(done.outputTokens).toBe(4); + expect(done.contextTotalTokens).toBe(504); + }); + test("invalid provider token usage is rejected instead of replacing estimates", async () => { const adapter = createKiroAdapter(provider); await adapter.buildRequest(parsedWith([{ role: "user", content: "hi" }])); @@ -863,6 +883,14 @@ describe("kiro adapter — parseStream", () => { expect(done.contextTotalTokens).toBe(50_000); }); + test("Kiro context percentage uses the native model window instead of a configured client cap", async () => { + const adapter = createKiroAdapter({ ...provider, contextWindow: 1_000_000 }); + await adapter.buildRequest(parsedWith([{ role: "user", content: "hi" }], undefined, "claude-sonnet-4.5")); + const done = await doneUsage(adapter, eventFrame({ content: "ok" }), eventFrame({ contextUsagePercentage: 25 })); + + expect(done.contextTotalTokens).toBe(50_000); + }); + test("Kiro auto ignores provider-level context window and falls back to heuristic totals", async () => { const adapter = createKiroAdapter({ ...provider, contextWindow: 200_000 }); await adapter.buildRequest(parsedWith([{ role: "user", content: "x".repeat(700) }], undefined, "kiro-auto")); @@ -919,6 +947,21 @@ describe("kiro adapter — parseStream", () => { expect(usage.contextTotalTokens).toBeLessThan(1000); }); + test("normalized images contribute conservative context tokens", async () => { + const onePixelPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + const adapter = createKiroAdapter(provider); + await adapter.buildRequest(parsedWith([{ + role: "user", + content: [ + { type: "text", text: "inspect" }, + { type: "image", imageUrl: `data:image/png;base64,${onePixelPng}` }, + ], + }])); + const usage = await doneUsage(adapter, eventFrame({ content: "ok" })); + + expect(usage.contextTotalTokens).toBeGreaterThanOrEqual(256 + usage.outputTokens); + }); + test("request log usage estimates the full Codex context while SSE usage stays current-turn", async () => { const latest = "please summarize recent commits"; const messages = [ From 8f1dd35c87b36e28269bd8bfd8b9e6b07805babc Mon Sep 17 00:00:00 2001 From: coseung2 <120152615+coseung2@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:53:27 +0900 Subject: [PATCH 5/7] fix(kiro): preserve routed context growth --- src/adapters/kiro.ts | 44 +++++++++++++++++++++++++++++---------- tests/kiro-stream.test.ts | 29 ++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index 9099bd2910..5f6fbbdb16 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -509,13 +509,21 @@ interface KiroFallbackAttempt { conversationId: string; } +interface KiroContextWindowState { + value?: number; +} + type KiroFallbackFactory = ( conversationId: string | undefined, assistantText: string, sawReasoning: boolean, ) => Promise; -function mergeKiroUsage(first: OcxUsage | undefined, second: OcxUsage | undefined): OcxUsage | undefined { +function mergeKiroUsage( + first: OcxUsage | undefined, + second: OcxUsage | undefined, + preserveFirstContextGrowth = false, +): OcxUsage | undefined { if (!first) return second; if (!second) return first; const sumOptional = (key: keyof OcxUsage): number | undefined => { @@ -528,11 +536,20 @@ function mergeKiroUsage(first: OcxUsage | undefined, second: OcxUsage | undefine const totalTokens = typeof first.totalTokens === "number" && typeof second.totalTokens === "number" ? first.totalTokens + second.totalTokens : undefined; + const carriedContextTotal = preserveFirstContextGrowth && typeof first.contextTotalTokens === "number" + ? first.contextTotalTokens + second.outputTokens + : undefined; return { inputTokens: first.inputTokens + second.inputTokens, outputTokens: first.outputTokens + second.outputTokens, ...(typeof first.contextTotalTokens === "number" || typeof second.contextTotalTokens === "number" - ? { contextTotalTokens: Math.max(first.contextTotalTokens ?? 0, second.contextTotalTokens ?? 0) } + ? { + contextTotalTokens: Math.max( + first.contextTotalTokens ?? 0, + second.contextTotalTokens ?? 0, + carriedContextTotal ?? 0, + ), + } : {}), ...(totalTokens !== undefined ? { totalTokens } : {}), ...(sumOptional("cachedInputTokens") !== undefined ? { cachedInputTokens: sumOptional("cachedInputTokens") } : {}), @@ -573,7 +590,7 @@ async function* parseKiroAttempt( mode: KiroCompletionMode, modelId: string | undefined, inputTokens: number, - contextWindow: number | undefined, + contextWindowState: KiroContextWindowState, nameMap: Map | undefined, conversationId: string | undefined, previousAssistantText?: string, @@ -605,8 +622,8 @@ async function* parseKiroAttempt( returnedConversationId ? { kiro: { conversationId: returnedConversationId } } : undefined; const contextUsageTotalFloor = (): number | undefined => { - if (contextUsagePercentage === undefined || !contextWindow) return undefined; - const floor = Math.ceil(contextWindow * Math.min(contextUsagePercentage, 100) / 100); + if (contextUsagePercentage === undefined || !contextWindowState.value) return undefined; + const floor = Math.ceil(contextWindowState.value * Math.min(contextUsagePercentage, 100) / 100); return Number.isFinite(floor) && floor > 0 ? floor : undefined; }; const usage = (): OcxUsage => { @@ -804,6 +821,9 @@ async function* parseKiroAttempt( if (isValidKiroConversationId(ev.conversationId)) returnedConversationId = ev.conversationId; break; case "content": + if (ev.modelId) { + contextWindowState.value = kiroUpstreamContextWindow(ev.modelId) ?? contextWindowState.value; + } if (open) { open = null; return { assistantText, sawReasoning, terminal: protocolTerminal(kiroTruncationErrorMessage("content arrived before tool stop")) }; @@ -904,7 +924,7 @@ async function* parseKiroAttempt( if (contextUsagePercentage !== undefined) { debugProviderDiagnostic("kiro", "context_usage", { contextUsagePercentage, - ...(contextWindow ? { configuredContextWindow: contextWindow } : {}), + ...(contextWindowState.value ? { upstreamContextWindow: contextWindowState.value } : {}), }); } debugProviderDiagnostic("kiro", "attempt_complete", { @@ -1033,12 +1053,13 @@ export async function* parseKiroStream( fallbackFactory?: KiroFallbackFactory, contextInputEstimate?: number, ): AsyncGenerator { + const contextWindowState: KiroContextWindowState = { value: contextWindow }; const first = parseKiroAttempt( response, completionMode, modelId, inputTokens, - contextWindow, + contextWindowState, nameMap, conversationId, undefined, @@ -1103,7 +1124,7 @@ export async function* parseKiroStream( "text_fallback", modelId, fallback.inputTokens, - contextWindow, + contextWindowState, fallback.nameMap, fallback.conversationId, firstResult.assistantText, @@ -1119,7 +1140,8 @@ export async function* parseKiroStream( yield retryableKiroIncomplete( "empty_kiro_fallback", "Kiro's bounded completion retry ended without a terminal result", - mergeKiroUsage(firstResult.usage, secondResult.usage) ?? { inputTokens, outputTokens: 0, estimated: true }, + mergeKiroUsage(firstResult.usage, secondResult.usage, Boolean(firstResult.assistantText)) + ?? { inputTokens, outputTokens: 0, estimated: true }, secondResult.providerState ?? firstResult.providerState, ); return; @@ -1127,7 +1149,7 @@ export async function* parseKiroStream( if (secondResult.terminal.type === "done" || secondResult.terminal.type === "incomplete") { yield { ...secondResult.terminal, - usage: mergeKiroUsage(firstResult.usage, secondResult.terminal.usage), + usage: mergeKiroUsage(firstResult.usage, secondResult.terminal.usage, Boolean(firstResult.assistantText)), providerState: secondResult.terminal.providerState ?? firstResult.providerState, }; return; @@ -1135,7 +1157,7 @@ export async function* parseKiroStream( yield { ...secondResult.terminal, ...(secondResult.terminal.type === "error" - ? { usage: mergeKiroUsage(firstResult.usage, secondResult.terminal.usage) } + ? { usage: mergeKiroUsage(firstResult.usage, secondResult.terminal.usage, Boolean(firstResult.assistantText)) } : {}), }; } diff --git a/tests/kiro-stream.test.ts b/tests/kiro-stream.test.ts index 70fcf9dd46..97d8e4ad7b 100644 --- a/tests/kiro-stream.test.ts +++ b/tests/kiro-stream.test.ts @@ -335,6 +335,23 @@ describe("kiro adapter — parseStream", () => { ); }); + test("bounded fallback preserves definite growth after an upstream context checkpoint", async () => { + const finalText = "f".repeat(3500); + const finalOutputTokens = estimateTokens(finalText, "claude-sonnet-4.5"); + globalThis.fetch = (async () => new Response(streamOf(eventFrame({ content: finalText })))) as typeof fetch; + const adapter = createKiroAdapter(provider); + await adapter.buildRequest(parsedWith([{ role: "user", content: "do it" }], [bashTool])); + + const events = await collectAdapterEvents(adapter.parseStream(new Response(streamOf( + eventFrame({ content: "I am checking." }), + eventFrame({ contextUsagePercentage: 25 }), + )))); + const done = events.at(-1); + + expect(done?.type).toBe("done"); + if (done?.type === "done") expect(done.usage?.contextTotalTokens).toBe(50_000 + finalOutputTokens); + }); + test("keeps a private-completion fallback after reasoning-only output as the final answer", async () => { globalThis.fetch = (async () => new Response(streamOf(...completionFrames("Done.")))) as typeof fetch; const adapter = createKiroAdapter(provider); @@ -906,6 +923,18 @@ describe("kiro adapter — parseStream", () => { expect(done.contextTotalTokens).toBe(300); }); + test("Kiro auto uses the concrete response model to decode context percentage", async () => { + const adapter = createKiroAdapter(provider); + await adapter.buildRequest(parsedWith([{ role: "user", content: "hi" }], undefined, "kiro-auto")); + const done = await doneUsage( + adapter, + eventFrame({ content: "ok", modelId: "claude-sonnet-4.5" }), + eventFrame({ contextUsagePercentage: 25 }), + ); + + expect(done.contextTotalTokens).toBe(50_000); + }); + test("fresh payload includes history while usage counts only the current turn", async () => { const latest = "please summarize recent commits"; const shortMessages = [ From 7e21e64016968ce0e6e1d12afbbe517d0063d925 Mon Sep 17 00:00:00 2001 From: coseung2 <120152615+coseung2@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:02:24 +0900 Subject: [PATCH 6/7] fix(kiro): preserve merged usage identity --- src/adapters/kiro.ts | 4 +++- tests/kiro-stream.test.ts | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index 5f6fbbdb16..4eed41445f 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -539,15 +539,17 @@ function mergeKiroUsage( const carriedContextTotal = preserveFirstContextGrowth && typeof first.contextTotalTokens === "number" ? first.contextTotalTokens + second.outputTokens : undefined; + const combinedOutputTokens = first.outputTokens + second.outputTokens; return { inputTokens: first.inputTokens + second.inputTokens, - outputTokens: first.outputTokens + second.outputTokens, + outputTokens: combinedOutputTokens, ...(typeof first.contextTotalTokens === "number" || typeof second.contextTotalTokens === "number" ? { contextTotalTokens: Math.max( first.contextTotalTokens ?? 0, second.contextTotalTokens ?? 0, carriedContextTotal ?? 0, + combinedOutputTokens, ), } : {}), diff --git a/tests/kiro-stream.test.ts b/tests/kiro-stream.test.ts index 97d8e4ad7b..794d83345e 100644 --- a/tests/kiro-stream.test.ts +++ b/tests/kiro-stream.test.ts @@ -388,6 +388,24 @@ describe("kiro adapter — parseStream", () => { expect(events.at(-1)).toMatchObject({ type: "done", endTurn: true }); }); + test("reasoning-only fallback keeps absolute context above combined output", async () => { + const reasoning = "r".repeat(14_000); + const finalText = "f".repeat(14_000); + globalThis.fetch = (async () => new Response(streamOf(eventFrame({ content: finalText })))) as typeof fetch; + const adapter = createKiroAdapter(provider); + await adapter.buildRequest(parsedWith([{ role: "user", content: "solve" }], [bashTool])); + + const events = await collectAdapterEvents(adapter.parseStream(new Response(streamOf( + eventFrame({ content: `${reasoning}` }), + )))); + const done = events.at(-1); + + expect(done?.type).toBe("done"); + if (done?.type === "done") { + expect(done.usage?.contextTotalTokens).toBeGreaterThanOrEqual(done.usage?.outputTokens ?? 0); + } + }); + test("normal Responses cancellation aborts the adapter-owned fallback without another replay", async () => { const abort = new AbortController(); let fetches = 0; From e78e84636b799e37ac985e83781190bda6539e0c Mon Sep 17 00:00:00 2001 From: coseung2 <120152615+coseung2@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:16:43 +0900 Subject: [PATCH 7/7] fix(kiro): use provider token ratio for GPT routes --- src/adapters/kiro.ts | 12 ++++++++---- tests/kiro-stream.test.ts | 10 ++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/adapters/kiro.ts b/src/adapters/kiro.ts index 4eed41445f..9e43e891c0 100644 --- a/src/adapters/kiro.ts +++ b/src/adapters/kiro.ts @@ -144,6 +144,10 @@ function estimateKiroImageTokens(image: KiroImage): number { return Math.max(256, Math.ceil(decodedBytes / 512)); } +function estimateKiroTokens(text: string, modelId?: string): number { + return estimateTokens(text, modelId ? `kiro/${modelId}` : "kiro"); +} + function estimateKiroPayloadInputTokens(payload: Record, modelId: string): number { const conversationState = (payload as { conversationState?: { @@ -174,7 +178,7 @@ function estimateKiroPayloadInputTokens(payload: Record, modelI if (assistant.toolUses?.length) parts.push(serializeForUsage(assistant.toolUses)); } } - return estimateTokens(parts.join("\n"), modelId) + imageTokens; + return estimateKiroTokens(parts.join("\n"), modelId) + imageTokens; } function shouldCountStablePromptOverhead(parsed: OcxParsedRequest): boolean { @@ -191,14 +195,14 @@ function estimateKiroInputTokens(parsed: OcxParsedRequest): number { if (parsed.context.tools?.length) parts.push(serializeForUsage(parsed.context.tools)); } - return estimateTokens(parts.join("\n"), parsed.modelId); + return estimateKiroTokens(parts.join("\n"), parsed.modelId); } function estimateKiroLogInputTokens(parsed: OcxParsedRequest): number { const parts = parsed.context.messages.map(messageLogText).filter(Boolean); if (parsed.context.systemPrompt?.length) parts.push(...parsed.context.systemPrompt); if (parsed.context.tools?.length) parts.push(serializeForUsage(parsed.context.tools)); - return Math.max(estimateKiroInputTokens(parsed), estimateTokens(parts.join("\n"), parsed.modelId)); + return Math.max(estimateKiroInputTokens(parsed), estimateKiroTokens(parts.join("\n"), parsed.modelId)); } function kiroUpstreamContextWindow(modelId: string | undefined): number | undefined { @@ -631,7 +635,7 @@ async function* parseKiroAttempt( const usage = (): OcxUsage => { const base = authoritativeUsage ?? { inputTokens, - outputTokens: estimateTokens(outputChars, modelId), + outputTokens: estimateKiroTokens(outputChars, modelId), estimated: true, }; const estimatedContextTotal = contextInputEstimate !== undefined diff --git a/tests/kiro-stream.test.ts b/tests/kiro-stream.test.ts index 794d83345e..c2e9107cbc 100644 --- a/tests/kiro-stream.test.ts +++ b/tests/kiro-stream.test.ts @@ -953,6 +953,16 @@ describe("kiro adapter — parseStream", () => { expect(done.contextTotalTokens).toBe(50_000); }); + test("Kiro GPT routes use the Kiro token ratio without context percentage", async () => { + const adapter = createKiroAdapter(provider); + await adapter.buildRequest(parsedWith([{ role: "user", content: "x".repeat(3500) }], undefined, "gpt-5.6-sol")); + const done = await doneUsage(adapter, eventFrame({ content: "y".repeat(3500) })); + + expect(done.inputTokens).toBe(1000); + expect(done.outputTokens).toBe(1000); + expect(done.contextTotalTokens).toBe(2000); + }); + test("fresh payload includes history while usage counts only the current turn", async () => { const latest = "please summarize recent commits"; const shortMessages = [