Skip to content

Commit 19f1da5

Browse files
mabry1985B-A-M-Nclaude
authored
feat(stats): add model cost estimation to /stats model (QwenLM#3780) (#219)
Cherry-picks upstream qwen-code PR QwenLM#3780. Skips the source-attribution features (`bySource`, `flattenModelsBySource`, MAIN_SOURCE) that came along for the ride — those depend on a separate un-ported subagent attribution PR. What's in: - `costCalculator` util — pure function, takes input/output tokens + per-model `ModelPricing` ({inputPerMillionTokens, outputPerMillionTokens}), returns null when pricing is absent or total cost is zero. - `ui.modelPricing` setting — a `Record<string, ModelPricing>` keyed by model name. When set, the `/stats model` TUI shows a Cost section, and `/stats model` in non-interactive mode appends "Estimated cost" per model. - `ModelStatsDisplay` adds a Cost section after Tokens, gated on at least one model having computable cost (`hasPricing`). - `statsCommand` model subcommand: in non-interactive/ACP modes, returns a text message including per-model Estimated cost lines. Conflict-resolution notes: - Dropped `supportedModes` field — un-ported metadata, single consumer, no enforcement in our slash system. - Dropped upstream's expanded non-interactive output for top-level /stats and /stats tools (depends on `metrics.files.totalLinesAdded` which our SessionMetrics doesn't have). Kept just the model-cost path, which is the headline feature. - Rewrote `ModelStatsDisplay` cost row from scratch against our HEAD shape (no `bySource`, no `key.split('::')`) instead of taking upstream's full re-render. - Gate non-interactive path on explicit `=== 'non_interactive' || === 'acp'` rather than `!== 'interactive'`, so undefined defaults to interactive and existing tests keep working. - vscode-ide-companion settings.schema.json: deleted in our fork, removed from cherry-pick. Tests: 32 new tests pass (costCalculator, statsCommand model cost in non-interactive, ModelStatsDisplay cost row). Broader sweep of 1186 tests across packages/cli/src/ui/{commands,components} all pass. Co-authored-by: John London <benevolentjoker@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f8f92dd commit 19f1da5

7 files changed

Lines changed: 770 additions & 3 deletions

File tree

packages/cli/src/config/settingsSchema.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,25 @@ const SETTINGS_SCHEMA = {
848848
},
849849
},
850850

