fix(cliproxy): harden composite validation and runtime safeguards

This commit is contained in:
Tam Nhu Tran
2026-02-13 05:34:30 +07:00
parent 2816c6652d
commit 1a23f912a4
10 changed files with 509 additions and 101 deletions
+118
View File
@@ -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<Record<CompositeTierName, CompositeTierConfig>>;
function isRecord(value: unknown): value is Record<string, unknown> {
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;
}
+12 -4
View File
@@ -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 &&
+12
View File
@@ -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);
+18 -7
View File
@@ -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,
+7 -22
View File
@@ -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<string>(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<string, unknown>)) {
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})`);
}
}
}
+22 -67
View File
@@ -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<string, { provider?: string; model?: string }>,
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;
}
@@ -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/');
});
});
@@ -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',
@@ -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'");
});
});
@@ -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;