diff --git a/.changeset/calm-usage-count.md b/.changeset/calm-usage-count.md new file mode 100644 index 0000000000..64c1ef96d6 --- /dev/null +++ b/.changeset/calm-usage-count.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Prevent partial OpenAI-compatible usage reports from producing negative uncached input token counts. diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts index 68eed46d31..bf94052d56 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts @@ -172,7 +172,7 @@ export function extractUsage(usage: unknown): TokenUsage | null { } return { - inputOther: promptTokens - cached, + inputOther: Math.max(promptTokens - cached, 0), output: completionTokens, inputCacheRead: cached, inputCacheCreation: 0, diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts index dfb482f2dd..9cb4047683 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts @@ -705,7 +705,7 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { const details = readObjectField(usage, 'input_tokens_details'); const cached = details ? (readNumberField(details, 'cached_tokens') ?? 0) : 0; this._usage = { - inputOther: inputTokens - cached, + inputOther: Math.max(inputTokens - cached, 0), output: outputTokens, inputCacheRead: cached, inputCacheCreation: 0, diff --git a/packages/agent-core-v2/test/kosong/provider/composition.test.ts b/packages/agent-core-v2/test/kosong/provider/composition.test.ts index 331281ef5a..7c68407e40 100644 --- a/packages/agent-core-v2/test/kosong/provider/composition.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/composition.test.ts @@ -57,7 +57,11 @@ import { import '#/kosong/provider/bases/google-genai/index'; import { GoogleGenAIChatProvider } from '#/kosong/provider/bases/google-genai/google-genai'; import '#/kosong/provider/bases/openai/index'; -import { OpenAIResponsesChatProvider } from '#/kosong/provider/bases/openai/openai-responses'; +import { extractUsage } from '#/kosong/provider/bases/openai/openai-common'; +import { + OpenAIResponsesChatProvider, + OpenAIResponsesStreamedMessage, +} from '#/kosong/provider/bases/openai/openai-responses'; import { OpenAILegacyChatProvider } from '#/kosong/provider/bases/openai/openai-legacy'; import { ProtocolAdapterRegistry } from '#/kosong/provider/protocolAdapterRegistry'; import { @@ -662,6 +666,46 @@ async function captureResponsesBody( return captured; } +describe('OpenAI usage normalization', () => { + it('keeps Chat Completions uncached input non-negative when cached tokens lack a prompt total', () => { + expect( + extractUsage({ + completion_tokens: 5, + prompt_tokens_details: { cached_tokens: 10 }, + }), + ).toEqual({ + inputOther: 0, + output: 5, + inputCacheRead: 10, + inputCacheCreation: 0, + }); + }); + + it('keeps Responses uncached input non-negative when cached tokens lack an input total', async () => { + const stream = new OpenAIResponsesStreamedMessage( + { + id: 'resp_partial_usage', + status: 'completed', + output: [], + usage: { + output_tokens: 5, + input_tokens_details: { cached_tokens: 10 }, + }, + }, + false, + ); + + await drain(stream); + + expect(stream.usage).toEqual({ + inputOther: 0, + output: 5, + inputCacheRead: 10, + inputCacheCreation: 0, + }); + }); +}); + describe('per-turn intent wire encoding (behavior probes)', () => { it('encodes cacheKey + thinking + budget on the Kimi wire as prompt_cache_key + expanded thinking, never reasoning_effort', async () => { const provider = registry.createChatProvider({ diff --git a/packages/kosong/src/providers/openai-common.ts b/packages/kosong/src/providers/openai-common.ts index 5f94a9fc66..15a0f086ba 100644 --- a/packages/kosong/src/providers/openai-common.ts +++ b/packages/kosong/src/providers/openai-common.ts @@ -190,7 +190,7 @@ export function extractUsage(usage: unknown): TokenUsage | null { } return { - inputOther: promptTokens - cached, + inputOther: Math.max(promptTokens - cached, 0), output: completionTokens, inputCacheRead: cached, inputCacheCreation: 0, diff --git a/packages/kosong/src/providers/openai-responses.ts b/packages/kosong/src/providers/openai-responses.ts index 66770f1cdd..bcb559c7e7 100644 --- a/packages/kosong/src/providers/openai-responses.ts +++ b/packages/kosong/src/providers/openai-responses.ts @@ -718,7 +718,7 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { const details = readObjectField(usage, 'input_tokens_details'); const cached = details ? (readNumberField(details, 'cached_tokens') ?? 0) : 0; this._usage = { - inputOther: inputTokens - cached, + inputOther: Math.max(inputTokens - cached, 0), output: outputTokens, inputCacheRead: cached, inputCacheCreation: 0, diff --git a/packages/kosong/test/kimi.test.ts b/packages/kosong/test/kimi.test.ts index 23f3fe2475..d076b3f2ca 100644 --- a/packages/kosong/test/kimi.test.ts +++ b/packages/kosong/test/kimi.test.ts @@ -2162,6 +2162,19 @@ describe('extractUsage', () => { }); }); + it('keeps uncached input non-negative when cached tokens are reported without prompt tokens', () => { + const usage = extractUsage({ + completion_tokens: 20, + prompt_tokens_details: { cached_tokens: 50 }, + }); + expect(usage).toEqual({ + inputOther: 0, + output: 20, + inputCacheRead: 50, + inputCacheCreation: 0, + }); + }); + it('returns null for null/undefined', () => { const undef: unknown = undefined; expect(extractUsage(null)).toBeNull(); diff --git a/packages/kosong/test/openai-responses.test.ts b/packages/kosong/test/openai-responses.test.ts index c7f9b3ee97..cb167f2bbf 100644 --- a/packages/kosong/test/openai-responses.test.ts +++ b/packages/kosong/test/openai-responses.test.ts @@ -1205,6 +1205,32 @@ describe('OpenAIResponsesChatProvider', () => { }); }); + it('keeps uncached input non-negative when cached tokens are reported without input tokens', async () => { + const stream = new OpenAIResponsesStreamedMessage( + { + id: 'resp_partial_usage', + status: 'completed', + output: [], + usage: { + output_tokens: 5, + input_tokens_details: { cached_tokens: 10 }, + }, + }, + false, + ); + + for await (const part of stream) { + void part; + } + + expect(stream.usage).toEqual({ + inputOther: 0, + output: 5, + inputCacheRead: 10, + inputCacheCreation: 0, + }); + }); + it('yields ToolCall from non-stream response with function_call output item', async () => { const provider = createProvider(); (provider as any)._stream = false;