Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions packages/cli/src/ui/hooks/useAgentStreamingState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,13 @@ export function useAgentStreamingState(

// Dedicated listener for usage metadata — updates React state directly
// so the token count is available immediately (even if no other event
// triggers a re-render). Prefers totalTokenCount (prompt + output)
// because output becomes history for the next round, matching
// geminiChat.ts.
// triggers a re-render). Context usage tracks prompt size; output
// isn't in history yet.
const usageHandler = (event: {
usage?: { totalTokenCount?: number; promptTokenCount?: number };
}) => {
const count =
event?.usage?.totalTokenCount ?? event?.usage?.promptTokenCount;
event?.usage?.promptTokenCount ?? event?.usage?.totalTokenCount;
if (typeof count === 'number' && count > 0) {
setLastPromptTokenCount(count);
}
Expand Down
1 change: 0 additions & 1 deletion packages/cli/src/ui/hooks/useSessionPicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,5 +275,4 @@ describe('useSessionPicker multi-select state', () => {
expect(onConfirmMulti).toHaveBeenCalledWith(['s2']);
expect(onSelect).not.toHaveBeenCalled();
});

});
9 changes: 5 additions & 4 deletions packages/core/src/agents/runtime/agent-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1637,10 +1637,11 @@ Important Rules:
const thoughtTok = Number(usage.thoughtsTokenCount || 0);
const cachedTok = Number(usage.cachedContentTokenCount || 0);
const totalTok = Number(usage.totalTokenCount || 0);
// Prefer totalTokenCount (prompt + output) for context usage — the
// output from this round becomes history for the next, matching
// the approach in geminiChat.ts.
const contextTok = isFinite(totalTok) && totalTok > 0 ? totalTok : inTok;
// Context usage tracks prompt size; output isn't in history yet.
// Guard against malformed provider values (`Infinity`/`NaN`) so the
// downstream compaction math doesn't get poisoned — `Infinity` is
// truthy and would otherwise overwrite a valid prior reading.
const contextTok = inTok || totalTok;
Comment thread
tanzhenxin marked this conversation as resolved.
if (isFinite(contextTok) && contextTok > 0) {
this.lastPromptTokenCount = contextTok;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2269,8 +2269,8 @@ describe('AnthropicContentGenerator', () => {
delta: { stop_reason: 'end_turn' },
usage: {
output_tokens: 5,
input_tokens: 7,
cache_read_input_tokens: 2,
input_tokens: 2,
cache_read_input_tokens: 7,
},
};
yield { type: 'message_stop' };
Expand Down Expand Up @@ -2331,11 +2331,88 @@ describe('AnthropicContentGenerator', () => {
const last = chunks[chunks.length - 1]!;
expect(last.candidates?.[0]?.finishReason).toBe(FinishReason.STOP);
expect(last.usageMetadata).toEqual({
cachedContentTokenCount: 2,
promptTokenCount: 9, // cached(2) + input(7)
cachedContentTokenCount: 7,
promptTokenCount: 9, // input(2) + cached(7) — Anthropic-true (input < cache_read)
candidatesTokenCount: 5,
totalTokenCount: 14,
});
});

it('accumulates cache_creation_input_tokens through the streaming pipeline', async () => {
// Real Anthropic mid-conversation: `message_start` reports the warm
// prefix bucket (cache_read), the new cache write bucket
// (cache_creation), and the fresh tail (input). The streaming
// accumulator must hold onto cache_creation alongside the other
// buckets so the final chunk's usageMetadata reflects the full
// prompt size — otherwise the cache_creation portion is silently
// dropped from the displayed total and the Footer under-reports by
// exactly that many tokens.
const { AnthropicContentGenerator } = await importGenerator();
anthropicState.createImpl.mockResolvedValue(
(async function* () {
yield {
type: 'message_start',
message: {
id: 'msg-1',
model: 'claude-test',
usage: {
input_tokens: 2_500,
cache_read_input_tokens: 32_088,
cache_creation_input_tokens: 8_700,
},
},
};
yield {
type: 'content_block_start',
index: 0,
content_block: { type: 'text' },
};
yield {
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text: 'ok' },
};
yield { type: 'content_block_stop', index: 0 };
yield {
type: 'message_delta',
delta: { stop_reason: 'end_turn' },
usage: { output_tokens: 400 },
};
yield { type: 'message_stop' };
})(),
);

const generator = new AnthropicContentGenerator(
{
model: 'claude-test',
apiKey: 'test-key',
timeout: 10_000,
maxRetries: 2,
samplingParams: { max_tokens: 123 },
schemaCompliance: 'auto',
},
mockConfig,
);

const stream = await generator.generateContentStream({
model: 'models/ignored',
contents: 'Hello',
} as unknown as GenerateContentParameters);

const chunks: GenerateContentResponse[] = [];
for await (const chunk of stream) {
chunks.push(chunk);
}

const last = chunks[chunks.length - 1]!;
expect(last.usageMetadata).toEqual({
// Sum of all three prompt buckets: 2,500 + 32,088 + 8,700 = 43,288.
// cachedContentTokenCount reports cache_read only.
promptTokenCount: 43_288,
candidatesTokenCount: 400,
totalTokenCount: 43_688,
cachedContentTokenCount: 32_088,
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type RawMessageStreamEvent = Anthropic.RawMessageStreamEvent;
import { RequestTokenEstimator } from '../../utils/request-tokenizer/index.js';
import { safeJsonParse } from '../../utils/safeJsonParse.js';
import { AnthropicContentConverter } from './converter.js';
import { buildAnthropicUsageMetadata } from './usage.js';
import {
buildRuntimeFetchOptions,
redactProxyError,
Expand Down Expand Up @@ -770,6 +771,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
let messageId: string | undefined;
let model = this.contentGeneratorConfig.model;
let cachedTokens = 0;
let cacheCreationTokens = 0;
let promptTokens = 0;
let completionTokens = 0;
let finishReason: string | undefined;
Expand All @@ -784,6 +786,9 @@ export class AnthropicContentGenerator implements ContentGenerator {
model = event.message.model ?? model;
cachedTokens =
event.message.usage?.cache_read_input_tokens ?? cachedTokens;
cacheCreationTokens =
event.message.usage?.cache_creation_input_tokens ??
cacheCreationTokens;
promptTokens = event.message.usage?.input_tokens ?? promptTokens;
break;
}
Expand Down Expand Up @@ -910,19 +915,25 @@ export class AnthropicContentGenerator implements ContentGenerator {
cachedTokens = cacheRead;
}
}
if (usageRecord?.['cache_creation_input_tokens'] !== undefined) {
const cacheCreate = usageRecord['cache_creation_input_tokens'];
if (typeof cacheCreate === 'number') {
cacheCreationTokens = cacheCreate;
}
}

if (finishReason || event.usage) {
const chunk = this.buildGeminiChunk(
undefined,
messageId,
model,
finishReason,
{
cachedContentTokenCount: cachedTokens,
promptTokenCount: cachedTokens + promptTokens,
candidatesTokenCount: completionTokens,
totalTokenCount: cachedTokens + promptTokens + completionTokens,
},
buildAnthropicUsageMetadata({
inputTokens: promptTokens,
cacheReadTokens: cachedTokens,
cacheCreationTokens,
outputTokens: completionTokens,
}),
);
collectedResponses.push(chunk);
yield chunk;
Expand All @@ -936,12 +947,12 @@ export class AnthropicContentGenerator implements ContentGenerator {
messageId,
model,
finishReason,
{
cachedContentTokenCount: cachedTokens,
promptTokenCount: cachedTokens + promptTokens,
candidatesTokenCount: completionTokens,
totalTokenCount: cachedTokens + promptTokens + completionTokens,
},
buildAnthropicUsageMetadata({
inputTokens: promptTokens,
cacheReadTokens: cachedTokens,
cacheCreationTokens,
outputTokens: completionTokens,
}),
);
collectedResponses.push(chunk);
yield chunk;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1306,6 +1306,7 @@ describe('AnthropicContentConverter', () => {
promptTokenCount: 3,
candidatesTokenCount: 5,
totalTokenCount: 8,
cachedContentTokenCount: 0,
});

const parts = response.candidates?.[0]?.content?.parts || [];
Expand All @@ -1332,6 +1333,35 @@ describe('AnthropicContentConverter', () => {
{ functionCall: { id: 't1', name: 'tool', args: { x: 1 } } },
]);
});

it('forwards cache_read_input_tokens and cache_creation_input_tokens through to usageMetadata', () => {
// A real Anthropic mid-conversation response carries all three prompt
// buckets simultaneously: `input_tokens` (the non-cached tail),
// `cache_read_input_tokens` (the warm prefix served from cache), and
// `cache_creation_input_tokens` (the new region being written). The
// converter must forward both cache fields so the normalizer can sum
// them — dropping either silently undercounts the Footer reading by
// the size of the dropped bucket.
const response = converter.convertAnthropicResponseToGemini({
id: 'msg-1',
model: 'claude-test',
stop_reason: 'end_turn',
content: [{ type: 'text', text: 'ok' }],
usage: {
input_tokens: 2_500,
cache_read_input_tokens: 32_088,
cache_creation_input_tokens: 8_700,
output_tokens: 400,
},
} as unknown as Anthropic.Message);

expect(response.usageMetadata).toEqual({
promptTokenCount: 43_288,
candidatesTokenCount: 400,
totalTokenCount: 43_688,
cachedContentTokenCount: 32_088,
});
});
});

describe('mapAnthropicFinishReasonToGemini', () => {
Expand Down
14 changes: 7 additions & 7 deletions packages/core/src/core/anthropicContentGenerator/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
ToolListUnion,
} from '@google/genai';
import { FinishReason, GenerateContentResponse } from '@google/genai';
import { buildAnthropicUsageMetadata } from './usage.js';
import type Anthropic from '@anthropic-ai/sdk';
import { safeJsonParse } from '../../utils/safeJsonParse.js';
import {
Expand Down Expand Up @@ -325,13 +326,12 @@ export class AnthropicContentConverter {
geminiResponse.promptFeedback = { safetyRatings: [] };

if (response.usage) {
const promptTokens = response.usage.input_tokens || 0;
const completionTokens = response.usage.output_tokens || 0;
geminiResponse.usageMetadata = {
promptTokenCount: promptTokens,
candidatesTokenCount: completionTokens,
totalTokenCount: promptTokens + completionTokens,
};
geminiResponse.usageMetadata = buildAnthropicUsageMetadata({
inputTokens: response.usage.input_tokens || 0,
cacheReadTokens: response.usage.cache_read_input_tokens || 0,
cacheCreationTokens: response.usage.cache_creation_input_tokens || 0,
outputTokens: response.usage.output_tokens || 0,
});
}

return geminiResponse;
Expand Down
Loading
Loading