mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
fix(cliproxy): harden composite validation and runtime safeguards
This commit is contained in:
1 parent
2816c6652d
commit
1a23f912a4
10 files changed
+509
-101
No files matched your search
@@ -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;
|
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
|
// Get base model to check thinking support
|
||||||
const baseModel = result.ANTHROPIC_MODEL || '';
|
const baseModel = result.ANTHROPIC_MODEL || '';
|
||||||
if (!supportsThinking(provider, baseModel)) {
|
if (!supportsThinking(provider, baseModel)) {
|
||||||
@@ -152,10 +162,8 @@ export function applyThinkingConfig(
|
|||||||
}
|
}
|
||||||
thinkingValue = validation.value;
|
thinkingValue = validation.value;
|
||||||
|
|
||||||
// P1 FIX: If validation says 'off' AND no per-tier thinking config, skip ALL processing
|
// If auto-detection resolves default tier to "off", skip the main model but still allow
|
||||||
// This distinguishes between:
|
// explicit per-tier thinking values for other tiers.
|
||||||
// 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 (thinkingValue === 'off') {
|
if (thinkingValue === 'off') {
|
||||||
const hasPerTierThinking =
|
const hasPerTierThinking =
|
||||||
compositeTierThinking &&
|
compositeTierThinking &&
|
||||||
|
|||||||
@@ -840,6 +840,18 @@ export async function execClaudeWithCLIProxy(
|
|||||||
compositeDefaultTier: cfg.compositeDefaultTier,
|
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();
|
const webSearchEnv = getWebSearchHookEnv();
|
||||||
logEnvironment(env, webSearchEnv, verbose);
|
logEnvironment(env, webSearchEnv, verbose);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { isUnifiedMode } from '../../config/unified-config-loader';
|
|||||||
import { deleteConfigForPort } from '../config-generator';
|
import { deleteConfigForPort } from '../config-generator';
|
||||||
import { hasActiveSessions, deleteSessionLockForPort } from '../session-tracker';
|
import { hasActiveSessions, deleteSessionLockForPort } from '../session-tracker';
|
||||||
import { warn } from '../../utils/ui';
|
import { warn } from '../../utils/ui';
|
||||||
|
import { validateCompositeTiers } from '../composite-validator';
|
||||||
import {
|
import {
|
||||||
createSettingsFile,
|
createSettingsFile,
|
||||||
createSettingsFileUnified,
|
createSettingsFileUnified,
|
||||||
@@ -309,6 +310,14 @@ export function createCompositeVariant(
|
|||||||
try {
|
try {
|
||||||
const { name, defaultTier, tiers } = options;
|
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
|
// Validate all tier providers against backend compatibility
|
||||||
const tierNames: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku'];
|
const tierNames: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku'];
|
||||||
for (const tier of tierNames) {
|
for (const tier of tierNames) {
|
||||||
@@ -387,6 +396,15 @@ export function updateCompositeVariant(
|
|||||||
haiku: { ...existing.tiers.haiku, ...updates.tiers?.haiku },
|
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
|
// Validate all tier providers against backend compatibility
|
||||||
const tierNames: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku'];
|
const tierNames: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku'];
|
||||||
for (const tier of tierNames) {
|
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
|
// Create new settings file with updated config
|
||||||
const settingsPath = createCompositeSettingsFile(
|
const settingsPath = createCompositeSettingsFile(
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ import {
|
|||||||
ThinkingConfig,
|
ThinkingConfig,
|
||||||
DashboardAuthConfig,
|
DashboardAuthConfig,
|
||||||
ImageAnalysisConfig,
|
ImageAnalysisConfig,
|
||||||
CLIPROXY_SUPPORTED_PROVIDERS,
|
|
||||||
CursorConfig,
|
CursorConfig,
|
||||||
} from './unified-config-types';
|
} from './unified-config-types';
|
||||||
|
import { validateCompositeTiers } from '../cliproxy/composite-validator';
|
||||||
import { isUnifiedConfigEnabled } from './feature-flags';
|
import { isUnifiedConfigEnabled } from './feature-flags';
|
||||||
|
|
||||||
const CONFIG_YAML = 'config.yaml';
|
const CONFIG_YAML = 'config.yaml';
|
||||||
@@ -212,32 +212,17 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
|
|||||||
* Warns about invalid providers in composite variant configurations.
|
* Warns about invalid providers in composite variant configurations.
|
||||||
*/
|
*/
|
||||||
function validateCompositeVariants(config: UnifiedConfig): void {
|
function validateCompositeVariants(config: UnifiedConfig): void {
|
||||||
const validProviders = new Set<string>(CLIPROXY_SUPPORTED_PROVIDERS);
|
|
||||||
const variants = config.cliproxy?.variants;
|
const variants = config.cliproxy?.variants;
|
||||||
if (!variants) return;
|
if (!variants) return;
|
||||||
|
|
||||||
for (const [name, variant] of Object.entries(variants)) {
|
for (const [name, variant] of Object.entries(variants)) {
|
||||||
if ('type' in variant && variant.type === 'composite') {
|
if ('type' in variant && variant.type === 'composite') {
|
||||||
// Guard against malformed composite variants
|
const error = validateCompositeTiers(variant.tiers, {
|
||||||
if (!variant.tiers || typeof variant.tiers !== 'object') {
|
defaultTier: variant.default_tier,
|
||||||
console.warn(`[!] Composite variant '${name}' missing tiers object, skipping validation`);
|
requireAllTiers: true,
|
||||||
continue;
|
});
|
||||||
}
|
if (error) {
|
||||||
|
console.warn(`[!] Variant '${name}': invalid composite config (${error})`);
|
||||||
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`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names';
|
import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names';
|
||||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||||
import { CLIPROXY_SUPPORTED_PROVIDERS } from '../../config/unified-config-types';
|
|
||||||
import {
|
import {
|
||||||
createVariant,
|
createVariant,
|
||||||
removeVariant,
|
removeVariant,
|
||||||
@@ -17,64 +16,13 @@ import {
|
|||||||
createCompositeVariant,
|
createCompositeVariant,
|
||||||
updateCompositeVariant,
|
updateCompositeVariant,
|
||||||
} from '../../cliproxy/services/variant-service';
|
} from '../../cliproxy/services/variant-service';
|
||||||
|
import {
|
||||||
|
validateCompositeDefaultTier,
|
||||||
|
validateCompositeTiers,
|
||||||
|
} from '../../cliproxy/composite-validator';
|
||||||
|
|
||||||
const router = Router();
|
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
|
* GET /api/cliproxy - List cliproxy variants
|
||||||
* Uses variant-service for consistent behavior with CLI
|
* 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)
|
// 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) {
|
if (tierError) {
|
||||||
res.status(400).json({ error: tierError });
|
res.status(400).json({ error: tierError });
|
||||||
return;
|
return;
|
||||||
@@ -221,26 +172,30 @@ router.put('/:name', (req: Request, res: Response): void => {
|
|||||||
|
|
||||||
// Validate tiers shape, providers, and default_tier if provided
|
// Validate tiers shape, providers, and default_tier if provided
|
||||||
if (tiers) {
|
if (tiers) {
|
||||||
const tierError = validateCompositeTiers(tiers, default_tier);
|
const tierError = validateCompositeTiers(tiers, {
|
||||||
|
defaultTier: default_tier,
|
||||||
|
});
|
||||||
if (tierError) {
|
if (tierError) {
|
||||||
res.status(400).json({ error: tierError });
|
res.status(400).json({ error: tierError });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (
|
} else {
|
||||||
default_tier &&
|
const defaultTierError = validateCompositeDefaultTier(default_tier);
|
||||||
!VALID_TIERS.includes(default_tier as (typeof VALID_TIERS)[number])
|
if (defaultTierError) {
|
||||||
) {
|
res.status(400).json({
|
||||||
res.status(400).json({
|
error: defaultTierError,
|
||||||
error: `Invalid default_tier '${default_tier}': must be one of ${VALID_TIERS.join(', ')}`,
|
});
|
||||||
});
|
return;
|
||||||
return;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = updateCompositeVariant(name, { defaultTier: default_tier, tiers });
|
const result = updateCompositeVariant(name, { defaultTier: default_tier, tiers });
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const status = result.error?.includes('not found') ? 404 : 400;
|
const status = result.error?.includes('not found') ? 404 : 400;
|
||||||
res.status(status).json({ error: result.error });
|
res.status(status).json({
|
||||||
|
error: result.error,
|
||||||
|
});
|
||||||
return;
|
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');
|
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', () => {
|
it('should handle numeric budgets in per-tier thinking', () => {
|
||||||
const envVars: NodeJS.ProcessEnv = {
|
const envVars: NodeJS.ProcessEnv = {
|
||||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
|
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 os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
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 {
|
import {
|
||||||
saveCompositeVariantUnified,
|
saveCompositeVariantUnified,
|
||||||
listVariantsFromConfig,
|
listVariantsFromConfig,
|
||||||
@@ -173,6 +176,51 @@ cliproxy:
|
|||||||
expect(result.variant?.tiers?.haiku.provider).toBe('gemini');
|
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', () => {
|
it('should return error when variant does not exist', () => {
|
||||||
const result = updateCompositeVariant('nonexistent', {
|
const result = updateCompositeVariant('nonexistent', {
|
||||||
tiers: {
|
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', () => {
|
describe('saveCompositeVariantUnified', () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let originalCcsDir: string | undefined;
|
let originalCcsDir: string | undefined;
|
||||||
|
|||||||
Reference in new issue
Block a user