851+
modelPricing: {
852+
type: 'object',
853+
label: 'Model Pricing',
854+
category: 'Model',
855+
requiresRestart: false,
856+
default: undefined as
857+
| Record<
858+
string,
859+
{
860+
inputPerMillionTokens?: number;
861+
outputPerMillionTokens?: number;
862+
}
863+
>
864+
| undefined,
865+
description:
866+
'Optional per-model pricing for cost estimation in /stats model. Example: {"qwen3-coder": {"inputPerMillionTokens": 0.30, "outputPerMillionTokens": 1.20}}',
867+
showInDialog: false,
868+
},
869+
851870
context: {
852871
type: 'object',
853872
label: 'Context',

packages/cli/src/ui/commands/statsCommand.test.ts

Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import { type CommandContext } from './types.js';
1010
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
1111
import { MessageType } from '../types.js';
1212
import { formatDuration } from '../utils/formatters.js';
13+
import type { ModelMetrics } from '@qwen-code/qwen-code-core';
14+
15+
const toModelMetrics = (core: ModelMetrics): ModelMetrics => core;
1316

1417
describe('statsCommand', () => {
1518
let mockContext: CommandContext;
@@ -75,4 +78,358 @@ describe('statsCommand', () => {
7578
expect.any(Number),
7679
);
7780
});
81+
82+
describe('non-interactive mode', () => {
83+
let nonInteractiveContext: ReturnType<typeof createMockCommandContext>;
84+
85+
beforeEach(() => {
86+
nonInteractiveContext = createMockCommandContext({
87+
executionMode: 'non_interactive',
88+
});
89+
nonInteractiveContext.session.stats.sessionStartTime = startTime;
90+
});
91+
92+
it('stats model subcommand should return text in non-interactive mode', async () => {
93+
const modelSubCommand = statsCommand.subCommands?.find(
94+
(sc) => sc.name === 'model',
95+
);
96+
if (!modelSubCommand?.action) throw new Error('Subcommand has no action');
97+
98+
const result = (await modelSubCommand.action(
99+
nonInteractiveContext,
100+
'',
101+
)) as { type: string; content: string };
102+
103+
expect(result.type).toBe('message');
104+
expect(nonInteractiveContext.ui.addItem).not.toHaveBeenCalled();
105+
});
106+
107+
it('stats model shows cost when pricing is configured', async () => {
108+
const modelSubCommand = statsCommand.subCommands?.find(
109+
(sc) => sc.name === 'model',
110+
);
111+
if (!modelSubCommand?.action) throw new Error('Subcommand has no action');
112+
113+
const contextWithPricing = createMockCommandContext({
114+
executionMode: 'non_interactive',
115+
});
116+
// Set up settings with modelPricing
117+
(
118+
contextWithPricing.services.settings as unknown as Record<
119+
string,
120+
unknown
121+
>
122+
)['merged'] = {
123+
modelPricing: {
124+
'test-model': {
125+
inputPerMillionTokens: 0.3,
126+
outputPerMillionTokens: 1.2,
127+
},
128+
},
129+
};
130+
// Set up model metrics
131+
contextWithPricing.session.stats.metrics.models = {
132+
'test-model': toModelMetrics({
133+
tokens: {
134+
prompt: 1_000_000,
135+
candidates: 500_000,
136+
cached: 0,
137+
total: 1_500_000,
138+
thoughts: 0,
139+
tool: 0,
140+
},
141+
api: {
142+
totalRequests: 10,
143+
totalErrors: 0,
144+
totalLatencyMs: 0,
145+
},
146+
}),
147+
};
148+
149+
const result = (await modelSubCommand.action(contextWithPricing, '')) as {
150+
type: string;
151+
content: string;
152+
};
153+
154+
expect(result.type).toBe('message');
155+
expect(result.content).toContain('test-model');
156+
expect(result.content).toContain('prompt=1000000');
157+
expect(result.content).toContain('Estimated cost: $0.9000');
158+
});
159+
160+
it('stats model does not show cost when pricing is not configured', async () => {
161+
const modelSubCommand = statsCommand.subCommands?.find(
162+
(sc) => sc.name === 'model',
163+
);
164+
if (!modelSubCommand?.action) throw new Error('Subcommand has no action');
165+
166+
const contextWithoutPricing = createMockCommandContext({
167+
executionMode: 'non_interactive',
168+
});
169+
// Set up model metrics without pricing
170+
contextWithoutPricing.session.stats.metrics.models = {
171+
'test-model': toModelMetrics({
172+
tokens: {
173+
prompt: 1_000_000,
174+
candidates: 500_000,
175+
cached: 0,
176+
total: 1_500_000,
177+
thoughts: 0,
178+
tool: 0,
179+
},
180+
api: {
181+
totalRequests: 10,
182+
totalErrors: 0,
183+
totalLatencyMs: 0,
184+
},
185+
}),
186+
};
187+
188+
const result = (await modelSubCommand.action(
189+
contextWithoutPricing,
190+
'',
191+
)) as { type: string; content: string };
192+
193+
expect(result.type).toBe('message');
194+
expect(result.content).toContain('test-model');
195+
expect(result.content).not.toContain('Estimated cost');
196+
});
197+
198+
it('stats model shows cost per model when multiple models have pricing', async () => {
199+
const modelSubCommand = statsCommand.subCommands?.find(
200+
(sc) => sc.name === 'model',
201+
);
202+
if (!modelSubCommand?.action) throw new Error('Subcommand has no action');
203+
204+
const context = createMockCommandContext({
205+
executionMode: 'non_interactive',
206+
});
207+
// Set up settings with multiple model pricing
208+
(context.services.settings as unknown as Record<string, unknown>)[
209+
'merged'
210+
] = {
211+
modelPricing: {
212+
'model-a': {
213+
inputPerMillionTokens: 0.5,
214+
outputPerMillionTokens: 1.5,
215+
},
216+
'model-b': {
217+
inputPerMillionTokens: 0.1,
218+
outputPerMillionTokens: 0.5,
219+
},
220+
},
221+
};
222+
// Set up multiple model metrics
223+
context.session.stats.metrics.models = {
224+
'model-a': toModelMetrics({
225+
tokens: {
226+
prompt: 2_000_000,
227+
candidates: 1_000_000,
228+
cached: 0,
229+
total: 3_000_000,
230+
thoughts: 0,
231+
tool: 0,
232+
},
233+
api: {
234+
totalRequests: 20,
235+
totalErrors: 0,
236+
totalLatencyMs: 0,
237+
},
238+
}),
239+
'model-b': toModelMetrics({
240+
tokens: {
241+
prompt: 500_000,
242+
candidates: 200_000,
243+
cached: 0,
244+
total: 700_000,
245+
thoughts: 0,
246+
tool: 0,
247+
},
248+
api: {
249+
totalRequests: 5,
250+
totalErrors: 0,
251+
totalLatencyMs: 0,
252+
},
253+
}),
254+
};
255+
256+
const result = (await modelSubCommand.action(context, '')) as {
257+
type: string;
258+
content: string;
259+
};
260+
261+
expect(result.type).toBe('message');
262+
expect(result.content).toContain('model-a');
263+
expect(result.content).toContain('model-b');
264+
// model-a: 2M * $0.50 + 1M * $1.50 = $1.00 + $1.50 = $2.50
265+
// model-b: 500K * $0.10 + 200K * $0.50 = $0.05 + $0.10 = $0.15
266+
expect(result.content).toContain('Estimated cost: $2.5000');
267+
expect(result.content).toContain('Estimated cost: $0.1500');
268+
});
269+
270+
it('stats model shows cost only for models with pricing', async () => {
271+
const modelSubCommand = statsCommand.subCommands?.find(
272+
(sc) => sc.name === 'model',
273+
);
274+
if (!modelSubCommand?.action) throw new Error('Subcommand has no action');
275+
276+
const context = createMockCommandContext({
277+
executionMode: 'non_interactive',
278+
});
279+
// Only model-a has pricing
280+
(context.services.settings as unknown as Record<string, unknown>)[
281+
'merged'
282+
] = {
283+
modelPricing: {
284+
'model-a': {
285+
inputPerMillionTokens: 0.3,
286+
outputPerMillionTokens: 1.2,
287+
},
288+
// model-b has no pricing
289+
},
290+
};
291+
context.session.stats.metrics.models = {
292+
'model-a': toModelMetrics({
293+
tokens: {
294+
prompt: 1_000_000,
295+
candidates: 1_000_000,
296+
cached: 0,
297+
total: 2_000_000,
298+
thoughts: 0,
299+
tool: 0,
300+
},
301+
api: {
302+
totalRequests: 10,
303+
totalErrors: 0,
304+
totalLatencyMs: 0,
305+
},
306+
}),
307+
'model-b': toModelMetrics({
308+
tokens: {
309+
prompt: 1_000_000,
310+
candidates: 1_000_000,
311+
cached: 0,
312+
total: 2_000_000,
313+
thoughts: 0,
314+
tool: 0,
315+
},
316+
api: {
317+
totalRequests: 10,
318+
totalErrors: 0,
319+
totalLatencyMs: 0,
320+
},
321+
}),
322+
};
323+
324+
const result = (await modelSubCommand.action(context, '')) as {
325+
type: string;
326+
content: string;
327+
};
328+
329+
expect(result.type).toBe('message');
330+
// model-a has pricing
331+
expect(result.content).toContain('model-a');
332+
// model-b has no pricing
333+
expect(result.content).toContain('model-b');
334+
// Count occurrences of "Estimated cost"
335+
const costMatches = result.content.match(/Estimated cost/g);
336+
expect(costMatches).toBeTruthy();
337+
expect(costMatches!.length).toBe(1);
338+
});
339+
340+
it('stats model handles zero tokens with pricing', async () => {
341+
const modelSubCommand = statsCommand.subCommands?.find(
342+
(sc) => sc.name === 'model',
343+
);
344+
if (!modelSubCommand?.action) throw new Error('Subcommand has no action');
345+
346+
const context = createMockCommandContext({
347+
executionMode: 'non_interactive',
348+
});
349+
(context.services.settings as unknown as Record<string, unknown>)[
350+
'merged'
351+
] = {
352+
modelPricing: {
353+
'test-model': {
354+
inputPerMillionTokens: 0.3,
355+
outputPerMillionTokens: 1.2,
356+
},
357+
},
358+
};
359+
context.session.stats.metrics.models = {
360+
'test-model': toModelMetrics({
361+
tokens: {
362+
prompt: 0,
363+
candidates: 0,
364+
cached: 0,
365+
total: 0,
366+
thoughts: 0,
367+
tool: 0,
368+
},
369+
api: {
370+
totalRequests: 0,
371+
totalErrors: 0,
372+
totalLatencyMs: 0,
373+
},
374+
}),
375+
};
376+
377+
const result = (await modelSubCommand.action(context, '')) as {
378+
type: string;
379+
content: string;
380+
};
381+
382+
expect(result.type).toBe('message');
383+
expect(result.content).toContain('test-model');
384+
// Zero tokens mean zero cost, so no cost line should appear
385+
expect(result.content).not.toContain('Estimated cost');
386+
});
387+
388+
it('stats model handles partial pricing (input only)', async () => {
389+
const modelSubCommand = statsCommand.subCommands?.find(
390+
(sc) => sc.name === 'model',
391+
);
392+
if (!modelSubCommand?.action) throw new Error('Subcommand has no action');
393+
394+
const context = createMockCommandContext({
395+
executionMode: 'non_interactive',
396+
});
397+
(context.services.settings as unknown as Record<string, unknown>)[
398+
'merged'
399+
] = {
400+
modelPricing: {
401+
'test-model': {
402+
inputPerMillionTokens: 0.3,
403+
// No output pricing
404+
},
405+
},
406+
};
407+
context.session.stats.metrics.models = {
408+
'test-model': toModelMetrics({
409+
tokens: {
410+
prompt: 1_000_000,
411+
candidates: 1_000_000,
412+
cached: 0,
413+
total: 2_000_000,
414+
thoughts: 0,
415+
tool: 0,
416+
},
417+
api: {
418+
totalRequests: 10,
419+
totalErrors: 0,
420+
totalLatencyMs: 0,
421+
},
422+
}),
423+
};
424+
425+
const result = (await modelSubCommand.action(context, '')) as {
426+
type: string;
427+
content: string;
428+
};
429+
430+
expect(result.type).toBe('message');
431+
// 1M input tokens * $0.30/M = $0.30
432+
expect(result.content).toContain('Estimated cost: $0.3000');
433+
});
434+
});
78435
});

0 commit comments

Comments
 (0)