diff --git a/src/cliproxy/composite-validator.ts b/src/cliproxy/composite-validator.ts new file mode 100644 index 00000000..981b58e0 --- /dev/null +++ b/src/cliproxy/composite-validator.ts @@ -0,0 +1,118 @@ +/** + * Shared validation helpers for composite CLIProxy variants. + * Used by API routes, service layer, and config loader to avoid contract drift. + */ + +import { CLIPROXY_SUPPORTED_PROVIDERS, CompositeTierConfig } from '../config/unified-config-types'; +import type { CLIProxyProvider } from './types'; + +export const VALID_COMPOSITE_TIERS = ['opus', 'sonnet', 'haiku'] as const; +export type CompositeTierName = (typeof VALID_COMPOSITE_TIERS)[number]; + +interface CompositeValidationOptions { + defaultTier?: unknown; + requireAllTiers?: boolean; +} + +type CompositeTierInput = Partial>; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isValidProvider(provider: unknown): provider is CLIProxyProvider { + return ( + typeof provider === 'string' && + CLIPROXY_SUPPORTED_PROVIDERS.includes(provider as CLIProxyProvider) + ); +} + +export function validateCompositeDefaultTier(defaultTier: unknown): string | null { + if ( + defaultTier !== undefined && + !VALID_COMPOSITE_TIERS.includes(defaultTier as CompositeTierName) + ) { + return `Invalid default_tier '${String(defaultTier)}': must be one of ${VALID_COMPOSITE_TIERS.join(', ')}`; + } + return null; +} + +/** + * Validate composite tier payload. + * + * Create mode (`requireAllTiers=true`): all tiers required. + * Update mode (`requireAllTiers=false`): partial tiers allowed. + */ +export function validateCompositeTiers( + tiers: unknown, + options: CompositeValidationOptions = {} +): string | null { + const { defaultTier, requireAllTiers = false } = options; + + const defaultTierError = validateCompositeDefaultTier(defaultTier); + if (defaultTierError) { + return defaultTierError; + } + + if (!isRecord(tiers)) { + return "Invalid tiers payload: expected object with tier keys ('opus', 'sonnet', 'haiku')"; + } + + const tierMap = tiers as CompositeTierInput; + + for (const tier of VALID_COMPOSITE_TIERS) { + const tierValue = tierMap[tier]; + + if (requireAllTiers && tierValue === undefined) { + return `Missing required tier '${tier}': all tiers (opus, sonnet, haiku) required for create`; + } + + if (tierValue === undefined) { + continue; + } + + if (!isRecord(tierValue)) { + return `Invalid tier config for '${tier}': expected object with provider and model`; + } + + const provider = tierValue.provider; + const model = tierValue.model; + + if (typeof provider !== 'string' || typeof model !== 'string') { + return `Invalid tier config for '${tier}': requires 'provider' and 'model' strings`; + } + + if (!model.trim()) { + return `Invalid model for tier '${tier}': model cannot be empty or whitespace`; + } + + if (!isValidProvider(provider)) { + return `Invalid provider '${provider}' for tier '${tier}': must be one of ${CLIPROXY_SUPPORTED_PROVIDERS.join(', ')}`; + } + + if (tierValue.fallback !== undefined) { + const fallback = tierValue.fallback; + if (!isRecord(fallback)) { + return `Invalid fallback config for tier '${tier}': expected object with provider and model`; + } + + if (typeof fallback.provider !== 'string' || typeof fallback.model !== 'string') { + return `Invalid fallback config for tier '${tier}': requires 'provider' and 'model' strings`; + } + + if (!fallback.model.trim()) { + return `Invalid fallback model for tier '${tier}': model cannot be empty or whitespace`; + } + + if (!isValidProvider(fallback.provider)) { + return `Invalid fallback provider '${fallback.provider}' for tier '${tier}': must be one of ${CLIPROXY_SUPPORTED_PROVIDERS.join(', ')}`; + } + + if (fallback.provider === provider && fallback.model === model) { + return `Circular fallback in tier '${tier}': fallback cannot point to same provider and model`; + } + } + } + + return null; +} diff --git a/src/cliproxy/config/thinking-config.ts b/src/cliproxy/config/thinking-config.ts index cda3a7ee..69ec1762 100644 --- a/src/cliproxy/config/thinking-config.ts +++ b/src/cliproxy/config/thinking-config.ts @@ -108,6 +108,16 @@ export function applyThinkingConfig( return result; } + // Explicit "off" (CLI override or manual config override) must disable ALL tier thinking. + const explicitOffOverride = + thinkingOverride === 'off' || + (thinkingOverride === undefined && + thinkingConfig.mode === 'manual' && + thinkingConfig.override === 'off'); + if (explicitOffOverride) { + return result; + } + // Get base model to check thinking support const baseModel = result.ANTHROPIC_MODEL || ''; if (!supportsThinking(provider, baseModel)) { @@ -152,10 +162,8 @@ export function applyThinkingConfig( } thinkingValue = validation.value; - // P1 FIX: If validation says 'off' AND no per-tier thinking config, skip ALL processing - // This distinguishes between: - // 1. "off" with no per-tier config → no thinking anywhere - // 2. "off" with per-tier config → skip main model, process tiers with their own values + // If auto-detection resolves default tier to "off", skip the main model but still allow + // explicit per-tier thinking values for other tiers. if (thinkingValue === 'off') { const hasPerTierThinking = compositeTierThinking && diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 26abbf81..050a8d6b 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -840,6 +840,18 @@ export async function execClaudeWithCLIProxy( compositeDefaultTier: cfg.compositeDefaultTier, }); + if (cfg.isComposite && cfg.compositeTiers && cfg.compositeDefaultTier) { + const mode = useRemoteProxy + ? proxyConfig.protocol === 'https' + ? 'remote-https' + : 'remote-http' + : 'local'; + const defaultTierProvider = cfg.compositeTiers[cfg.compositeDefaultTier]?.provider ?? provider; + log( + `Composite self-check: mode=${mode}, baseUrl=${env.ANTHROPIC_BASE_URL || 'unset'}, defaultTier=${cfg.compositeDefaultTier}, defaultProvider=${defaultTierProvider}` + ); + } + const webSearchEnv = getWebSearchHookEnv(); logEnvironment(env, webSearchEnv, verbose); diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts index 76ce8814..b9048d86 100644 --- a/src/cliproxy/services/variant-service.ts +++ b/src/cliproxy/services/variant-service.ts @@ -17,6 +17,7 @@ import { isUnifiedMode } from '../../config/unified-config-loader'; import { deleteConfigForPort } from '../config-generator'; import { hasActiveSessions, deleteSessionLockForPort } from '../session-tracker'; import { warn } from '../../utils/ui'; +import { validateCompositeTiers } from '../composite-validator'; import { createSettingsFile, createSettingsFileUnified, @@ -309,6 +310,14 @@ export function createCompositeVariant( try { const { name, defaultTier, tiers } = options; + const validationError = validateCompositeTiers(tiers, { + defaultTier, + requireAllTiers: true, + }); + if (validationError) { + return { success: false, error: validationError }; + } + // Validate all tier providers against backend compatibility const tierNames: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku']; for (const tier of tierNames) { @@ -387,6 +396,15 @@ export function updateCompositeVariant( haiku: { ...existing.tiers.haiku, ...updates.tiers?.haiku }, }; + const newDefaultTier = updates.defaultTier ?? existing.default_tier ?? 'sonnet'; + const validationError = validateCompositeTiers(mergedTiers, { + defaultTier: newDefaultTier, + requireAllTiers: true, + }); + if (validationError) { + return { success: false, error: validationError }; + } + // Validate all tier providers against backend compatibility const tierNames: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku']; for (const tier of tierNames) { @@ -396,13 +414,6 @@ export function updateCompositeVariant( } } - const newDefaultTier = updates.defaultTier ?? existing.default_tier ?? 'sonnet'; - - // Delete old settings file - if (existing.settings) { - deleteSettingsFile(existing.settings); - } - // Create new settings file with updated config const settingsPath = createCompositeSettingsFile( name, diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 0824597d..773259c4 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -26,9 +26,9 @@ import { ThinkingConfig, DashboardAuthConfig, ImageAnalysisConfig, - CLIPROXY_SUPPORTED_PROVIDERS, CursorConfig, } from './unified-config-types'; +import { validateCompositeTiers } from '../cliproxy/composite-validator'; import { isUnifiedConfigEnabled } from './feature-flags'; const CONFIG_YAML = 'config.yaml'; @@ -212,32 +212,17 @@ export function loadUnifiedConfig(): UnifiedConfig | null { * Warns about invalid providers in composite variant configurations. */ function validateCompositeVariants(config: UnifiedConfig): void { - const validProviders = new Set(CLIPROXY_SUPPORTED_PROVIDERS); const variants = config.cliproxy?.variants; if (!variants) return; for (const [name, variant] of Object.entries(variants)) { if ('type' in variant && variant.type === 'composite') { - // Guard against malformed composite variants - if (!variant.tiers || typeof variant.tiers !== 'object') { - console.warn(`[!] Composite variant '${name}' missing tiers object, skipping validation`); - continue; - } - - for (const [tier, tierConfig] of Object.entries(variant.tiers as Record)) { - if (!tierConfig || typeof tierConfig !== 'object') { - console.warn( - `[!] Variant '${name}': invalid config in ${tier} tier (expected object, got ${tierConfig === null ? 'null' : typeof tierConfig})` - ); - continue; - } - - const provider = (tierConfig as { provider?: unknown }).provider; - if (typeof provider !== 'string' || !validProviders.has(provider)) { - console.warn( - `[!] Variant '${name}': invalid provider '${String(provider)}' in ${tier} tier` - ); - } + const error = validateCompositeTiers(variant.tiers, { + defaultTier: variant.default_tier, + requireAllTiers: true, + }); + if (error) { + console.warn(`[!] Variant '${name}': invalid composite config (${error})`); } } } diff --git a/src/web-server/routes/variant-routes.ts b/src/web-server/routes/variant-routes.ts index 209e0dac..14aa4039 100644 --- a/src/web-server/routes/variant-routes.ts +++ b/src/web-server/routes/variant-routes.ts @@ -7,7 +7,6 @@ import { Router, Request, Response } from 'express'; import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names'; import type { CLIProxyProvider } from '../../cliproxy/types'; -import { CLIPROXY_SUPPORTED_PROVIDERS } from '../../config/unified-config-types'; import { createVariant, removeVariant, @@ -17,64 +16,13 @@ import { createCompositeVariant, updateCompositeVariant, } from '../../cliproxy/services/variant-service'; +import { + validateCompositeDefaultTier, + validateCompositeTiers, +} from '../../cliproxy/composite-validator'; const router = Router(); -const VALID_TIERS = ['opus', 'sonnet', 'haiku'] as const; - -/** Validate composite tiers shape and provider/default_tier values. Returns error string or null. */ -function validateCompositeTiers( - tiers: Record, - defaultTier?: string, - requireAllTiers = false -): string | null { - // Validate default_tier - if (defaultTier && !VALID_TIERS.includes(defaultTier as (typeof VALID_TIERS)[number])) { - return `Invalid default_tier '${defaultTier}': must be one of ${VALID_TIERS.join(', ')}`; - } - // Validate each tier - for (const tier of VALID_TIERS) { - const tierValue = tiers[tier]; - - // For POST/create: all tiers required - if (requireAllTiers && tierValue === undefined) { - return `Missing required tier '${tier}': all tiers (opus, sonnet, haiku) required for create`; - } - - // Skip validation for tiers not present in the request (PUT partial updates) - if (tierValue === undefined) continue; - - // Guard against null tier values - if (tierValue === null || typeof tierValue !== 'object') { - return `Invalid tier config for '${tier}': expected object with provider and model`; - } - - if (typeof tierValue.provider !== 'string' || typeof tierValue.model !== 'string') { - return `Invalid tier config for '${tier}': requires 'provider' and 'model' strings`; - } - // Validate non-empty model string (whitespace-only is also invalid) - if (!tiers[tier].model?.trim()) { - return `Invalid model for tier '${tier}': model cannot be empty or whitespace`; - } - if (!CLIPROXY_SUPPORTED_PROVIDERS.includes(tiers[tier].provider as CLIProxyProvider)) { - return `Invalid provider '${tiers[tier].provider}' for tier '${tier}': must be one of ${CLIPROXY_SUPPORTED_PROVIDERS.join(', ')}`; - } - // Check for circular fallback (fallback points to same provider+model) - const tierConfig = tiers[tier] as { - provider: string; - model: string; - fallback?: { provider?: string; model?: string }; - }; - if (tierConfig.fallback) { - const fb = tierConfig.fallback; - if (fb.provider === tierConfig.provider && fb.model === tierConfig.model) { - return `Circular fallback in tier '${tier}': fallback cannot point to same provider and model`; - } - } - } - return null; -} - /** * GET /api/cliproxy - List cliproxy variants * Uses variant-service for consistent behavior with CLI @@ -132,7 +80,10 @@ router.post('/', (req: Request, res: Response): void => { } // Validate tiers shape, providers, and default_tier (all tiers required for create) - const tierError = validateCompositeTiers(tiers, default_tier, true); + const tierError = validateCompositeTiers(tiers, { + defaultTier: default_tier, + requireAllTiers: true, + }); if (tierError) { res.status(400).json({ error: tierError }); return; @@ -221,26 +172,30 @@ router.put('/:name', (req: Request, res: Response): void => { // Validate tiers shape, providers, and default_tier if provided if (tiers) { - const tierError = validateCompositeTiers(tiers, default_tier); + const tierError = validateCompositeTiers(tiers, { + defaultTier: default_tier, + }); if (tierError) { res.status(400).json({ error: tierError }); return; } - } else if ( - default_tier && - !VALID_TIERS.includes(default_tier as (typeof VALID_TIERS)[number]) - ) { - res.status(400).json({ - error: `Invalid default_tier '${default_tier}': must be one of ${VALID_TIERS.join(', ')}`, - }); - return; + } else { + const defaultTierError = validateCompositeDefaultTier(default_tier); + if (defaultTierError) { + res.status(400).json({ + error: defaultTierError, + }); + return; + } } const result = updateCompositeVariant(name, { defaultTier: default_tier, tiers }); if (!result.success) { const status = result.error?.includes('not found') ? 404 : 400; - res.status(status).json({ error: result.error }); + res.status(status).json({ + error: result.error, + }); return; } diff --git a/tests/unit/cliproxy/composite-env-routing.test.ts b/tests/unit/cliproxy/composite-env-routing.test.ts new file mode 100644 index 00000000..64076d48 --- /dev/null +++ b/tests/unit/cliproxy/composite-env-routing.test.ts @@ -0,0 +1,60 @@ +/** + * Composite environment routing tests. + */ + +import { describe, it, expect } from 'bun:test'; +import { buildClaudeEnvironment } from '../../../src/cliproxy/executor/env-resolver'; + +const tiers = { + opus: { provider: 'agy' as const, model: 'claude-opus-4-6-thinking' }, + sonnet: { provider: 'gemini' as const, model: 'gemini-2.5-pro' }, + haiku: { provider: 'codex' as const, model: 'gpt-5.1-codex-mini' }, +}; + +describe('buildClaudeEnvironment - composite remote routing', () => { + it('uses remote base URL and auth token for direct remote composite mode', () => { + const env = buildClaudeEnvironment({ + provider: 'agy', + useRemoteProxy: true, + remoteConfig: { + host: 'remote.example.com', + port: 9443, + protocol: 'https', + authToken: 'remote-auth-token', + }, + localPort: 8318, + verbose: false, + isComposite: true, + compositeTiers: tiers, + compositeDefaultTier: 'sonnet', + }); + + expect(env.ANTHROPIC_BASE_URL).toBe('https://remote.example.com:9443'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('remote-auth-token'); + expect(env.ANTHROPIC_BASE_URL).not.toContain('/api/provider/'); + }); + + it('uses local tunnel endpoint for HTTPS remote composite mode', () => { + const env = buildClaudeEnvironment({ + provider: 'agy', + useRemoteProxy: true, + remoteConfig: { + host: 'remote.example.com', + port: 9443, + protocol: 'https', + authToken: 'remote-auth-token', + }, + httpsTunnel: {} as never, + tunnelPort: 9911, + localPort: 8318, + verbose: false, + isComposite: true, + compositeTiers: tiers, + compositeDefaultTier: 'sonnet', + }); + + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9911'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('remote-auth-token'); + expect(env.ANTHROPIC_BASE_URL).not.toContain('/api/provider/'); + }); +}); diff --git a/tests/unit/cliproxy/composite-thinking.test.ts b/tests/unit/cliproxy/composite-thinking.test.ts index 30d8cf5e..0112d6b9 100644 --- a/tests/unit/cliproxy/composite-thinking.test.ts +++ b/tests/unit/cliproxy/composite-thinking.test.ts @@ -284,6 +284,56 @@ describe('applyThinkingConfig - composite variant integration', () => { 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('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(high)'); + }); + it('should handle numeric budgets in per-tier thinking', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', diff --git a/tests/unit/cliproxy/composite-validator.test.ts b/tests/unit/cliproxy/composite-validator.test.ts new file mode 100644 index 00000000..3701b089 --- /dev/null +++ b/tests/unit/cliproxy/composite-validator.test.ts @@ -0,0 +1,80 @@ +/** + * Unit tests for shared composite variant validation. + */ + +import { describe, it, expect } from 'bun:test'; +import { + validateCompositeDefaultTier, + validateCompositeTiers, +} from '../../../src/cliproxy/composite-validator'; + +const validTier = { + provider: 'agy', + model: 'claude-sonnet-4-5-thinking', +}; + +describe('validateCompositeDefaultTier', () => { + it('accepts valid tier names', () => { + expect(validateCompositeDefaultTier('opus')).toBeNull(); + expect(validateCompositeDefaultTier('sonnet')).toBeNull(); + expect(validateCompositeDefaultTier('haiku')).toBeNull(); + }); + + it('rejects invalid tier names', () => { + const error = validateCompositeDefaultTier('invalid-tier'); + expect(error).toContain("Invalid default_tier 'invalid-tier'"); + }); +}); + +describe('validateCompositeTiers', () => { + it('rejects missing required tiers in create mode', () => { + const error = validateCompositeTiers( + { + opus: validTier, + sonnet: validTier, + }, + { defaultTier: 'sonnet', requireAllTiers: true } + ); + + expect(error).toContain("Missing required tier 'haiku'"); + }); + + it('rejects null tier objects', () => { + const error = validateCompositeTiers( + { + opus: null, + sonnet: validTier, + haiku: validTier, + }, + { defaultTier: 'sonnet', requireAllTiers: true } + ); + + expect(error).toContain("Invalid tier config for 'opus'"); + }); + + it('accepts partial updates in update mode', () => { + const error = validateCompositeTiers( + { + opus: { provider: 'gemini', model: 'gemini-2.5-pro' }, + }, + { defaultTier: 'sonnet' } + ); + + expect(error).toBeNull(); + }); + + it('rejects circular fallback definitions', () => { + const error = validateCompositeTiers( + { + opus: { + provider: 'gemini', + model: 'gemini-2.5-pro', + fallback: { provider: 'gemini', model: 'gemini-2.5-pro' }, + }, + }, + { defaultTier: 'opus' } + ); + + expect(error).toContain("Circular fallback in tier 'opus'"); + }); +}); diff --git a/tests/unit/cliproxy/composite-variant-service.test.ts b/tests/unit/cliproxy/composite-variant-service.test.ts index 93745c25..5f291315 100644 --- a/tests/unit/cliproxy/composite-variant-service.test.ts +++ b/tests/unit/cliproxy/composite-variant-service.test.ts @@ -6,7 +6,10 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; -import { updateCompositeVariant } from '../../../src/cliproxy/services/variant-service'; +import { + createCompositeVariant, + updateCompositeVariant, +} from '../../../src/cliproxy/services/variant-service'; import { saveCompositeVariantUnified, listVariantsFromConfig, @@ -173,6 +176,51 @@ cliproxy: expect(result.variant?.tiers?.haiku.provider).toBe('gemini'); }); + it('should preserve optional tier fields when updating provider/model only', () => { + const initialConfig: CompositeVariantConfig = { + type: 'composite', + default_tier: 'sonnet', + tiers: { + opus: { + provider: 'agy', + model: 'claude-opus-4-6-thinking', + fallback: { provider: 'gemini', model: 'gemini-2.5-flash' }, + thinking: 'xhigh', + account: 'team-a', + }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, + }, + settings: 'cliproxy/composite-test.settings.json', + port: 8318, + }; + saveCompositeVariantUnified('test', initialConfig); + + const settingsDir = path.join(tmpDir, 'cliproxy'); + fs.mkdirSync(settingsDir, { recursive: true }); + fs.writeFileSync( + path.join(settingsDir, 'composite-test.settings.json'), + JSON.stringify({ env: {} }), + 'utf-8' + ); + + const result = updateCompositeVariant('test', { + tiers: { + opus: { provider: 'gemini', model: 'gemini-2.5-pro' }, + }, + }); + + expect(result.success).toBe(true); + expect(result.variant?.tiers?.opus.provider).toBe('gemini'); + expect(result.variant?.tiers?.opus.model).toBe('gemini-2.5-pro'); + expect(result.variant?.tiers?.opus.fallback).toEqual({ + provider: 'gemini', + model: 'gemini-2.5-flash', + }); + expect(result.variant?.tiers?.opus.thinking).toBe('xhigh'); + expect(result.variant?.tiers?.opus.account).toBe('team-a'); + }); + it('should return error when variant does not exist', () => { const result = updateCompositeVariant('nonexistent', { tiers: { @@ -219,6 +267,87 @@ cliproxy: }); }); +describe('createCompositeVariant', () => { + let tmpDir: string; + let originalCcsDir: string | undefined; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-composite-test-')); + originalCcsDir = process.env.CCS_DIR; + process.env.CCS_DIR = tmpDir; + + const configPath = path.join(tmpDir, 'config.yaml'); + fs.writeFileSync( + configPath, + `version: 2 +accounts: {} +profiles: {} +preferences: + theme: system + telemetry: false + auto_update: true +cliproxy: + oauth_accounts: {} + providers: + - gemini + - codex + - agy + variants: {} +`, + 'utf-8' + ); + }); + + afterEach(() => { + if (originalCcsDir !== undefined) { + process.env.CCS_DIR = originalCcsDir; + } else { + delete process.env.CCS_DIR; + } + + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('returns validation error for missing required tier in create flow', () => { + const result = createCompositeVariant({ + name: 'broken', + defaultTier: 'sonnet', + tiers: { + opus: { provider: 'gemini', model: 'gemini-2.5-pro' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + } as unknown as { + opus: { provider: 'gemini' | 'codex' | 'agy'; model: string }; + sonnet: { provider: 'gemini' | 'codex' | 'agy'; model: string }; + haiku: { provider: 'gemini' | 'codex' | 'agy'; model: string }; + }, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Missing required tier 'haiku'"); + }); + + it('returns validation error for null tier payload in create flow', () => { + const result = createCompositeVariant({ + name: 'broken-null', + defaultTier: 'sonnet', + tiers: { + opus: null, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, + } as unknown as { + opus: { provider: 'gemini' | 'codex' | 'agy'; model: string }; + sonnet: { provider: 'gemini' | 'codex' | 'agy'; model: string }; + haiku: { provider: 'gemini' | 'codex' | 'agy'; model: string }; + }, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Invalid tier config for 'opus'"); + }); +}); + describe('saveCompositeVariantUnified', () => { let tmpDir: string; let originalCcsDir: string | undefined;