mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 06:17:09 +00:00
fix(cliproxy): harden composite validation and runtime safeguards
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user