@@ -24,6 +24,7 @@ import {
2424 MODELS_WITH_VERBOSITY ,
2525 PROVIDERS_WITH_TOOL_USAGE_CONTROL ,
2626 prepareToolsWithUsageControl ,
27+ shouldBillModelUsage ,
2728 supportsTemperature ,
2829 supportsToolUsageControl ,
2930 transformCustomTool ,
@@ -40,6 +41,7 @@ describe('getApiKey', () => {
4041 beforeEach ( ( ) => {
4142 vi . clearAllMocks ( )
4243
44+ // @ts -expect-error - mocking boolean with different value
4345 isHostedSpy . mockReturnValue ( false )
4446
4547 module . require = vi . fn ( ( ) => ( {
@@ -53,6 +55,7 @@ describe('getApiKey', () => {
5355 } )
5456
5557 it ( 'should return user-provided key when not in hosted environment' , ( ) => {
58+ // @ts -expect-error - mocking boolean with different value
5659 isHostedSpy . mockReturnValue ( false )
5760
5861 // For OpenAI
@@ -65,6 +68,7 @@ describe('getApiKey', () => {
6568 } )
6669
6770 it ( 'should throw error if no key provided in non-hosted environment' , ( ) => {
71+ // @ts -expect-error - mocking boolean with different value
6872 isHostedSpy . mockReturnValue ( false )
6973
7074 expect ( ( ) => getApiKey ( 'openai' , 'gpt-4' ) ) . toThrow ( 'API key is required for openai gpt-4' )
@@ -80,7 +84,8 @@ describe('getApiKey', () => {
8084 throw new Error ( 'Rotation failed' )
8185 } )
8286
83- const key = getApiKey ( 'openai' , 'gpt-4' , 'user-fallback-key' )
87+ // Use gpt-4o which IS in the hosted models list
88+ const key = getApiKey ( 'openai' , 'gpt-4o' , 'user-fallback-key' )
8489 expect ( key ) . toBe ( 'user-fallback-key' )
8590 } )
8691
@@ -91,7 +96,8 @@ describe('getApiKey', () => {
9196 throw new Error ( 'Rotation failed' )
9297 } )
9398
94- expect ( ( ) => getApiKey ( 'openai' , 'gpt-4' ) ) . toThrow ( 'No API key available for openai gpt-4' )
99+ // Use gpt-4o which IS in the hosted models list
100+ expect ( ( ) => getApiKey ( 'openai' , 'gpt-4o' ) ) . toThrow ( 'No API key available for openai gpt-4o' )
95101 } )
96102
97103 it ( 'should require user key for non-OpenAI/Anthropic providers even in hosted environment' , ( ) => {
@@ -104,6 +110,30 @@ describe('getApiKey', () => {
104110 'API key is required for other-provider some-model'
105111 )
106112 } )
113+
114+ it ( 'should require user key for models NOT in hosted list even if provider matches' , ( ) => {
115+ isHostedSpy . mockReturnValue ( true )
116+
117+ // Models with version suffixes that are NOT in the hosted list should require user API key
118+ // even though they're from anthropic/openai providers
119+
120+ // User provides their own key - should work
121+ const key1 = getApiKey ( 'anthropic' , 'claude-sonnet-4-20250514' , 'user-key-anthropic' )
122+ expect ( key1 ) . toBe ( 'user-key-anthropic' )
123+
124+ // No user key - should throw, NOT use server key
125+ expect ( ( ) => getApiKey ( 'anthropic' , 'claude-sonnet-4-20250514' ) ) . toThrow (
126+ 'API key is required for anthropic claude-sonnet-4-20250514'
127+ )
128+
129+ // Same for OpenAI versioned models not in list
130+ const key2 = getApiKey ( 'openai' , 'gpt-4o-2024-08-06' , 'user-key-openai' )
131+ expect ( key2 ) . toBe ( 'user-key-openai' )
132+
133+ expect ( ( ) => getApiKey ( 'openai' , 'gpt-4o-2024-08-06' ) ) . toThrow (
134+ 'API key is required for openai gpt-4o-2024-08-06'
135+ )
136+ } )
107137} )
108138
109139describe ( 'Model Capabilities' , ( ) => {
@@ -476,6 +506,52 @@ describe('getHostedModels', () => {
476506 } )
477507} )
478508
509+ describe ( 'shouldBillModelUsage' , ( ) => {
510+ it . concurrent ( 'should return true for exact matches of hosted models' , ( ) => {
511+ // OpenAI models
512+ expect ( shouldBillModelUsage ( 'gpt-4o' ) ) . toBe ( true )
513+ expect ( shouldBillModelUsage ( 'o1' ) ) . toBe ( true )
514+
515+ // Anthropic models
516+ expect ( shouldBillModelUsage ( 'claude-sonnet-4-0' ) ) . toBe ( true )
517+ expect ( shouldBillModelUsage ( 'claude-opus-4-0' ) ) . toBe ( true )
518+
519+ // Google models
520+ expect ( shouldBillModelUsage ( 'gemini-2.5-pro' ) ) . toBe ( true )
521+ expect ( shouldBillModelUsage ( 'gemini-2.5-flash' ) ) . toBe ( true )
522+ } )
523+
524+ it . concurrent ( 'should return false for non-hosted models' , ( ) => {
525+ // Other providers
526+ expect ( shouldBillModelUsage ( 'deepseek-v3' ) ) . toBe ( false )
527+ expect ( shouldBillModelUsage ( 'grok-4-latest' ) ) . toBe ( false )
528+
529+ // Unknown models
530+ expect ( shouldBillModelUsage ( 'unknown-model' ) ) . toBe ( false )
531+ } )
532+
533+ it . concurrent ( 'should return false for versioned model names not in hosted list' , ( ) => {
534+ // Versioned model names that are NOT in the hosted list
535+ // These should NOT be billed (user provides own API key)
536+ expect ( shouldBillModelUsage ( 'claude-sonnet-4-20250514' ) ) . toBe ( false )
537+ expect ( shouldBillModelUsage ( 'gpt-4o-2024-08-06' ) ) . toBe ( false )
538+ expect ( shouldBillModelUsage ( 'claude-3-5-sonnet-20241022' ) ) . toBe ( false )
539+ } )
540+
541+ it . concurrent ( 'should be case insensitive' , ( ) => {
542+ expect ( shouldBillModelUsage ( 'GPT-4O' ) ) . toBe ( true )
543+ expect ( shouldBillModelUsage ( 'Claude-Sonnet-4-0' ) ) . toBe ( true )
544+ expect ( shouldBillModelUsage ( 'GEMINI-2.5-PRO' ) ) . toBe ( true )
545+ } )
546+
547+ it . concurrent ( 'should not match partial model names' , ( ) => {
548+ // Should not match partial/prefix models
549+ expect ( shouldBillModelUsage ( 'gpt-4' ) ) . toBe ( false ) // gpt-4o is hosted, not gpt-4
550+ expect ( shouldBillModelUsage ( 'claude-sonnet' ) ) . toBe ( false )
551+ expect ( shouldBillModelUsage ( 'gemini' ) ) . toBe ( false )
552+ } )
553+ } )
554+
479555describe ( 'Provider Management' , ( ) => {
480556 describe ( 'getProviderFromModel' , ( ) => {
481557 it . concurrent ( 'should return correct provider for known models' , ( ) => {
0 commit comments