diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index c051ffa150c..ea504f68b79 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -966,6 +966,25 @@ const SETTINGS_SCHEMA = { }, }, + modelPricing: { + type: 'object', + label: 'Model Pricing', + category: 'Model', + requiresRestart: false, + default: undefined as + | Record< + string, + { + inputPerMillionTokens?: number; + outputPerMillionTokens?: number; + } + > + | undefined, + description: + 'Optional per-model pricing for cost estimation in /stats model. Example: {"qwen3-coder": {"inputPerMillionTokens": 0.30, "outputPerMillionTokens": 1.20}}', + showInDialog: false, + }, + context: { type: 'object', label: 'Context', diff --git a/packages/cli/src/ui/commands/statsCommand.test.ts b/packages/cli/src/ui/commands/statsCommand.test.ts index 356422dfdc4..d37382729c5 100644 --- a/packages/cli/src/ui/commands/statsCommand.test.ts +++ b/packages/cli/src/ui/commands/statsCommand.test.ts @@ -10,6 +10,13 @@ import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { MessageType } from '../types.js'; import { formatDuration } from '../utils/formatters.js'; +import { MAIN_SOURCE } from '@qwen-code/qwen-code-core'; +import type { ModelMetricsCore, ModelMetrics } from '@qwen-code/qwen-code-core'; + +const toModelMetrics = (core: ModelMetricsCore): ModelMetrics => ({ + ...core, + bySource: { [MAIN_SOURCE]: core }, +}); describe('statsCommand', () => { let mockContext: CommandContext; @@ -105,7 +112,12 @@ describe('statsCommand', () => { it('should return error if sessionStartTime is not available', async () => { if (!statsCommand.action) throw new Error('Command has no action'); - nonInteractiveContext.session.stats.sessionStartTime = undefined; + ( + nonInteractiveContext.session.stats as unknown as Record< + string, + unknown + > + )['sessionStartTime'] = undefined; const result = (await statsCommand.action(nonInteractiveContext, '')) as { type: string; @@ -145,5 +157,333 @@ describe('statsCommand', () => { expect(result.type).toBe('message'); expect(nonInteractiveContext.ui.addItem).not.toHaveBeenCalled(); }); + + it('stats model shows cost when pricing is configured', async () => { + const modelSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'model', + ); + if (!modelSubCommand?.action) throw new Error('Subcommand has no action'); + + const contextWithPricing = createMockCommandContext({ + executionMode: 'non_interactive', + }); + // Set up settings with modelPricing + ( + contextWithPricing.services.settings as unknown as Record< + string, + unknown + > + )['merged'] = { + modelPricing: { + 'test-model': { + inputPerMillionTokens: 0.3, + outputPerMillionTokens: 1.2, + }, + }, + }; + // Set up model metrics + contextWithPricing.session.stats.metrics.models = { + 'test-model': toModelMetrics({ + tokens: { + prompt: 1_000_000, + candidates: 500_000, + cached: 0, + total: 1_500_000, + thoughts: 0, + tool: 0, + }, + api: { + totalRequests: 10, + totalErrors: 0, + totalLatencyMs: 0, + }, + }), + }; + + const result = (await modelSubCommand.action(contextWithPricing, '')) as { + type: string; + content: string; + }; + + expect(result.type).toBe('message'); + expect(result.content).toContain('test-model'); + expect(result.content).toContain('prompt=1000000'); + expect(result.content).toContain('Estimated cost: $0.9000'); + }); + + it('stats model does not show cost when pricing is not configured', async () => { + const modelSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'model', + ); + if (!modelSubCommand?.action) throw new Error('Subcommand has no action'); + + const contextWithoutPricing = createMockCommandContext({ + executionMode: 'non_interactive', + }); + // Set up model metrics without pricing + contextWithoutPricing.session.stats.metrics.models = { + 'test-model': toModelMetrics({ + tokens: { + prompt: 1_000_000, + candidates: 500_000, + cached: 0, + total: 1_500_000, + thoughts: 0, + tool: 0, + }, + api: { + totalRequests: 10, + totalErrors: 0, + totalLatencyMs: 0, + }, + }), + }; + + const result = (await modelSubCommand.action( + contextWithoutPricing, + '', + )) as { type: string; content: string }; + + expect(result.type).toBe('message'); + expect(result.content).toContain('test-model'); + expect(result.content).not.toContain('Estimated cost'); + }); + + it('stats model shows cost per model when multiple models have pricing', async () => { + const modelSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'model', + ); + if (!modelSubCommand?.action) throw new Error('Subcommand has no action'); + + const context = createMockCommandContext({ + executionMode: 'non_interactive', + }); + // Set up settings with multiple model pricing + (context.services.settings as unknown as Record)[ + 'merged' + ] = { + modelPricing: { + 'model-a': { + inputPerMillionTokens: 0.5, + outputPerMillionTokens: 1.5, + }, + 'model-b': { + inputPerMillionTokens: 0.1, + outputPerMillionTokens: 0.5, + }, + }, + }; + // Set up multiple model metrics + context.session.stats.metrics.models = { + 'model-a': toModelMetrics({ + tokens: { + prompt: 2_000_000, + candidates: 1_000_000, + cached: 0, + total: 3_000_000, + thoughts: 0, + tool: 0, + }, + api: { + totalRequests: 20, + totalErrors: 0, + totalLatencyMs: 0, + }, + }), + 'model-b': toModelMetrics({ + tokens: { + prompt: 500_000, + candidates: 200_000, + cached: 0, + total: 700_000, + thoughts: 0, + tool: 0, + }, + api: { + totalRequests: 5, + totalErrors: 0, + totalLatencyMs: 0, + }, + }), + }; + + const result = (await modelSubCommand.action(context, '')) as { + type: string; + content: string; + }; + + expect(result.type).toBe('message'); + expect(result.content).toContain('model-a'); + expect(result.content).toContain('model-b'); + // model-a: 2M * $0.50 + 1M * $1.50 = $1.00 + $1.50 = $2.50 + // model-b: 500K * $0.10 + 200K * $0.50 = $0.05 + $0.10 = $0.15 + expect(result.content).toContain('Estimated cost: $2.5000'); + expect(result.content).toContain('Estimated cost: $0.1500'); + }); + + it('stats model shows cost only for models with pricing', async () => { + const modelSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'model', + ); + if (!modelSubCommand?.action) throw new Error('Subcommand has no action'); + + const context = createMockCommandContext({ + executionMode: 'non_interactive', + }); + // Only model-a has pricing + (context.services.settings as unknown as Record)[ + 'merged' + ] = { + modelPricing: { + 'model-a': { + inputPerMillionTokens: 0.3, + outputPerMillionTokens: 1.2, + }, + // model-b has no pricing + }, + }; + context.session.stats.metrics.models = { + 'model-a': toModelMetrics({ + tokens: { + prompt: 1_000_000, + candidates: 1_000_000, + cached: 0, + total: 2_000_000, + thoughts: 0, + tool: 0, + }, + api: { + totalRequests: 10, + totalErrors: 0, + totalLatencyMs: 0, + }, + }), + 'model-b': toModelMetrics({ + tokens: { + prompt: 1_000_000, + candidates: 1_000_000, + cached: 0, + total: 2_000_000, + thoughts: 0, + tool: 0, + }, + api: { + totalRequests: 10, + totalErrors: 0, + totalLatencyMs: 0, + }, + }), + }; + + const result = (await modelSubCommand.action(context, '')) as { + type: string; + content: string; + }; + + expect(result.type).toBe('message'); + // model-a has pricing + expect(result.content).toContain('model-a'); + // model-b has no pricing + expect(result.content).toContain('model-b'); + // Count occurrences of "Estimated cost" + const costMatches = result.content.match(/Estimated cost/g); + expect(costMatches).toBeTruthy(); + expect(costMatches!.length).toBe(1); + }); + + it('stats model handles zero tokens with pricing', async () => { + const modelSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'model', + ); + if (!modelSubCommand?.action) throw new Error('Subcommand has no action'); + + const context = createMockCommandContext({ + executionMode: 'non_interactive', + }); + (context.services.settings as unknown as Record)[ + 'merged' + ] = { + modelPricing: { + 'test-model': { + inputPerMillionTokens: 0.3, + outputPerMillionTokens: 1.2, + }, + }, + }; + context.session.stats.metrics.models = { + 'test-model': toModelMetrics({ + tokens: { + prompt: 0, + candidates: 0, + cached: 0, + total: 0, + thoughts: 0, + tool: 0, + }, + api: { + totalRequests: 0, + totalErrors: 0, + totalLatencyMs: 0, + }, + }), + }; + + const result = (await modelSubCommand.action(context, '')) as { + type: string; + content: string; + }; + + expect(result.type).toBe('message'); + expect(result.content).toContain('test-model'); + // Zero tokens mean zero cost, so no cost line should appear + expect(result.content).not.toContain('Estimated cost'); + }); + + it('stats model handles partial pricing (input only)', async () => { + const modelSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'model', + ); + if (!modelSubCommand?.action) throw new Error('Subcommand has no action'); + + const context = createMockCommandContext({ + executionMode: 'non_interactive', + }); + (context.services.settings as unknown as Record)[ + 'merged' + ] = { + modelPricing: { + 'test-model': { + inputPerMillionTokens: 0.3, + // No output pricing + }, + }, + }; + context.session.stats.metrics.models = { + 'test-model': toModelMetrics({ + tokens: { + prompt: 1_000_000, + candidates: 1_000_000, + cached: 0, + total: 2_000_000, + thoughts: 0, + tool: 0, + }, + api: { + totalRequests: 10, + totalErrors: 0, + totalLatencyMs: 0, + }, + }), + }; + + const result = (await modelSubCommand.action(context, '')) as { + type: string; + content: string; + }; + + expect(result.type).toBe('message'); + // 1M input tokens * $0.30/M = $0.30 + expect(result.content).toContain('Estimated cost: $0.3000'); + }); }); }); diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index 6d96bccf984..72a49ae3307 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -14,6 +14,7 @@ import { CommandKind, } from './types.js'; import { t } from '../../i18n/index.js'; +import { calculateCost } from '../../utils/costCalculator.js'; export const statsCommand: SlashCommand = { name: 'stats', @@ -89,6 +90,7 @@ export const statsCommand: SlashCommand = { action: (context: CommandContext): MessageActionReturn | void => { if (context.executionMode !== 'interactive') { const { metrics } = context.session.stats; + const pricing = context.services.settings.merged.modelPricing; const lines: string[] = []; for (const [modelName, modelMetrics] of Object.entries( metrics.models, @@ -96,6 +98,15 @@ export const statsCommand: SlashCommand = { lines.push( `${modelName}: prompt=${modelMetrics.tokens.prompt}, output=${modelMetrics.tokens.candidates}, cached=${modelMetrics.tokens.cached}`, ); + const cost = calculateCost({ + inputTokens: modelMetrics.tokens.prompt, + outputTokens: + modelMetrics.tokens.candidates + modelMetrics.tokens.thoughts, + pricing: pricing?.[modelName], + }); + if (cost != null) { + lines.push(` Estimated cost: $${cost.toFixed(4)}`); + } } if (lines.length === 0) { lines.push('No model usage data yet.'); diff --git a/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx b/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx index 9e871ad3f05..c080ad2589b 100644 --- a/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx +++ b/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx @@ -14,6 +14,8 @@ import type { SessionMetrics, } from '../contexts/SessionContext.js'; import { MAIN_SOURCE } from '@qwen-code/qwen-code-core'; +import { SettingsContext } from '../contexts/SettingsContext.js'; +import type { LoadedSettings } from '../../config/settings.js'; const mainOnly = (core: ModelMetricsCore): ModelMetrics => ({ ...core, @@ -31,9 +33,16 @@ vi.mock('../contexts/SessionContext.js', async (importOriginal) => { const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats); -const renderWithMockedStats = (metrics: SessionMetrics) => { +const renderWithMockedStats = ( + metrics: SessionMetrics, + modelPricing?: Record< + string, + { inputPerMillionTokens?: number; outputPerMillionTokens?: number } + >, +) => { useSessionStatsMock.mockReturnValue({ stats: { + sessionId: '', sessionStartTime: new Date(), metrics, lastPromptTokenCount: 0, @@ -42,9 +51,18 @@ const renderWithMockedStats = (metrics: SessionMetrics) => { getPromptCount: () => 5, startNewPrompt: vi.fn(), + startNewSession: vi.fn(), }); - return render(); + const mockSettings = { + merged: { modelPricing }, + } as unknown as LoadedSettings; + + return render( + + + , + ); }; describe('', () => { @@ -69,9 +87,10 @@ describe('', () => { totalSuccess: 0, totalFail: 0, totalDurationMs: 0, - totalDecisions: { accept: 0, reject: 0, modify: 0 }, + totalDecisions: { accept: 0, reject: 0, modify: 0, auto_accept: 0 }, byName: {}, }, + files: { totalLinesAdded: 0, totalLinesRemoved: 0 }, }); expect(lastFrame()).toContain( @@ -100,9 +119,10 @@ describe('', () => { totalSuccess: 0, totalFail: 0, totalDurationMs: 0, - totalDecisions: { accept: 0, reject: 0, modify: 0 }, + totalDecisions: { accept: 0, reject: 0, modify: 0, auto_accept: 0 }, byName: {}, }, + files: { totalLinesAdded: 0, totalLinesRemoved: 0 }, }); const output = lastFrame(); @@ -143,9 +163,10 @@ describe('', () => { totalSuccess: 0, totalFail: 0, totalDurationMs: 0, - totalDecisions: { accept: 0, reject: 0, modify: 0 }, + totalDecisions: { accept: 0, reject: 0, modify: 0, auto_accept: 0 }, byName: {}, }, + files: { totalLinesAdded: 0, totalLinesRemoved: 0 }, }); const output = lastFrame(); @@ -186,9 +207,10 @@ describe('', () => { totalSuccess: 0, totalFail: 0, totalDurationMs: 0, - totalDecisions: { accept: 0, reject: 0, modify: 0 }, + totalDecisions: { accept: 0, reject: 0, modify: 0, auto_accept: 0 }, byName: {}, }, + files: { totalLinesAdded: 0, totalLinesRemoved: 0 }, }); const output = lastFrame(); @@ -221,9 +243,10 @@ describe('', () => { totalSuccess: 0, totalFail: 0, totalDurationMs: 0, - totalDecisions: { accept: 0, reject: 0, modify: 0 }, + totalDecisions: { accept: 0, reject: 0, modify: 0, auto_accept: 0 }, byName: {}, }, + files: { totalLinesAdded: 0, totalLinesRemoved: 0 }, }); expect(lastFrame()).toMatchSnapshot(); @@ -249,9 +272,10 @@ describe('', () => { totalSuccess: 0, totalFail: 0, totalDurationMs: 0, - totalDecisions: { accept: 0, reject: 0, modify: 0 }, + totalDecisions: { accept: 0, reject: 0, modify: 0, auto_accept: 0 }, byName: {}, }, + files: { totalLinesAdded: 0, totalLinesRemoved: 0 }, }); const output = lastFrame(); @@ -266,7 +290,7 @@ describe('', () => { totalSuccess: 0, totalFail: 0, totalDurationMs: 0, - totalDecisions: { accept: 0, reject: 0, modify: 0 }, + totalDecisions: { accept: 0, reject: 0, modify: 0, auto_accept: 0 }, byName: {}, }; const baseFiles: SessionMetrics['files'] = { @@ -324,5 +348,144 @@ describe('', () => { expect(output).toContain('glm-5 (main)'); expect(output).toContain('glm-5 (echoer)'); }); + + describe('Cost estimation', () => { + const makeCore = ( + prompt: number, + candidates: number, + thoughts: number, + ): ModelMetricsCore => ({ + api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, + tokens: { + prompt, + candidates, + total: prompt + candidates + thoughts, + cached: 0, + thoughts, + tool: 0, + }, + }); + + const baseTools: SessionMetrics['tools'] = { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0, auto_accept: 0 }, + byName: {}, + }; + const baseFiles: SessionMetrics['files'] = { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }; + + const renderCostTest = ( + models: Record, + pricing?: Record< + string, + { inputPerMillionTokens?: number; outputPerMillionTokens?: number } + >, + ) => + renderWithMockedStats( + { models, tools: baseTools, files: baseFiles }, + pricing, + ); + + it('should not display Cost section when no pricing and no thoughts', () => { + const { lastFrame } = renderCostTest( + { 'gemini-2.5-pro': mainOnly(makeCore(10, 20, 0)) }, + {}, + ); + expect(lastFrame()).not.toContain('Cost'); + expect(lastFrame()).not.toContain('Estimated'); + }); + + it('should display Cost section when pricing is configured', () => { + const { lastFrame } = renderCostTest( + { 'gemini-2.5-pro': mainOnly(makeCore(10, 20, 0)) }, + { + 'gemini-2.5-pro': { + inputPerMillionTokens: 1, + outputPerMillionTokens: 2, + }, + }, + ); + expect(lastFrame()).toContain('Cost'); + expect(lastFrame()).toContain('Estimated'); + // 10 * 1/1M + 20 * 2/1M = 0.00001 + 0.00004 = 0.00005 -> 0.0001 + expect(lastFrame()).toContain('0.0001'); + }); + + it('should include thoughts tokens in cost calculation (regression)', () => { + const { lastFrame } = renderCostTest( + { 'gemini-2.5-pro': mainOnly(makeCore(10, 20, 5)) }, + { + 'gemini-2.5-pro': { + inputPerMillionTokens: 1, + outputPerMillionTokens: 2, + }, + }, + ); + expect(lastFrame()).toContain('Cost'); + // With thoughts=5: (10*1 + 25*2)/1M = 0.00006 -> rounds to 0.0001 + // Without thoughts: (10*1 + 20*2)/1M = 0.00005 -> rounds to 0.0001 + // Use larger numbers to make the difference visible + }); + + it('should include thoughts tokens — larger numbers to expose difference', () => { + // 1000 prompt, 2000 candidates, 500 thoughts + // With thoughts: (1000*1 + 2500*2)/1M = 0.001 + 0.005 = 0.006 + // Without thoughts: (1000*1 + 2000*2)/1M = 0.001 + 0.004 = 0.005 + const { lastFrame } = renderCostTest( + { 'gemini-2.5-pro': mainOnly(makeCore(1000, 2000, 500)) }, + { + 'gemini-2.5-pro': { + inputPerMillionTokens: 1, + outputPerMillionTokens: 2, + }, + }, + ); + expect(lastFrame()).toContain('Cost'); + expect(lastFrame()).toContain('0.0060'); + }); + + it('should use raw model name for pricing with subagent attribution', () => { + const core = makeCore(10, 20, 0); + const { lastFrame } = renderCostTest( + { + 'gemini-2.5-pro': { + ...core, + bySource: { + [MAIN_SOURCE]: core, + echoer: { ...core, tokens: { ...core.tokens } }, + }, + } as unknown as ModelMetrics, + }, + // Pricing keyed by raw name, not "gemini-2.5-pro::echoer" + { + 'gemini-2.5-pro': { + inputPerMillionTokens: 1, + outputPerMillionTokens: 2, + }, + }, + ); + expect(lastFrame()).toContain('Cost'); + expect(lastFrame()).toContain('Estimated'); + }); + + it('should handle multiple models with different pricing', () => { + const { lastFrame } = renderCostTest( + { + 'model-a': mainOnly(makeCore(100, 200, 10)), + 'model-b': mainOnly(makeCore(50, 80, 0)), + }, + { + 'model-a': { inputPerMillionTokens: 2, outputPerMillionTokens: 4 }, + 'model-b': { inputPerMillionTokens: 1, outputPerMillionTokens: 3 }, + }, + ); + expect(lastFrame()).toContain('Cost'); + }); + }); }); }); diff --git a/packages/cli/src/ui/components/ModelStatsDisplay.tsx b/packages/cli/src/ui/components/ModelStatsDisplay.tsx index 389c4221f47..7f968efc60c 100644 --- a/packages/cli/src/ui/components/ModelStatsDisplay.tsx +++ b/packages/cli/src/ui/components/ModelStatsDisplay.tsx @@ -17,6 +17,8 @@ import type { ModelMetricsCore } from '../contexts/SessionContext.js'; import { useSessionStats } from '../contexts/SessionContext.js'; import { flattenModelsBySource } from '../utils/modelsBySource.js'; import { t } from '../../i18n/index.js'; +import { useSettings } from '../contexts/SettingsContext.js'; +import { calculateCost } from '../../utils/costCalculator.js'; const METRIC_COL_WIDTH = 28; // 28 + 2*24 = 76, fitting the 76-column panel at 80-column terminal width @@ -65,6 +67,8 @@ export const ModelStatsDisplay: React.FC = ({ const { stats } = useSessionStats(); const { models } = stats.metrics; const entries = flattenModelsBySource(models); + const settings = useSettings(); + const modelPricing = settings.merged.modelPricing; if (entries.length === 0) { return ( @@ -92,6 +96,17 @@ export const ModelStatsDisplay: React.FC = ({ const hasTool = entries.some(({ metrics }) => metrics.tokens.tool > 0); const hasCached = entries.some(({ metrics }) => metrics.tokens.cached > 0); + const getModelName = (key: string): string => key.split('::')[0]; + + const hasPricing = entries.some( + ({ key, metrics }) => + calculateCost({ + inputTokens: metrics.tokens.prompt, + outputTokens: metrics.tokens.candidates + metrics.tokens.thoughts, + pricing: modelPricing?.[getModelName(key)], + }) != null, + ); + return ( = ({ isSubtle values={getModelValues((m) => m.tokens.candidates.toLocaleString())} /> + {hasPricing && ( + <> + + + { + const cost = calculateCost({ + inputTokens: metrics.tokens.prompt, + outputTokens: + metrics.tokens.candidates + metrics.tokens.thoughts, + pricing: modelPricing?.[getModelName(key)], + }); + return cost != null ? `$${cost.toFixed(4)}` : 'N/A'; + })} + /> + + )} ); }; diff --git a/packages/cli/src/utils/costCalculator.test.ts b/packages/cli/src/utils/costCalculator.test.ts new file mode 100644 index 00000000000..7ae65f8054a --- /dev/null +++ b/packages/cli/src/utils/costCalculator.test.ts @@ -0,0 +1,205 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { calculateCost } from './costCalculator.js'; + +describe('calculateCost', () => { + it('calculates cost correctly with both input and output pricing', () => { + const cost = calculateCost({ + inputTokens: 1_000_000, + outputTokens: 1_000_000, + pricing: { + inputPerMillionTokens: 0.3, + outputPerMillionTokens: 1.2, + }, + }); + + // 1M input tokens * $0.30/M = $0.30 + // 1M output tokens * $1.20/M = $1.20 + // Total = $1.50 + expect(cost).toBe(1.5); + }); + + it('returns null when pricing is not defined', () => { + const cost = calculateCost({ + inputTokens: 1000, + outputTokens: 500, + pricing: undefined, + }); + + expect(cost).toBeNull(); + }); + + it('handles only input pricing', () => { + const cost = calculateCost({ + inputTokens: 1_000_000, + outputTokens: 1_000_000, + pricing: { + inputPerMillionTokens: 0.3, + }, + }); + + // 1M input tokens * $0.30/M = $0.30 + // No output pricing, so outputCost = 0 + // Total = $0.30 + expect(cost).toBe(0.3); + }); + + it('handles only output pricing', () => { + const cost = calculateCost({ + inputTokens: 1_000_000, + outputTokens: 1_000_000, + pricing: { + outputPerMillionTokens: 1.2, + }, + }); + + // No input pricing, so inputCost = 0 + // 1M output tokens * $1.20/M = $1.20 + // Total = $1.20 + expect(cost).toBe(1.2); + }); + + it('returns null for zero tokens when pricing is defined', () => { + const cost = calculateCost({ + inputTokens: 0, + outputTokens: 0, + pricing: { + inputPerMillionTokens: 0.3, + outputPerMillionTokens: 1.2, + }, + }); + + expect(cost).toBeNull(); + }); + + it('handles partial tokens correctly', () => { + const cost = calculateCost({ + inputTokens: 500_000, + outputTokens: 250_000, + pricing: { + inputPerMillionTokens: 0.3, + outputPerMillionTokens: 1.2, + }, + }); + + // 500K input tokens * $0.30/M = $0.15 + // 250K output tokens * $1.20/M = $0.30 + // Total = $0.45 + expect(cost).toBeCloseTo(0.45); + }); + + it('returns null when pricing object is empty', () => { + const cost = calculateCost({ + inputTokens: 1000, + outputTokens: 500, + pricing: {}, + }); + + expect(cost).toBeNull(); + }); + + it('returns null when pricing has null/undefined values', () => { + const cost = calculateCost({ + inputTokens: 1000, + outputTokens: 500, + pricing: { + inputPerMillionTokens: undefined, + outputPerMillionTokens: undefined, + }, + }); + + expect(cost).toBeNull(); + }); + + it('handles very small token counts', () => { + const cost = calculateCost({ + inputTokens: 1, + outputTokens: 1, + pricing: { + inputPerMillionTokens: 1.0, + outputPerMillionTokens: 2.0, + }, + }); + + // 1 token * $1.0/M = $0.000001 + // 1 token * $2.0/M = $0.000002 + // Total = $0.000003 + expect(cost).toBeCloseTo(0.000003, 10); + }); + + it('handles very large token counts', () => { + const cost = calculateCost({ + inputTokens: 1_000_000_000, // 1B tokens + outputTokens: 500_000_000, + pricing: { + inputPerMillionTokens: 0.3, + outputPerMillionTokens: 1.2, + }, + }); + + // 1B input tokens * $0.30/M = $300 + // 500M output tokens * $1.20/M = $600 + // Total = $900 + expect(cost).toBe(900); + }); + + it('handles zero input tokens with output pricing', () => { + const cost = calculateCost({ + inputTokens: 0, + outputTokens: 1_000_000, + pricing: { + outputPerMillionTokens: 1.2, + }, + }); + + expect(cost).toBe(1.2); + }); + + it('handles zero output tokens with input pricing', () => { + const cost = calculateCost({ + inputTokens: 1_000_000, + outputTokens: 0, + pricing: { + inputPerMillionTokens: 0.3, + }, + }); + + expect(cost).toBe(0.3); + }); + + it('handles fractional pricing', () => { + const cost = calculateCost({ + inputTokens: 1_000_000, + outputTokens: 1_000_000, + pricing: { + inputPerMillionTokens: 0.123456, + outputPerMillionTokens: 0.654321, + }, + }); + + expect(cost).toBeCloseTo(0.777777, 6); + }); + + it('rounds to 4 decimal places in display format', () => { + const cost = calculateCost({ + inputTokens: 123_456, + outputTokens: 789_012, + pricing: { + inputPerMillionTokens: 0.3, + outputPerMillionTokens: 1.2, + }, + }); + + // 123,456 * $0.30/M = $0.0370368 + // 789,012 * $1.20/M = $0.9468144 + // Total = $0.9838512 + expect(cost).toBeCloseTo(0.9838512, 7); + // Verify the toFixed(4) formatting that the UI uses + expect(cost?.toFixed(4)).toBe('0.9839'); + }); +}); diff --git a/packages/cli/src/utils/costCalculator.ts b/packages/cli/src/utils/costCalculator.ts new file mode 100644 index 00000000000..319adc68320 --- /dev/null +++ b/packages/cli/src/utils/costCalculator.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface ModelPricing { + inputPerMillionTokens?: number; + outputPerMillionTokens?: number; +} + +export function calculateCost(args: { + inputTokens: number; + outputTokens: number; + pricing?: ModelPricing; +}): number | null { + const { inputTokens, outputTokens, pricing } = args; + + if (!pricing) return null; + + const inputCost = + pricing.inputPerMillionTokens != null + ? (inputTokens / 1_000_000) * pricing.inputPerMillionTokens + : 0; + + const outputCost = + pricing.outputPerMillionTokens != null + ? (outputTokens / 1_000_000) * pricing.outputPerMillionTokens + : 0; + + const total = inputCost + outputCost; + return total > 0 ? total : null; +} diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index e78f3b2cbb9..a853cb201a2 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -380,6 +380,11 @@ } } }, + "modelPricing": { + "description": "Optional per-model pricing for cost estimation in /stats model. Example: {\"qwen3-coder\": {\"inputPerMillionTokens\": 0.30, \"outputPerMillionTokens\": 1.20}}", + "type": "object", + "additionalProperties": true + }, "context": { "description": "Settings for managing context provided to the model.", "type": "object",