fix(ui): Track last prompt token count in processStream (#8650)

This commit is contained in:
Sandy Tao
2025-09-18 11:46:56 -07:00
committed by GitHub
parent da14875c6a
commit 2fd86c1904
8 changed files with 72 additions and 24 deletions
@@ -16,7 +16,7 @@ vi.mock('@google/gemini-cli-core', async () => {
return { return {
...actual, ...actual,
uiTelemetryService: { uiTelemetryService: {
resetLastPromptTokenCount: vi.fn(), setLastPromptTokenCount: vi.fn(),
}, },
}; };
}); });
@@ -57,9 +57,8 @@ describe('clearCommand', () => {
expect(mockContext.ui.setDebugMessage).toHaveBeenCalledTimes(1); expect(mockContext.ui.setDebugMessage).toHaveBeenCalledTimes(1);
expect(mockResetChat).toHaveBeenCalledTimes(1); expect(mockResetChat).toHaveBeenCalledTimes(1);
expect(uiTelemetryService.resetLastPromptTokenCount).toHaveBeenCalledTimes( expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(0);
1, expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(1);
);
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1); expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
// Check the order of operations. // Check the order of operations.
@@ -67,7 +66,7 @@ describe('clearCommand', () => {
.invocationCallOrder[0]; .invocationCallOrder[0];
const resetChatOrder = mockResetChat.mock.invocationCallOrder[0]; const resetChatOrder = mockResetChat.mock.invocationCallOrder[0];
const resetTelemetryOrder = ( const resetTelemetryOrder = (
uiTelemetryService.resetLastPromptTokenCount as Mock uiTelemetryService.setLastPromptTokenCount as Mock
).mock.invocationCallOrder[0]; ).mock.invocationCallOrder[0];
const clearOrder = (mockContext.ui.clear as Mock).mock const clearOrder = (mockContext.ui.clear as Mock).mock
.invocationCallOrder[0]; .invocationCallOrder[0];
@@ -94,9 +93,8 @@ describe('clearCommand', () => {
'Clearing terminal.', 'Clearing terminal.',
); );
expect(mockResetChat).not.toHaveBeenCalled(); expect(mockResetChat).not.toHaveBeenCalled();
expect(uiTelemetryService.resetLastPromptTokenCount).toHaveBeenCalledTimes( expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(0);
1, expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(1);
);
expect(nullConfigContext.ui.clear).toHaveBeenCalledTimes(1); expect(nullConfigContext.ui.clear).toHaveBeenCalledTimes(1);
}); });
}); });
+1 -1
View File
@@ -24,7 +24,7 @@ export const clearCommand: SlashCommand = {
context.ui.setDebugMessage('Clearing terminal.'); context.ui.setDebugMessage('Clearing terminal.');
} }
uiTelemetryService.resetLastPromptTokenCount(); uiTelemetryService.setLastPromptTokenCount(0);
context.ui.clear(); context.ui.clear();
}, },
}; };
+19
View File
@@ -42,6 +42,7 @@ import { tokenLimit } from './tokenLimits.js';
import { ideContextStore } from '../ide/ideContext.js'; import { ideContextStore } from '../ide/ideContext.js';
import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js'; import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js';
import type { ModelRouterService } from '../routing/modelRouterService.js'; import type { ModelRouterService } from '../routing/modelRouterService.js';
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
// Mock fs module to prevent actual file system operations during tests // Mock fs module to prevent actual file system operations during tests
const mockFileSystem = new Map<string, string>(); const mockFileSystem = new Map<string, string>();
@@ -111,6 +112,11 @@ vi.mock('../telemetry/index.js', () => ({
logApiError: vi.fn(), logApiError: vi.fn(),
})); }));
vi.mock('../ide/ideContext.js'); vi.mock('../ide/ideContext.js');
vi.mock('../telemetry/uiTelemetry.js', () => ({
uiTelemetryService: {
setLastPromptTokenCount: vi.fn(),
},
}));
/** /**
* Array.fromAsync ponyfill, which will be available in es 2024. * Array.fromAsync ponyfill, which will be available in es 2024.
@@ -243,6 +249,7 @@ describe('Gemini Client (client.ts)', () => {
let mockGenerateContentFn: Mock; let mockGenerateContentFn: Mock;
beforeEach(async () => { beforeEach(async () => {
vi.resetAllMocks(); vi.resetAllMocks();
vi.mocked(uiTelemetryService.setLastPromptTokenCount).mockClear();
mockGenerateContentFn = vi.fn().mockResolvedValue({ mockGenerateContentFn = vi.fn().mockResolvedValue({
candidates: [{ content: { parts: [{ text: '{"key": "value"}' }] } }], candidates: [{ content: { parts: [{ text: '{"key": "value"}' }] } }],
@@ -440,6 +447,12 @@ describe('Gemini Client (client.ts)', () => {
newTokenCount: 5000, newTokenCount: 5000,
originalTokenCount: 1000, originalTokenCount: 1000,
}); });
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(
5000,
);
expect(
uiTelemetryService.setLastPromptTokenCount,
).toHaveBeenCalledTimes(1);
}); });
it('does not manipulate the source chat', async () => { it('does not manipulate the source chat', async () => {
@@ -559,6 +572,12 @@ describe('Gemini Client (client.ts)', () => {
tokens_after: newTokenCount, tokens_after: newTokenCount,
}), }),
); );
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(
newTokenCount,
);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(
1,
);
}); });
it('should trigger summarization if token count is at threshold with contextPercentageThreshold setting', async () => { it('should trigger summarization if token count is at threshold with contextPercentageThreshold setting', async () => {
+3
View File
@@ -49,6 +49,7 @@ import {
import type { IdeContext, File } from '../ide/types.js'; import type { IdeContext, File } from '../ide/types.js';
import { handleFallback } from '../fallback/handler.js'; import { handleFallback } from '../fallback/handler.js';
import type { RoutingContext } from '../routing/routingStrategy.js'; import type { RoutingContext } from '../routing/routingStrategy.js';
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
export function isThinkingSupported(model: string) { export function isThinkingSupported(model: string) {
return model.startsWith('gemini-2.5') || model === DEFAULT_GEMINI_MODEL_AUTO; return model.startsWith('gemini-2.5') || model === DEFAULT_GEMINI_MODEL_AUTO;
@@ -772,6 +773,8 @@ export class GeminiClient {
}; };
} }
uiTelemetryService.setLastPromptTokenCount(newTokenCount);
logChatCompression( logChatCompression(
this.config, this.config,
makeChatCompressionEvent({ makeChatCompressionEvent({
+24
View File
@@ -24,6 +24,7 @@ import { AuthType } from './contentGenerator.js';
import { type RetryOptions } from '../utils/retry.js'; import { type RetryOptions } from '../utils/retry.js';
import type { ToolRegistry } from '../tools/tool-registry.js'; import type { ToolRegistry } from '../tools/tool-registry.js';
import { Kind } from '../tools/tools.js'; import { Kind } from '../tools/tools.js';
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
// Mock fs module to prevent actual file system operations during tests // Mock fs module to prevent actual file system operations during tests
const mockFileSystem = new Map<string, string>(); const mockFileSystem = new Map<string, string>();
@@ -81,6 +82,12 @@ vi.mock('../telemetry/loggers.js', () => ({
logContentRetryFailure: mockLogContentRetryFailure, logContentRetryFailure: mockLogContentRetryFailure,
})); }));
vi.mock('../telemetry/uiTelemetry.js', () => ({
uiTelemetryService: {
setLastPromptTokenCount: vi.fn(),
},
}));
describe('GeminiChat', () => { describe('GeminiChat', () => {
let mockContentGenerator: ContentGenerator; let mockContentGenerator: ContentGenerator;
let chat: GeminiChat; let chat: GeminiChat;
@@ -89,6 +96,7 @@ describe('GeminiChat', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(uiTelemetryService.setLastPromptTokenCount).mockClear();
mockContentGenerator = { mockContentGenerator = {
generateContent: vi.fn(), generateContent: vi.fn(),
generateContentStream: vi.fn(), generateContentStream: vi.fn(),
@@ -529,6 +537,11 @@ describe('GeminiChat', () => {
}, },
], ],
text: () => 'response', text: () => 'response',
usageMetadata: {
promptTokenCount: 42,
candidatesTokenCount: 15,
totalTokenCount: 57,
},
} as unknown as GenerateContentResponse; } as unknown as GenerateContentResponse;
})(); })();
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
@@ -557,6 +570,14 @@ describe('GeminiChat', () => {
}, },
'prompt-id-1', 'prompt-id-1',
); );
// Verify that token counting is called when usageMetadata is present
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(
42,
);
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(
1,
);
}); });
}); });
@@ -700,6 +721,9 @@ describe('GeminiChat', () => {
role: 'model', role: 'model',
parts: [{ text: 'Successful response' }], parts: [{ text: 'Successful response' }],
}); });
// Verify that token counting is not called when usageMetadata is missing
expect(uiTelemetryService.setLastPromptTokenCount).not.toHaveBeenCalled();
}); });
it('should fail after all retries on persistent invalid content and report metrics', async () => { it('should fail after all retries on persistent invalid content and report metrics', async () => {
+6
View File
@@ -40,6 +40,7 @@ import {
import { handleFallback } from '../fallback/handler.js'; import { handleFallback } from '../fallback/handler.js';
import { isFunctionResponse } from '../utils/messageInspectors.js'; import { isFunctionResponse } from '../utils/messageInspectors.js';
import { partListUnionToString } from './geminiRequest.js'; import { partListUnionToString } from './geminiRequest.js';
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
export enum StreamEventType { export enum StreamEventType {
/** A regular content chunk from the API. */ /** A regular content chunk from the API. */
@@ -528,6 +529,11 @@ export class GeminiChat {
// Record token usage if this chunk has usageMetadata // Record token usage if this chunk has usageMetadata
if (chunk.usageMetadata) { if (chunk.usageMetadata) {
this.chatRecordingService.recordMessageTokens(chunk.usageMetadata); this.chatRecordingService.recordMessageTokens(chunk.usageMetadata);
if (chunk.usageMetadata.promptTokenCount !== undefined) {
uiTelemetryService.setLastPromptTokenCount(
chunk.usageMetadata.promptTokenCount,
);
}
} }
yield chunk; // Yield every chunk to the UI immediately. yield chunk; // Yield every chunk to the UI immediately.
+11 -11
View File
@@ -142,7 +142,7 @@ describe('UiTelemetryService', () => {
expect(spy).toHaveBeenCalledOnce(); expect(spy).toHaveBeenCalledOnce();
const { metrics, lastPromptTokenCount } = spy.mock.calls[0][0]; const { metrics, lastPromptTokenCount } = spy.mock.calls[0][0];
expect(metrics).toBeDefined(); expect(metrics).toBeDefined();
expect(lastPromptTokenCount).toBe(10); expect(lastPromptTokenCount).toBe(0);
}); });
describe('API Response Event Processing', () => { describe('API Response Event Processing', () => {
@@ -177,7 +177,7 @@ describe('UiTelemetryService', () => {
tool: 3, tool: 3,
}, },
}); });
expect(service.getLastPromptTokenCount()).toBe(10); expect(service.getLastPromptTokenCount()).toBe(0);
}); });
it('should aggregate multiple ApiResponseEvents for the same model', () => { it('should aggregate multiple ApiResponseEvents for the same model', () => {
@@ -227,7 +227,7 @@ describe('UiTelemetryService', () => {
tool: 9, tool: 9,
}, },
}); });
expect(service.getLastPromptTokenCount()).toBe(15); expect(service.getLastPromptTokenCount()).toBe(0);
}); });
it('should handle ApiResponseEvents for different models', () => { it('should handle ApiResponseEvents for different models', () => {
@@ -266,7 +266,7 @@ describe('UiTelemetryService', () => {
expect(metrics.models['gemini-2.5-flash']).toBeDefined(); expect(metrics.models['gemini-2.5-flash']).toBeDefined();
expect(metrics.models['gemini-2.5-pro'].api.totalRequests).toBe(1); expect(metrics.models['gemini-2.5-pro'].api.totalRequests).toBe(1);
expect(metrics.models['gemini-2.5-flash'].api.totalRequests).toBe(1); expect(metrics.models['gemini-2.5-flash'].api.totalRequests).toBe(1);
expect(service.getLastPromptTokenCount()).toBe(100); expect(service.getLastPromptTokenCount()).toBe(0);
}); });
}); });
@@ -543,10 +543,10 @@ describe('UiTelemetryService', () => {
} as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE }; } as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE };
service.addEvent(event); service.addEvent(event);
expect(service.getLastPromptTokenCount()).toBe(100); expect(service.getLastPromptTokenCount()).toBe(0);
// Now reset the token count // Now reset the token count
service.resetLastPromptTokenCount(); service.setLastPromptTokenCount(0);
expect(service.getLastPromptTokenCount()).toBe(0); expect(service.getLastPromptTokenCount()).toBe(0);
}); });
@@ -570,7 +570,7 @@ describe('UiTelemetryService', () => {
service.addEvent(event); service.addEvent(event);
spy.mockClear(); // Clear the spy to focus on the reset call spy.mockClear(); // Clear the spy to focus on the reset call
service.resetLastPromptTokenCount(); service.setLastPromptTokenCount(0);
expect(spy).toHaveBeenCalledOnce(); expect(spy).toHaveBeenCalledOnce();
const { metrics, lastPromptTokenCount } = spy.mock.calls[0][0]; const { metrics, lastPromptTokenCount } = spy.mock.calls[0][0];
@@ -596,7 +596,7 @@ describe('UiTelemetryService', () => {
const metricsBefore = service.getMetrics(); const metricsBefore = service.getMetrics();
service.resetLastPromptTokenCount(); service.setLastPromptTokenCount(0);
const metricsAfter = service.getMetrics(); const metricsAfter = service.getMetrics();
@@ -625,15 +625,15 @@ describe('UiTelemetryService', () => {
} as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE }; } as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE };
service.addEvent(event); service.addEvent(event);
expect(service.getLastPromptTokenCount()).toBe(100); expect(service.getLastPromptTokenCount()).toBe(0);
// Reset once // Reset once
service.resetLastPromptTokenCount(); service.setLastPromptTokenCount(0);
expect(service.getLastPromptTokenCount()).toBe(0); expect(service.getLastPromptTokenCount()).toBe(0);
// Reset again - should still be 0 and still emit event // Reset again - should still be 0 and still emit event
spy.mockClear(); spy.mockClear();
service.resetLastPromptTokenCount(); service.setLastPromptTokenCount(0);
expect(service.getLastPromptTokenCount()).toBe(0); expect(service.getLastPromptTokenCount()).toBe(0);
expect(spy).toHaveBeenCalledOnce(); expect(spy).toHaveBeenCalledOnce();
}); });
+2 -4
View File
@@ -144,8 +144,8 @@ export class UiTelemetryService extends EventEmitter {
return this.#lastPromptTokenCount; return this.#lastPromptTokenCount;
} }
resetLastPromptTokenCount(): void { setLastPromptTokenCount(lastPromptTokenCount: number): void {
this.#lastPromptTokenCount = 0; this.#lastPromptTokenCount = lastPromptTokenCount;
this.emit('update', { this.emit('update', {
metrics: this.#metrics, metrics: this.#metrics,
lastPromptTokenCount: this.#lastPromptTokenCount, lastPromptTokenCount: this.#lastPromptTokenCount,
@@ -171,8 +171,6 @@ export class UiTelemetryService extends EventEmitter {
modelMetrics.tokens.cached += event.cached_content_token_count; modelMetrics.tokens.cached += event.cached_content_token_count;
modelMetrics.tokens.thoughts += event.thoughts_token_count; modelMetrics.tokens.thoughts += event.thoughts_token_count;
modelMetrics.tokens.tool += event.tool_token_count; modelMetrics.tokens.tool += event.tool_token_count;
this.#lastPromptTokenCount = event.input_token_count;
} }
private processApiError(event: ApiErrorEvent) { private processApiError(event: ApiErrorEvent) {