@@ -10,6 +10,9 @@ import { type CommandContext } from './types.js';
1010import { createMockCommandContext } from '../../test-utils/mockCommandContext.js' ;
1111import { MessageType } from '../types.js' ;
1212import { formatDuration } from '../utils/formatters.js' ;
13+ import type { ModelMetrics } from '@qwen-code/qwen-code-core' ;
14+
15+ const toModelMetrics = ( core : ModelMetrics ) : ModelMetrics => core ;
1316
1417describe ( '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 ( / E s t i m a t e d c o s t / 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