/** * Composite Variant Thinking Configuration Tests * * Tests per-tier thinking configuration for composite variants */ import { describe, it, expect } from 'bun:test'; import { applyThinkingConfig, applyThinkingSuffix, detectTierFromModel, getThinkingValueForTier, } from '../../../src/cliproxy/config/thinking-config'; import { CLIProxyProvider } from '../../../src/cliproxy/types'; // ======================================== // applyThinkingSuffix // ======================================== describe('applyThinkingSuffix', () => { it('should append level name to model', () => { const result = applyThinkingSuffix('gemini-3-pro-preview', 'high'); expect(result).toBe('gemini-3-pro-preview(high)'); }); it('should append numeric budget to model', () => { const result = applyThinkingSuffix('claude-opus-4-6-thinking', 8192); expect(result).toBe('claude-opus-4-6-thinking(8192)'); }); it('should not append suffix if model already has one', () => { const result = applyThinkingSuffix('gemini-3-pro-preview(medium)', 'high'); expect(result).toBe('gemini-3-pro-preview(medium)'); }); it('should not append suffix if model already has numeric budget', () => { const result = applyThinkingSuffix('claude-opus-4(8192)', 16384); expect(result).toBe('claude-opus-4(8192)'); }); it('should handle empty parentheses as NOT having suffix', () => { const result = applyThinkingSuffix('model()', 'high'); // Empty parens don't match regex /\([^)]+\)$/ (requires non-empty content) expect(result).toBe('model()(high)'); }); it('should append to model with hyphens', () => { const result = applyThinkingSuffix('gpt-4o-mini', 'low'); expect(result).toBe('gpt-4o-mini(low)'); }); }); // ======================================== // detectTierFromModel // ======================================== describe('detectTierFromModel', () => { it('should detect opus from model name containing "opus"', () => { const result = detectTierFromModel('claude-opus-4-6-thinking'); expect(result).toBe('opus'); }); it('should detect sonnet from model name containing "sonnet"', () => { const result = detectTierFromModel('claude-sonnet-4-5-thinking'); expect(result).toBe('sonnet'); }); it('should detect haiku from model name containing "haiku"', () => { const result = detectTierFromModel('claude-haiku-4-5-20251001'); expect(result).toBe('haiku'); }); it('should default to sonnet for unknown model names', () => { const result = detectTierFromModel('gpt-4o-mini'); expect(result).toBe('sonnet'); }); it('should default to sonnet for gemini models', () => { const result = detectTierFromModel('gemini-3-pro-preview'); expect(result).toBe('sonnet'); }); it('should be case-insensitive', () => { const result = detectTierFromModel('CLAUDE-OPUS-4'); expect(result).toBe('opus'); }); it('should detect haiku even with uppercase', () => { const result = detectTierFromModel('CLAUDE-HAIKU-4'); expect(result).toBe('haiku'); }); }); // ======================================== // getThinkingValueForTier (mock config) // ======================================== describe('getThinkingValueForTier', () => { // Note: This function reads from unified config, so we're testing the logic // For full integration tests, see composite-variant-service.test.ts it('should return tier default when no provider override', () => { const thinkingConfig = { mode: 'auto' as const, tier_defaults: { opus: 'high', sonnet: 'medium', haiku: 'low', }, }; const result = getThinkingValueForTier('opus', 'agy' as CLIProxyProvider, thinkingConfig); expect(result).toBe('high'); }); it('should return provider-specific override when configured', () => { const thinkingConfig = { mode: 'auto' as const, tier_defaults: { opus: 'high', sonnet: 'medium', haiku: 'low', }, provider_overrides: { gemini: { opus: 'xhigh', sonnet: 'high', haiku: 'medium', }, }, }; const result = getThinkingValueForTier('opus', 'gemini' as CLIProxyProvider, thinkingConfig); expect(result).toBe('xhigh'); }); it('should fall back to tier default when provider has no override for tier', () => { const thinkingConfig = { mode: 'auto' as const, tier_defaults: { opus: 'high', sonnet: 'medium', haiku: 'low', }, provider_overrides: { gemini: { opus: 'xhigh', // No sonnet override }, }, }; const result = getThinkingValueForTier('sonnet', 'gemini' as CLIProxyProvider, thinkingConfig); expect(result).toBe('medium'); }); it('should use centralized defaults when tier_defaults undefined', () => { const thinkingConfig = { mode: 'auto' as const, // tier_defaults is optional - centralized defaults kick in }; const result = getThinkingValueForTier('opus', 'agy' as CLIProxyProvider, thinkingConfig); // DEFAULT_THINKING_TIER_DEFAULTS.opus = 'high' expect(result).toBe('high'); }); }); // ======================================== // applyThinkingConfig with compositeTierThinking // ======================================== describe('applyThinkingConfig - composite variant integration', () => { it('should apply per-tier thinking from compositeTierThinking parameter', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', }; const compositeTierThinking = { opus: 'xhigh', sonnet: 'medium', haiku: 'low', }; const result = applyThinkingConfig( envVars, 'agy' as CLIProxyProvider, undefined, // No CLI override compositeTierThinking ); // Budget-based tier models are normalized to provider-specific budget values. expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(32768)'); expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(8192)'); // Haiku doesn't support thinking per model-catalog — no suffix expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001'); // ANTHROPIC_MODEL is validated: 'medium' → 8192 for budget-type models expect(result.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking(8192)'); }); it('should apply partial per-tier thinking (only some tiers specified)', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', }; const compositeTierThinking = { opus: 'xhigh', // sonnet and haiku will fall back to global config }; const result = applyThinkingConfig( envVars, 'agy' as CLIProxyProvider, undefined, compositeTierThinking ); // Budget-based tier models are normalized to provider-specific budget values. expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(32768)'); // Sonnet gets defaults from global config (mode=auto) expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toContain('claude-sonnet-4-5-thinking'); // Haiku doesn't support thinking — stays unchanged expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001'); }); it('should allow per-tier thinking to be "off"', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', }; const compositeTierThinking = { opus: 'high', sonnet: 'medium', haiku: 'off', // Explicitly disabled for haiku }; const result = applyThinkingConfig( envVars, 'agy' as CLIProxyProvider, undefined, compositeTierThinking ); expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(24576)'); expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(8192)'); // Haiku should NOT have suffix because thinking is not supported for haiku expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001'); }); it('should prioritize CLI override over per-tier thinking', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', }; const compositeTierThinking = { opus: 'xhigh', sonnet: 'medium', haiku: 'low', }; const result = applyThinkingConfig( envVars, 'agy' as CLIProxyProvider, 'minimal', // CLI override takes priority compositeTierThinking ); // All thinking-capable tiers should use the validated CLI override. expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(1024)'); expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(1024)'); // Haiku doesn't support thinking — stays unchanged regardless of CLI override expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001'); }); it('should disable all tier thinking when CLI override is explicitly off', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', }; const compositeTierThinking = { opus: 'xhigh', sonnet: 'medium', haiku: 'low', }; const result = applyThinkingConfig( envVars, 'agy' as CLIProxyProvider, 'off', compositeTierThinking ); expect(result.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking'); expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking'); expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001'); }); it('treats off override aliases case-insensitively', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', }; const result = applyThinkingConfig(envVars, 'agy' as CLIProxyProvider, 'OFF', { opus: 'xhigh', sonnet: 'high', }); expect(result.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking'); expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking'); }); it('uses per-tier provider capability checks for mixed-provider composites', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'gemini-2.5-pro', ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-2.5-pro', ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', }; const result = applyThinkingConfig( envVars, 'gemini' as CLIProxyProvider, undefined, { sonnet: 'high', }, { sonnet: { provider: 'agy' as CLIProxyProvider }, } ); expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(24576)'); }); it('applies tier thinking even when default model does not support thinking', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'gpt-4o-mini', ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-4o-mini', ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', }; const result = applyThinkingConfig( envVars, 'codex' as CLIProxyProvider, undefined, { sonnet: 'high', }, { sonnet: { provider: 'agy' as CLIProxyProvider }, } ); // Default model provider/model do not support thinking. expect(result.ANTHROPIC_MODEL).toBe('gpt-4o-mini'); // Supported mixed-provider tier must still receive its configured thinking value. expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(24576)'); }); it('handles dotted agy Claude IDs for thinking capability lookup', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'claude-opus-4.5-thinking', ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4.5-thinking', ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.5-thinking', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5', }; const result = applyThinkingConfig(envVars, 'agy' as CLIProxyProvider, 'high'); // Capability lookup should succeed for dotted agy IDs after normalization. // Tier values are normalized through budget validation too. expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4.5-thinking(24576)'); expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4.5-thinking(24576)'); expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4.5-thinking(24576)'); // Haiku does not support thinking in agy catalog. expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4.5'); }); it('should handle numeric budgets in per-tier thinking', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', }; const compositeTierThinking = { opus: '32768', sonnet: '8192', haiku: '512', }; const result = applyThinkingConfig( envVars, 'agy' as CLIProxyProvider, undefined, compositeTierThinking ); // Tier models keep numeric values after validation when the provider supports them. expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(32768)'); expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(8192)'); // Haiku doesn't support thinking — stays unchanged expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001'); }); it('should not apply thinking when mode is off and no override', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', }; const compositeTierThinking = { opus: 'high', sonnet: 'medium', haiku: 'low', }; // Mock thinking config with mode=off by calling without thinkingOverride // and ensuring global config mode is 'off' // This test would require mocking getThinkingConfig() // For now, test the behavior when compositeTierThinking is undefined const result = applyThinkingConfig( envVars, 'agy' as CLIProxyProvider, undefined, undefined // No per-tier thinking ); // When mode=auto and no compositeTierThinking, defaults apply // This depends on global config - skipping full integration test here expect(result).toBeDefined(); }); it('should skip thinking for models that do not support it', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'gpt-4o-mini', // Model that doesn't support thinking ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-4o-mini', ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-4o-mini', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-4o-mini', }; const compositeTierThinking = { opus: 'high', sonnet: 'medium', haiku: 'low', }; const result = applyThinkingConfig( envVars, 'codex' as CLIProxyProvider, undefined, compositeTierThinking ); // Models should remain unchanged (no thinking suffix) because they don't support it // Note: This depends on supportsThinking() logic in model-catalog.ts // For models that don't support thinking, no suffix should be added expect(result.ANTHROPIC_MODEL).toBe('gpt-4o-mini'); }); it('should update ANTHROPIC_MODEL when it matches a tier model', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'claude-opus-4-6-thinking', // Matches opus tier ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', }; const compositeTierThinking = { opus: 'xhigh', sonnet: 'medium', haiku: 'low', }; const result = applyThinkingConfig( envVars, 'agy' as CLIProxyProvider, undefined, compositeTierThinking ); // ANTHROPIC_MODEL is validated: xhigh → 32768 for budget-type models expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4-6-thinking(32768)'); // Tier model keeps the validated budget value. expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(32768)'); }); it('should preserve models that already have thinking suffix', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking(high)', // Already has suffix ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking(high)', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', }; const compositeTierThinking = { opus: 'xhigh', sonnet: 'medium', // Won't override existing suffix haiku: 'low', }; const result = applyThinkingConfig( envVars, 'agy' as CLIProxyProvider, undefined, compositeTierThinking ); // Existing suffix on main model should be preserved. expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(high)'); expect(result.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking(high)'); // Other supported tiers still receive configured thinking after validation. expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(32768)'); }); it('should use codex effort suffix style when provider is codex', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'gpt-5.3-codex', ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex', ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini', }; const result = applyThinkingConfig( envVars, 'codex' as CLIProxyProvider, 'xhigh', // Explicit CLI override undefined ); expect(result.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-xhigh'); expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini-high'); }); it('should normalize legacy codex parenthesized tier suffix to effort suffix format', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'gpt-5.3-codex', ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex(high)', }; const result = applyThinkingConfig(envVars, 'codex' as CLIProxyProvider, 'high'); expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-high'); }); });