mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-23 12:23:35 +00:00
Merge pull request #537 from kaitranntt/feat/506-composite-provider-variant
feat(cliproxy): composite provider variants — mix providers per tier
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"sourceDirectory": "src",
|
||||
"largeFileThresholdLoc": 350,
|
||||
"typeScriptFileCount": 338,
|
||||
"locInSrc": 65869,
|
||||
"processExitReferenceCount": 152,
|
||||
"synchronousFsApiReferenceCount": 842,
|
||||
"largeFileCountOver350Loc": 52
|
||||
"typeScriptFileCount": 341,
|
||||
"locInSrc": 67708,
|
||||
"processExitReferenceCount": 164,
|
||||
"synchronousFsApiReferenceCount": 850,
|
||||
"largeFileCountOver350Loc": 55
|
||||
}
|
||||
|
||||
@@ -72,9 +72,13 @@ export function listApiProfiles(): ApiListResult {
|
||||
}
|
||||
// CLIProxy variants
|
||||
for (const [name, variant] of Object.entries(unifiedConfig.cliproxy?.variants || {})) {
|
||||
const provider =
|
||||
variant && 'type' in variant && variant.type === 'composite'
|
||||
? 'composite'
|
||||
: (variant as { provider?: string })?.provider || 'unknown';
|
||||
variants.push({
|
||||
name,
|
||||
provider: variant?.provider || 'unknown',
|
||||
provider,
|
||||
settings: variant?.settings || '-',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,13 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { findSimilarStrings, expandPath } from '../utils/helpers';
|
||||
import { Config, Settings, ProfileMetadata } from '../types';
|
||||
import { UnifiedConfig, CopilotConfig } from '../config/unified-config-types';
|
||||
import {
|
||||
UnifiedConfig,
|
||||
CopilotConfig,
|
||||
CLIProxyVariantConfig,
|
||||
CompositeVariantConfig,
|
||||
CompositeTierConfig,
|
||||
} from '../config/unified-config-types';
|
||||
import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader';
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
import type { CLIProxyProvider } from '../cliproxy/types';
|
||||
@@ -39,6 +45,16 @@ export interface ProfileDetectionResult {
|
||||
env?: Record<string, string>;
|
||||
/** For copilot profile: the copilot config */
|
||||
copilotConfig?: CopilotConfig;
|
||||
/** For composite variants: true when variant mixes providers per tier */
|
||||
isComposite?: boolean;
|
||||
/** For composite variants: which tier is the default */
|
||||
compositeDefaultTier?: 'opus' | 'sonnet' | 'haiku';
|
||||
/** For composite variants: per-tier provider+model mappings */
|
||||
compositeTiers?: {
|
||||
opus: CompositeTierConfig;
|
||||
sonnet: CompositeTierConfig;
|
||||
haiku: CompositeTierConfig;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AllProfiles {
|
||||
@@ -103,12 +119,46 @@ class ProfileDetector {
|
||||
// Check CLIProxy variants first
|
||||
if (config.cliproxy?.variants?.[profileName]) {
|
||||
const variant = config.cliproxy.variants[profileName];
|
||||
|
||||
// Handle composite variants
|
||||
if ('type' in variant && variant.type === 'composite') {
|
||||
const composite = variant as CompositeVariantConfig;
|
||||
|
||||
// Defensive: check for missing tiers or default_tier
|
||||
if (!composite.tiers || !composite.default_tier) {
|
||||
console.warn(
|
||||
`[!] Warning: Composite variant '${profileName}' has missing tiers or default_tier`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const defaultTierConfig = composite.tiers[composite.default_tier];
|
||||
if (!defaultTierConfig) {
|
||||
console.warn(
|
||||
`[!] Warning: Composite variant '${profileName}' missing config for default tier '${composite.default_tier}'`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'cliproxy',
|
||||
name: profileName,
|
||||
provider: defaultTierConfig.provider as CLIProxyProfileName,
|
||||
settingsPath: composite.settings,
|
||||
port: composite.port,
|
||||
isComposite: true,
|
||||
compositeDefaultTier: composite.default_tier,
|
||||
compositeTiers: composite.tiers,
|
||||
};
|
||||
}
|
||||
|
||||
const singleVariant = variant as CLIProxyVariantConfig;
|
||||
return {
|
||||
type: 'cliproxy',
|
||||
name: profileName,
|
||||
provider: variant.provider as CLIProxyProfileName,
|
||||
settingsPath: variant.settings,
|
||||
port: variant.port,
|
||||
provider: singleVariant.provider as CLIProxyProfileName,
|
||||
settingsPath: singleVariant.settings,
|
||||
port: singleVariant.port,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -369,7 +419,11 @@ class ProfileDetector {
|
||||
lines.push('CLIProxy variants (unified config):');
|
||||
variants.forEach((name) => {
|
||||
const variant = unifiedConfig.cliproxy?.variants[name];
|
||||
lines.push(` - ${name} (${variant?.provider || 'unknown'})`);
|
||||
const label =
|
||||
variant && 'type' in variant && variant.type === 'composite'
|
||||
? 'composite'
|
||||
: (variant as { provider?: string })?.provider || 'unknown';
|
||||
lines.push(` - ${name} (${label})`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -616,6 +616,10 @@ async function main(): Promise<void> {
|
||||
await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, {
|
||||
customSettingsPath,
|
||||
port: variantPort,
|
||||
isComposite: profileInfo.isComposite,
|
||||
compositeTiers: profileInfo.compositeTiers,
|
||||
compositeDefaultTier: profileInfo.compositeDefaultTier,
|
||||
profileName: profileInfo.name,
|
||||
});
|
||||
} else if (profileInfo.type === 'copilot') {
|
||||
// COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { getGlobalEnvConfig } from '../../config/unified-config-loader';
|
||||
import { getEffectiveApiKey } from '../auth-token-manager';
|
||||
import { expandPath } from '../../utils/helpers';
|
||||
import { warn } from '../../utils/ui';
|
||||
import { CompositeTierConfig } from '../../config/unified-config-types';
|
||||
import {
|
||||
validatePort,
|
||||
validateRemotePort,
|
||||
@@ -413,3 +414,102 @@ export function getRemoteEnvVars(
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
/** Remote config for composite variant (passed from env-resolver) */
|
||||
export interface CompositeRemoteConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
protocol: 'http' | 'https';
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variables for composite variant.
|
||||
* Uses root URL (no /api/provider/ path) for model-based routing.
|
||||
* Each tier maps to a different provider's model, routed by CLIProxyAPI.
|
||||
*
|
||||
* @param tiers Per-tier provider+model mappings
|
||||
* @param defaultTier Which tier ANTHROPIC_MODEL equals
|
||||
* @param port Local CLIProxy port (ignored if remoteConfig provided)
|
||||
* @param customSettingsPath Optional path to user's custom settings file
|
||||
* @param remoteConfig Optional remote proxy config (overrides localhost URL/auth)
|
||||
*/
|
||||
export function getCompositeEnvVars(
|
||||
tiers: { opus: CompositeTierConfig; sonnet: CompositeTierConfig; haiku: CompositeTierConfig },
|
||||
defaultTier: 'opus' | 'sonnet' | 'haiku',
|
||||
port: number = CLIPROXY_DEFAULT_PORT,
|
||||
customSettingsPath?: string,
|
||||
remoteConfig?: CompositeRemoteConfig
|
||||
): Record<string, string> {
|
||||
const globalEnv = getGlobalEnvVars();
|
||||
|
||||
// Load user settings if provided (may contain additional env vars like hooks)
|
||||
let additionalEnvVars: Record<string, string> = {};
|
||||
if (customSettingsPath) {
|
||||
const expandedPath = expandPath(customSettingsPath);
|
||||
if (fs.existsSync(expandedPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(expandedPath, 'utf-8');
|
||||
const settings: ProviderSettings = JSON.parse(content);
|
||||
if (settings.env && typeof settings.env === 'object') {
|
||||
// Extract non-core env vars (hooks, etc.)
|
||||
const {
|
||||
ANTHROPIC_BASE_URL: _baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: _authToken,
|
||||
ANTHROPIC_MODEL: _model,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: _opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: _sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: _haiku,
|
||||
...extra
|
||||
} = settings.env as Record<string, string>;
|
||||
additionalEnvVars = extra;
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON — ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const validPort = validatePort(port);
|
||||
|
||||
// Defensive: handle missing tiers gracefully
|
||||
const opusModel = tiers.opus?.model;
|
||||
const sonnetModel = tiers.sonnet?.model;
|
||||
const haikuModel = tiers.haiku?.model;
|
||||
const defaultModel = tiers[defaultTier]?.model;
|
||||
|
||||
// If default tier is missing, we cannot proceed meaningfully
|
||||
if (!defaultModel) {
|
||||
throw new Error(`Missing model for default tier '${defaultTier}'`);
|
||||
}
|
||||
|
||||
// Determine base URL and auth token based on remote vs local mode
|
||||
const baseUrl = remoteConfig
|
||||
? (() => {
|
||||
const normalizedProtocol = normalizeProtocol(remoteConfig.protocol);
|
||||
const effectivePort =
|
||||
validateRemotePort(remoteConfig.port) ?? getRemoteDefaultPort(normalizedProtocol);
|
||||
const standardWebPort = normalizedProtocol === 'https' ? 443 : 80;
|
||||
const portSuffix = effectivePort === standardWebPort ? '' : `:${effectivePort}`;
|
||||
return `${normalizedProtocol}://${remoteConfig.host}${portSuffix}`;
|
||||
})()
|
||||
: `http://127.0.0.1:${validPort}`;
|
||||
|
||||
const authToken = remoteConfig?.authToken ?? getEffectiveApiKey();
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...globalEnv,
|
||||
...additionalEnvVars,
|
||||
// Root URL — CLIProxyAPI routes based on model name in request body
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: authToken,
|
||||
ANTHROPIC_MODEL: defaultModel,
|
||||
};
|
||||
|
||||
// Only set tier env vars if the tier exists
|
||||
if (opusModel) env.ANTHROPIC_DEFAULT_OPUS_MODEL = opusModel;
|
||||
if (sonnetModel) env.ANTHROPIC_DEFAULT_SONNET_MODEL = sonnetModel;
|
||||
if (haikuModel) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = haikuModel;
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -67,19 +67,38 @@ export function getThinkingValueForTier(
|
||||
return thinkingConfig.tier_defaults?.[tier] ?? DEFAULT_THINKING_TIER_DEFAULTS[tier];
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite tier config for provider lookup (subset of full CompositeTierConfig)
|
||||
*/
|
||||
interface CompositeTierProvider {
|
||||
provider?: CLIProxyProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply thinking configuration to env vars.
|
||||
* Modifies ANTHROPIC_MODEL and tier models with thinking suffixes.
|
||||
*
|
||||
* @param envVars - Environment variables to modify
|
||||
* @param provider - CLIProxy provider
|
||||
* @param provider - CLIProxy provider (default provider for base model)
|
||||
* @param thinkingOverride - Optional CLI override (takes priority over config)
|
||||
* @param compositeTierThinking - Optional per-tier thinking overrides for composite variants
|
||||
* @param compositeTiers - Optional per-tier provider config for composite variants
|
||||
* @returns Modified env vars with thinking suffixes applied
|
||||
*/
|
||||
export function applyThinkingConfig(
|
||||
envVars: NodeJS.ProcessEnv,
|
||||
provider: CLIProxyProvider,
|
||||
thinkingOverride?: string | number
|
||||
thinkingOverride?: string | number,
|
||||
compositeTierThinking?: {
|
||||
opus?: string;
|
||||
sonnet?: string;
|
||||
haiku?: string;
|
||||
},
|
||||
compositeTiers?: {
|
||||
opus?: CompositeTierProvider;
|
||||
sonnet?: CompositeTierProvider;
|
||||
haiku?: CompositeTierProvider;
|
||||
}
|
||||
): NodeJS.ProcessEnv {
|
||||
const thinkingConfig = getThinkingConfig();
|
||||
const result = { ...envVars };
|
||||
@@ -89,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)) {
|
||||
@@ -115,7 +144,13 @@ export function applyThinkingConfig(
|
||||
} else if (thinkingConfig.mode === 'auto') {
|
||||
// Auto mode: detect tier and apply default
|
||||
const tier = detectTierFromModel(baseModel);
|
||||
thinkingValue = getThinkingValueForTier(tier, provider, thinkingConfig);
|
||||
// Check per-tier config first if composite
|
||||
const perTierValue = compositeTierThinking?.[tier];
|
||||
if (perTierValue !== undefined) {
|
||||
thinkingValue = perTierValue;
|
||||
} else {
|
||||
thinkingValue = getThinkingValueForTier(tier, provider, thinkingConfig);
|
||||
}
|
||||
} else {
|
||||
return result; // No thinking to apply
|
||||
}
|
||||
@@ -127,13 +162,18 @@ export function applyThinkingConfig(
|
||||
}
|
||||
thinkingValue = validation.value;
|
||||
|
||||
// If validation says off, don't apply suffix
|
||||
// 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') {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Apply thinking suffix to main model
|
||||
if (result.ANTHROPIC_MODEL) {
|
||||
const hasPerTierThinking =
|
||||
compositeTierThinking &&
|
||||
Object.values(compositeTierThinking).some((v) => v !== undefined && v !== 'off');
|
||||
if (!hasPerTierThinking) {
|
||||
return result; // No thinking to apply anywhere
|
||||
}
|
||||
// Otherwise, continue to process tiers with their own config (skip main model)
|
||||
} else if (result.ANTHROPIC_MODEL) {
|
||||
// Apply thinking suffix to main model (only if not off)
|
||||
result.ANTHROPIC_MODEL = applyThinkingSuffix(result.ANTHROPIC_MODEL, thinkingValue);
|
||||
}
|
||||
|
||||
@@ -146,15 +186,44 @@ export function applyThinkingConfig(
|
||||
|
||||
for (const tierVar of tierModels) {
|
||||
const model = result[tierVar];
|
||||
if (model && supportsThinking(provider, model)) {
|
||||
if (model) {
|
||||
// Get tier-specific thinking value
|
||||
const tier = tierVar.includes('OPUS')
|
||||
? 'opus'
|
||||
: tierVar.includes('SONNET')
|
||||
? 'sonnet'
|
||||
: 'haiku';
|
||||
const tierThinkingValue =
|
||||
thinkingOverride ?? getThinkingValueForTier(tier, provider, thinkingConfig);
|
||||
|
||||
// P2 FIX: Use tier-specific provider from compositeTiers for mixed-provider composites
|
||||
// Falls back to the default provider if not a composite or tier not specified
|
||||
const tierProvider = compositeTiers?.[tier]?.provider ?? provider;
|
||||
|
||||
// Check if this tier's model supports thinking (using tier-specific provider)
|
||||
if (!supportsThinking(tierProvider, model)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Priority chain: CLI --thinking > per-tier config > global config > defaults
|
||||
let tierThinkingValue: string | number;
|
||||
if (thinkingOverride !== undefined) {
|
||||
// CLI override takes priority
|
||||
tierThinkingValue = thinkingOverride;
|
||||
} else {
|
||||
const perTierValue = compositeTierThinking?.[tier];
|
||||
if (perTierValue !== undefined) {
|
||||
// Per-tier config from composite variant
|
||||
tierThinkingValue = perTierValue;
|
||||
} else {
|
||||
// Global config or defaults (use tier-specific provider for provider overrides)
|
||||
tierThinkingValue = getThinkingValueForTier(tier, tierProvider, thinkingConfig);
|
||||
}
|
||||
}
|
||||
|
||||
// If per-tier thinking is 'off', skip this tier
|
||||
if (tierThinkingValue === 'off') {
|
||||
continue;
|
||||
}
|
||||
|
||||
result[tierVar] = applyThinkingSuffix(model, tierThinkingValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,15 @@
|
||||
* - WebSearch and ImageAnalysis hook integration
|
||||
*/
|
||||
|
||||
import { getEffectiveEnvVars, getRemoteEnvVars, applyThinkingConfig } from '../config-generator';
|
||||
import {
|
||||
getEffectiveEnvVars,
|
||||
getRemoteEnvVars,
|
||||
getCompositeEnvVars,
|
||||
applyThinkingConfig,
|
||||
} from '../config-generator';
|
||||
import { applyExtendedContextConfig } from '../config/extended-context-config';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { CompositeTierConfig } from '../../config/unified-config-types';
|
||||
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
|
||||
import { getImageAnalysisHookEnv } from '../../utils/hooks/get-image-analysis-hook-env';
|
||||
import { CodexReasoningProxy } from '../codex-reasoning-proxy';
|
||||
@@ -41,6 +47,16 @@ export interface ProxyChainConfig {
|
||||
/** Extended context override: true = force on, false = force off, undefined = auto */
|
||||
extendedContextOverride?: boolean;
|
||||
verbose: boolean;
|
||||
/** Composite variant: true when mixing providers per tier */
|
||||
isComposite?: boolean;
|
||||
/** Composite variant: per-tier provider+model mappings */
|
||||
compositeTiers?: {
|
||||
opus: CompositeTierConfig;
|
||||
sonnet: CompositeTierConfig;
|
||||
haiku: CompositeTierConfig;
|
||||
};
|
||||
/** Composite variant: which tier is the default */
|
||||
compositeDefaultTier?: 'opus' | 'sonnet' | 'haiku';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,53 +76,107 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
|
||||
extendedContextOverride,
|
||||
codexReasoningPort,
|
||||
toolSanitizationPort,
|
||||
isComposite,
|
||||
compositeTiers,
|
||||
compositeDefaultTier,
|
||||
} = config;
|
||||
|
||||
// Build base env vars - remote or local
|
||||
// Build base env vars - check remote mode first
|
||||
let envVars: NodeJS.ProcessEnv;
|
||||
|
||||
if (useRemoteProxy && remoteConfig) {
|
||||
if (httpsTunnel && tunnelPort) {
|
||||
// HTTPS remote via local tunnel - use HTTP to tunnel
|
||||
envVars = getRemoteEnvVars(
|
||||
provider,
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port: tunnelPort,
|
||||
protocol: 'http', // Tunnel speaks HTTP locally
|
||||
authToken: remoteConfig.authToken,
|
||||
},
|
||||
customSettingsPath
|
||||
);
|
||||
// HTTPS remote via local tunnel
|
||||
if (isComposite && compositeTiers && compositeDefaultTier) {
|
||||
envVars = getCompositeEnvVars(
|
||||
compositeTiers,
|
||||
compositeDefaultTier,
|
||||
tunnelPort,
|
||||
customSettingsPath,
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port: tunnelPort,
|
||||
protocol: 'http',
|
||||
authToken: remoteConfig.authToken,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
envVars = getRemoteEnvVars(
|
||||
provider,
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port: tunnelPort,
|
||||
protocol: 'http', // Tunnel speaks HTTP locally
|
||||
authToken: remoteConfig.authToken,
|
||||
},
|
||||
customSettingsPath
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// HTTP remote - direct connection
|
||||
envVars = getRemoteEnvVars(
|
||||
provider,
|
||||
{
|
||||
host: remoteConfig.host,
|
||||
port: remoteConfig.port,
|
||||
protocol: remoteConfig.protocol,
|
||||
authToken: remoteConfig.authToken,
|
||||
},
|
||||
customSettingsPath
|
||||
);
|
||||
if (isComposite && compositeTiers && compositeDefaultTier) {
|
||||
envVars = getCompositeEnvVars(
|
||||
compositeTiers,
|
||||
compositeDefaultTier,
|
||||
remoteConfig.port,
|
||||
customSettingsPath,
|
||||
{
|
||||
host: remoteConfig.host,
|
||||
port: remoteConfig.port,
|
||||
protocol: remoteConfig.protocol,
|
||||
authToken: remoteConfig.authToken,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
envVars = getRemoteEnvVars(
|
||||
provider,
|
||||
{
|
||||
host: remoteConfig.host,
|
||||
port: remoteConfig.port,
|
||||
protocol: remoteConfig.protocol,
|
||||
authToken: remoteConfig.authToken,
|
||||
},
|
||||
customSettingsPath
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Local proxy mode
|
||||
const remoteRewriteConfig = remoteConfig
|
||||
? {
|
||||
host: remoteConfig.host,
|
||||
port: remoteConfig.port,
|
||||
protocol: remoteConfig.protocol,
|
||||
authToken: remoteConfig.authToken,
|
||||
}
|
||||
: undefined;
|
||||
if (isComposite && compositeTiers && compositeDefaultTier) {
|
||||
envVars = getCompositeEnvVars(
|
||||
compositeTiers,
|
||||
compositeDefaultTier,
|
||||
localPort,
|
||||
customSettingsPath
|
||||
);
|
||||
} else {
|
||||
const remoteRewriteConfig = remoteConfig
|
||||
? {
|
||||
host: remoteConfig.host,
|
||||
port: remoteConfig.port,
|
||||
protocol: remoteConfig.protocol,
|
||||
authToken: remoteConfig.authToken,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
envVars = getEffectiveEnvVars(provider, localPort, customSettingsPath, remoteRewriteConfig);
|
||||
envVars = getEffectiveEnvVars(provider, localPort, customSettingsPath, remoteRewriteConfig);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract per-tier thinking from composite config
|
||||
let compositeTierThinking: { opus?: string; sonnet?: string; haiku?: string } | undefined;
|
||||
if (isComposite && compositeTiers) {
|
||||
const tierThinking: { opus?: string; sonnet?: string; haiku?: string } = {};
|
||||
if (compositeTiers.opus?.thinking) tierThinking.opus = compositeTiers.opus.thinking;
|
||||
if (compositeTiers.sonnet?.thinking) tierThinking.sonnet = compositeTiers.sonnet.thinking;
|
||||
if (compositeTiers.haiku?.thinking) tierThinking.haiku = compositeTiers.haiku.thinking;
|
||||
if (Object.keys(tierThinking).length > 0) {
|
||||
compositeTierThinking = tierThinking;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply thinking configuration to model (auto tier-based or manual override)
|
||||
applyThinkingConfig(envVars, provider, thinkingOverride);
|
||||
applyThinkingConfig(envVars, provider, thinkingOverride, compositeTierThinking, compositeTiers);
|
||||
|
||||
// Apply extended context suffix for 1M token context window
|
||||
// Auto-enabled for Gemini, opt-in for Claude (--1m flag)
|
||||
@@ -176,3 +246,24 @@ export function logEnvironment(
|
||||
log(`Claude env: Global env applied (telemetry/reporting disabled)`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply fallback provider config to env vars for a failed tier */
|
||||
export function applyFallback(
|
||||
env: Record<string, string>,
|
||||
failedTier: 'opus' | 'sonnet' | 'haiku',
|
||||
fallback: { provider: string; model: string }
|
||||
): Record<string, string> {
|
||||
const tierEnvMap = {
|
||||
opus: 'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
sonnet: 'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
haiku: 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
} as const;
|
||||
const result = { ...env };
|
||||
const originalModel = result[tierEnvMap[failedTier]];
|
||||
result[tierEnvMap[failedTier]] = fallback.model;
|
||||
// If failed tier is default tier, also update ANTHROPIC_MODEL
|
||||
if (result.ANTHROPIC_MODEL === originalModel) {
|
||||
result.ANTHROPIC_MODEL = fallback.model;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
+154
-33
@@ -113,20 +113,31 @@ export async function execClaudeWithCLIProxy(
|
||||
}
|
||||
};
|
||||
|
||||
// Helper: Extract unique providers from composite tiers
|
||||
const compositeProviders =
|
||||
cfg.isComposite && cfg.compositeTiers
|
||||
? [...new Set(Object.values(cfg.compositeTiers).map((t) => t.provider))]
|
||||
: [];
|
||||
|
||||
// 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults)
|
||||
const unifiedConfig = loadOrCreateUnifiedConfig();
|
||||
|
||||
// 0a. Runtime backend/provider validation
|
||||
const backend: CLIProxyBackend = unifiedConfig.cliproxy?.backend ?? DEFAULT_BACKEND;
|
||||
if (backend === 'original' && PLUS_ONLY_PROVIDERS.includes(provider)) {
|
||||
console.error('');
|
||||
console.error(fail(`${provider} requires CLIProxyAPIPlus backend`));
|
||||
console.error('');
|
||||
console.error('To use this provider, either:');
|
||||
console.error(' 1. Set `cliproxy.backend: plus` in ~/.ccs/config.yaml');
|
||||
console.error(' 2. Use --backend=plus flag: ccs ' + provider + ' --backend=plus');
|
||||
console.error('');
|
||||
throw new Error(`Provider ${provider} requires Plus backend`);
|
||||
|
||||
// Collect all providers to validate (default + composite tiers)
|
||||
const allProviders = [provider, ...compositeProviders];
|
||||
for (const p of allProviders) {
|
||||
if (backend === 'original' && PLUS_ONLY_PROVIDERS.includes(p as CLIProxyProvider)) {
|
||||
console.error('');
|
||||
console.error(fail(`${p} requires CLIProxyAPIPlus backend`));
|
||||
console.error('');
|
||||
console.error('To use this provider, either:');
|
||||
console.error(' 1. Set `cliproxy.backend: plus` in ~/.ccs/config.yaml');
|
||||
console.error(' 2. Use --backend=plus flag: ccs ' + p + ' --backend=plus');
|
||||
console.error('');
|
||||
throw new Error(`Provider ${p} requires Plus backend`);
|
||||
}
|
||||
}
|
||||
|
||||
const cliproxyServerConfig = unifiedConfig.cliproxy_server;
|
||||
@@ -421,8 +432,18 @@ export async function execClaudeWithCLIProxy(
|
||||
|
||||
// Handle --config
|
||||
if (forceConfig && supportsModelConfig(provider)) {
|
||||
await configureProviderModel(provider, true, cfg.customSettingsPath);
|
||||
process.exit(0);
|
||||
// Block --config for composite variants (per-tier models in config.yaml)
|
||||
if (cfg.isComposite) {
|
||||
const variantName = cfg.profileName || provider;
|
||||
console.log(
|
||||
warn('Composite variants use per-tier config. Edit config.yaml to change tier models.')
|
||||
);
|
||||
console.error(` Use "ccs cliproxy edit ${variantName}" to modify composite variants`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
await configureProviderModel(provider, true, cfg.customSettingsPath);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle --logout
|
||||
@@ -477,7 +498,52 @@ export async function execClaudeWithCLIProxy(
|
||||
if (providerConfig.requiresOAuth && !skipLocalAuth) {
|
||||
log(`Checking authentication for ${provider}`);
|
||||
|
||||
if (forceAuth || !isAuthenticated(provider)) {
|
||||
// Multi-provider auth check for composite variants
|
||||
if (compositeProviders.length > 0) {
|
||||
// Handle forceAuth for composite providers
|
||||
if (forceAuth) {
|
||||
const { triggerOAuth } = await import('../auth-handler');
|
||||
const failures: string[] = [];
|
||||
for (const p of compositeProviders) {
|
||||
const authSuccess = await triggerOAuth(p, {
|
||||
verbose,
|
||||
add: addAccount,
|
||||
...(forceHeadless ? { headless: true } : {}),
|
||||
...(setNickname ? { nickname: setNickname } : {}),
|
||||
...(noIncognito ? { noIncognito: true } : {}),
|
||||
...(pasteCallback ? { pasteCallback: true } : {}),
|
||||
...(portForward ? { portForward: true } : {}),
|
||||
});
|
||||
if (!authSuccess) {
|
||||
failures.push(p);
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
const succeeded = compositeProviders.filter((p) => !failures.includes(p));
|
||||
console.error(fail(`Auth failed for: ${failures.join(', ')}`));
|
||||
if (succeeded.length > 0) {
|
||||
console.error(info(`Succeeded: ${succeeded.join(', ')}`));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Check for unauthenticated providers
|
||||
const unauthenticatedProviders: string[] = [];
|
||||
for (const p of compositeProviders) {
|
||||
if (!isAuthenticated(p)) {
|
||||
unauthenticatedProviders.push(p);
|
||||
}
|
||||
}
|
||||
if (unauthenticatedProviders.length > 0) {
|
||||
console.error(fail('Composite variant requires authentication for multiple providers:'));
|
||||
for (const p of unauthenticatedProviders) {
|
||||
console.error(` - ${p} (run "ccs ${p} --auth")`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (forceAuth || !isAuthenticated(provider)) {
|
||||
const { triggerOAuth } = await import('../auth-handler');
|
||||
const authSuccess = await triggerOAuth(provider, {
|
||||
verbose,
|
||||
@@ -498,8 +564,14 @@ export async function execClaudeWithCLIProxy(
|
||||
log(`${provider} already authenticated`);
|
||||
}
|
||||
|
||||
// 3a. Proactive token refresh
|
||||
await handleTokenExpiration(provider, verbose);
|
||||
// 3a. Proactive token refresh (multi-provider for composite)
|
||||
if (compositeProviders.length > 0) {
|
||||
for (const p of compositeProviders) {
|
||||
await handleTokenExpiration(p, verbose);
|
||||
}
|
||||
} else {
|
||||
await handleTokenExpiration(provider, verbose);
|
||||
}
|
||||
|
||||
// 3a-1. Update lastUsedAt
|
||||
const usedAccount = getDefaultAccount(provider);
|
||||
@@ -510,7 +582,14 @@ export async function execClaudeWithCLIProxy(
|
||||
|
||||
// 3b. Preflight quota check (Antigravity only)
|
||||
if (!skipLocalAuth) {
|
||||
await handleQuotaCheck(provider);
|
||||
// Multi-tier quota check for composite variants (check if ANY tier uses 'agy')
|
||||
if (compositeProviders.length > 0) {
|
||||
if (compositeProviders.includes('agy')) {
|
||||
await handleQuotaCheck('agy');
|
||||
}
|
||||
} else {
|
||||
await handleQuotaCheck(provider);
|
||||
}
|
||||
}
|
||||
|
||||
// 3c. Account safety: enforce cross-provider isolation
|
||||
@@ -529,27 +608,50 @@ export async function execClaudeWithCLIProxy(
|
||||
}
|
||||
|
||||
// 4. First-run model configuration
|
||||
if (supportsModelConfig(provider) && !skipLocalAuth) {
|
||||
if (!cfg.isComposite && supportsModelConfig(provider) && !skipLocalAuth) {
|
||||
await configureProviderModel(provider, false, cfg.customSettingsPath);
|
||||
}
|
||||
|
||||
// 5. Check for broken models
|
||||
const currentModel = getCurrentModel(provider, cfg.customSettingsPath);
|
||||
if (currentModel && isModelBroken(provider, currentModel)) {
|
||||
const modelEntry = findModel(provider, currentModel);
|
||||
const issueUrl = getModelIssueUrl(provider, currentModel);
|
||||
console.error('');
|
||||
console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`));
|
||||
console.error(' Tool calls will fail. Use "gemini-3-pro-preview" instead.');
|
||||
if (issueUrl) {
|
||||
console.error(` Tracking: ${issueUrl}`);
|
||||
// 5. Check for broken models (multi-tier for composite)
|
||||
if (compositeProviders.length > 0 && cfg.compositeTiers) {
|
||||
// Check all tier models in composite variant
|
||||
const tiers: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku'];
|
||||
for (const tier of tiers) {
|
||||
const tierConfig = cfg.compositeTiers[tier];
|
||||
if (tierConfig && isModelBroken(tierConfig.provider, tierConfig.model)) {
|
||||
const modelEntry = findModel(tierConfig.provider, tierConfig.model);
|
||||
const issueUrl = getModelIssueUrl(tierConfig.provider, tierConfig.model);
|
||||
console.error('');
|
||||
console.error(
|
||||
warn(
|
||||
`${tier} tier: ${modelEntry?.name || tierConfig.model} has known issues with Claude Code`
|
||||
)
|
||||
);
|
||||
console.error(' Tool calls will fail. Consider changing the model in config.yaml.');
|
||||
if (issueUrl) {
|
||||
console.error(` Tracking: ${issueUrl}`);
|
||||
}
|
||||
console.error('');
|
||||
}
|
||||
}
|
||||
if (skipLocalAuth) {
|
||||
console.error(' Note: Model may be overridden by remote proxy configuration.');
|
||||
} else {
|
||||
console.error(` Run "ccs ${provider} --config" to change model.`);
|
||||
} else {
|
||||
const currentModel = getCurrentModel(provider, cfg.customSettingsPath);
|
||||
if (currentModel && isModelBroken(provider, currentModel)) {
|
||||
const modelEntry = findModel(provider, currentModel);
|
||||
const issueUrl = getModelIssueUrl(provider, currentModel);
|
||||
console.error('');
|
||||
console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`));
|
||||
console.error(' Tool calls will fail. Use "gemini-3-pro-preview" instead.');
|
||||
if (issueUrl) {
|
||||
console.error(` Tracking: ${issueUrl}`);
|
||||
}
|
||||
if (skipLocalAuth) {
|
||||
console.error(' Note: Model may be overridden by remote proxy configuration.');
|
||||
} else {
|
||||
console.error(` Run "ccs ${provider} --config" to change model.`);
|
||||
}
|
||||
console.error('');
|
||||
}
|
||||
console.error('');
|
||||
}
|
||||
|
||||
// 6. Ensure user settings file exists
|
||||
@@ -640,6 +742,9 @@ export async function execClaudeWithCLIProxy(
|
||||
thinkingOverride,
|
||||
extendedContextOverride,
|
||||
verbose,
|
||||
isComposite: cfg.isComposite,
|
||||
compositeTiers: cfg.compositeTiers,
|
||||
compositeDefaultTier: cfg.compositeDefaultTier,
|
||||
});
|
||||
|
||||
if (initialEnvVars.ANTHROPIC_BASE_URL) {
|
||||
@@ -665,11 +770,12 @@ export async function execClaudeWithCLIProxy(
|
||||
? `http://127.0.0.1:${toolSanitizationPort}`
|
||||
: initialEnvVars.ANTHROPIC_BASE_URL;
|
||||
|
||||
// 10. Setup Codex reasoning proxy (Codex only)
|
||||
// 10. Setup Codex reasoning proxy (single-provider Codex only)
|
||||
let codexReasoningProxy: CodexReasoningProxy | null = null;
|
||||
let codexReasoningPort: number | null = null;
|
||||
|
||||
if (provider === 'codex') {
|
||||
// Composite variants require root model-routed endpoints, never provider-pinned codex endpoints.
|
||||
if (provider === 'codex' && !cfg.isComposite) {
|
||||
if (!postSanitizationBaseUrl) {
|
||||
log('ANTHROPIC_BASE_URL not set for Codex, reasoning proxy disabled');
|
||||
} else {
|
||||
@@ -729,8 +835,23 @@ export async function execClaudeWithCLIProxy(
|
||||
thinkingOverride,
|
||||
extendedContextOverride,
|
||||
verbose,
|
||||
isComposite: cfg.isComposite,
|
||||
compositeTiers: cfg.compositeTiers,
|
||||
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);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { fail, warn, info } from '../../utils/ui';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { handleBanDetection } from '../account-safety';
|
||||
import { CompositeTierConfig } from '../../config/unified-config-types';
|
||||
|
||||
/**
|
||||
* Check if error is network-related
|
||||
@@ -98,3 +99,34 @@ export async function handleQuotaCheck(provider: CLIProxyProvider): Promise<void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Error patterns indicating provider failure */
|
||||
export const PROVIDER_ERROR_PATTERNS = [
|
||||
/Error:\s*4[0-9]{2}/i,
|
||||
/Error:\s*5[0-9]{2}/i,
|
||||
/overloaded/i,
|
||||
/quota.*exceeded/i,
|
||||
/ECONNREFUSED/i,
|
||||
/rate.?limit/i,
|
||||
];
|
||||
|
||||
/** Detect which composite tier failed from stderr output */
|
||||
export function detectFailedTier(
|
||||
stderr: string,
|
||||
tiers: { opus: CompositeTierConfig; sonnet: CompositeTierConfig; haiku: CompositeTierConfig }
|
||||
): 'opus' | 'sonnet' | 'haiku' | null {
|
||||
for (const tier of ['opus', 'sonnet', 'haiku'] as const) {
|
||||
// Strip thinking suffix (e.g., "model(high)" → "model") for matching
|
||||
const model = tiers[tier].model.replace(/\([^)]+\)$/, '');
|
||||
if (stderr.includes(model)) return tier;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Check if Claude exit indicates provider error (vs normal user exit) */
|
||||
export function isProviderError(exitCode: number, stderr: string): boolean {
|
||||
// Exit code 0 means success, even if stderr has error-like output
|
||||
// (could be warnings, debug info, etc.)
|
||||
if (exitCode === 0) return false;
|
||||
return PROVIDER_ERROR_PATTERNS.some((p) => p.test(stderr));
|
||||
}
|
||||
|
||||
@@ -9,9 +9,13 @@ export {
|
||||
variantExists,
|
||||
listVariants,
|
||||
createVariant,
|
||||
createCompositeVariant,
|
||||
updateCompositeVariant,
|
||||
removeVariant,
|
||||
type VariantConfig,
|
||||
type VariantOperationResult,
|
||||
type CreateCompositeVariantOptions,
|
||||
type UpdateCompositeVariantOptions,
|
||||
} from './variant-service';
|
||||
|
||||
// Proxy lifecycle
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
import * as fs from 'fs';
|
||||
import { getConfigPath, loadConfigSafe } from '../../utils/config-manager';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import {
|
||||
CLIProxyVariantConfig,
|
||||
CompositeVariantConfig,
|
||||
CompositeTierConfig,
|
||||
CLIPROXY_SUPPORTED_PROVIDERS,
|
||||
} from '../../config/unified-config-types';
|
||||
import {
|
||||
loadOrCreateUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
@@ -27,6 +33,16 @@ export interface VariantConfig {
|
||||
account?: string;
|
||||
model?: string;
|
||||
port?: number;
|
||||
/** Composite variant fields */
|
||||
type?: 'composite';
|
||||
default_tier?: 'opus' | 'sonnet' | 'haiku';
|
||||
tiers?: {
|
||||
opus: CompositeTierConfig;
|
||||
sonnet: CompositeTierConfig;
|
||||
haiku: CompositeTierConfig;
|
||||
};
|
||||
/** Whether any tier has fallback configured */
|
||||
hasFallback?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,14 +98,66 @@ export function listVariantsFromConfig(): Record<string, VariantConfig> {
|
||||
const unifiedConfig = loadOrCreateUnifiedConfig();
|
||||
const variants = unifiedConfig.cliproxy?.variants || {};
|
||||
const result: Record<string, VariantConfig> = {};
|
||||
for (const name of Object.keys(variants)) {
|
||||
const v = variants[name];
|
||||
result[name] = {
|
||||
provider: v.provider,
|
||||
settings: v.settings,
|
||||
account: v.account,
|
||||
port: v.port,
|
||||
};
|
||||
for (const [name, variantConfig] of Object.entries(variants)) {
|
||||
try {
|
||||
if ('type' in variantConfig && variantConfig.type === 'composite') {
|
||||
const composite = variantConfig as CompositeVariantConfig;
|
||||
const tiers = composite.tiers as Partial<{
|
||||
opus: CompositeTierConfig;
|
||||
sonnet: CompositeTierConfig;
|
||||
haiku: CompositeTierConfig;
|
||||
}> | null;
|
||||
|
||||
if (!tiers || !tiers.opus || !tiers.sonnet || !tiers.haiku || !composite.default_tier) {
|
||||
console.warn(
|
||||
`[!] Skipping malformed composite variant '${name}': missing required tier configuration`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const defaultTierConfig = tiers[composite.default_tier];
|
||||
if (!defaultTierConfig) {
|
||||
console.warn(
|
||||
`[!] Skipping malformed composite variant '${name}': missing default tier '${composite.default_tier}'`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedTiers = {
|
||||
opus: tiers.opus,
|
||||
sonnet: tiers.sonnet,
|
||||
haiku: tiers.haiku,
|
||||
};
|
||||
|
||||
const hasFallback = !!(
|
||||
normalizedTiers.opus.fallback ||
|
||||
normalizedTiers.sonnet.fallback ||
|
||||
normalizedTiers.haiku.fallback
|
||||
);
|
||||
|
||||
result[name] = {
|
||||
provider: defaultTierConfig.provider,
|
||||
settings: composite.settings,
|
||||
port: composite.port,
|
||||
type: 'composite',
|
||||
default_tier: composite.default_tier,
|
||||
tiers: normalizedTiers,
|
||||
hasFallback,
|
||||
};
|
||||
} else {
|
||||
const single = variantConfig as CLIProxyVariantConfig;
|
||||
result[name] = {
|
||||
provider: single.provider,
|
||||
settings: single.settings,
|
||||
account: single.account,
|
||||
port: single.port,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[!] Skipping malformed variant '${name}': ${(error as Error).message || 'invalid config'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -117,6 +185,27 @@ export function listVariantsFromConfig(): Record<string, VariantConfig> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save composite variant to unified config
|
||||
*/
|
||||
export function saveCompositeVariantUnified(name: string, config: CompositeVariantConfig): void {
|
||||
const unifiedConfig = loadOrCreateUnifiedConfig();
|
||||
|
||||
if (!unifiedConfig.cliproxy) {
|
||||
unifiedConfig.cliproxy = {
|
||||
oauth_accounts: {},
|
||||
providers: [...CLIPROXY_SUPPORTED_PROVIDERS],
|
||||
variants: {},
|
||||
};
|
||||
}
|
||||
if (!unifiedConfig.cliproxy.variants) {
|
||||
unifiedConfig.cliproxy.variants = {};
|
||||
}
|
||||
|
||||
unifiedConfig.cliproxy.variants[name] = config;
|
||||
saveUnifiedConfig(unifiedConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save variant to unified config
|
||||
*/
|
||||
@@ -132,7 +221,7 @@ export function saveVariantUnified(
|
||||
if (!config.cliproxy) {
|
||||
config.cliproxy = {
|
||||
oauth_accounts: {},
|
||||
providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp', 'claude'],
|
||||
providers: [...CLIPROXY_SUPPORTED_PROVIDERS],
|
||||
variants: {},
|
||||
};
|
||||
}
|
||||
@@ -204,7 +293,23 @@ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | nu
|
||||
delete config.cliproxy.variants[name];
|
||||
saveUnifiedConfig(config);
|
||||
|
||||
return { provider: variant.provider, settings: variant.settings, port: variant.port };
|
||||
if ('type' in variant && variant.type === 'composite') {
|
||||
const composite = variant as CompositeVariantConfig;
|
||||
return {
|
||||
provider: composite.tiers[composite.default_tier].provider,
|
||||
settings: composite.settings,
|
||||
port: composite.port,
|
||||
type: 'composite',
|
||||
default_tier: composite.default_tier,
|
||||
tiers: composite.tiers,
|
||||
};
|
||||
}
|
||||
const singleVariant = variant as CLIProxyVariantConfig;
|
||||
return {
|
||||
provider: singleVariant.provider,
|
||||
settings: singleVariant.settings,
|
||||
port: singleVariant.port,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,18 +9,24 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CLIProxyProfileName } from '../../auth/profile-detector';
|
||||
import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS } from '../types';
|
||||
import { CompositeTierConfig, CompositeVariantConfig } from '../../config/unified-config-types';
|
||||
import { isReservedName, isWindowsReservedName } from '../../config/reserved-names';
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { DEFAULT_BACKEND } from '../platform-detector';
|
||||
import { isUnifiedMode } from '../../config/unified-config-loader';
|
||||
import { deleteConfigForPort } from '../config-generator';
|
||||
import { deleteSessionLockForPort } from '../session-tracker';
|
||||
import { hasActiveSessions, deleteSessionLockForPort } from '../session-tracker';
|
||||
import { warn } from '../../utils/ui';
|
||||
import { validateCompositeTiers } from '../composite-validator';
|
||||
import {
|
||||
createSettingsFile,
|
||||
createSettingsFileUnified,
|
||||
createCompositeSettingsFile,
|
||||
deleteSettingsFile,
|
||||
getRelativeSettingsPath,
|
||||
getCompositeRelativeSettingsPath,
|
||||
updateSettingsModel,
|
||||
updateSettingsProviderAndModel,
|
||||
} from './variant-settings';
|
||||
import {
|
||||
VariantConfig,
|
||||
@@ -28,6 +34,7 @@ import {
|
||||
listVariantsFromConfig,
|
||||
saveVariantUnified,
|
||||
saveVariantLegacy,
|
||||
saveCompositeVariantUnified,
|
||||
removeVariantFromUnifiedConfig,
|
||||
removeVariantFromLegacyConfig,
|
||||
getNextAvailablePort,
|
||||
@@ -149,6 +156,22 @@ export function createVariant(
|
||||
*/
|
||||
export function removeVariant(name: string): VariantOperationResult {
|
||||
try {
|
||||
// First check if variant exists and has active sessions
|
||||
const variants = listVariantsFromConfig();
|
||||
const existingVariant = variants[name];
|
||||
|
||||
if (!existingVariant) {
|
||||
return { success: false, error: `Variant '${name}' not found` };
|
||||
}
|
||||
|
||||
// Check for active sessions on this variant's port before deletion
|
||||
if (existingVariant.port && hasActiveSessions(existingVariant.port)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Cannot delete variant '${name}': CLIProxy is running with active sessions. Stop the session first.`,
|
||||
};
|
||||
}
|
||||
|
||||
let variant: VariantConfig | null;
|
||||
|
||||
if (isUnifiedMode()) {
|
||||
@@ -174,11 +197,7 @@ export function removeVariant(name: string): VariantOperationResult {
|
||||
}
|
||||
}
|
||||
|
||||
if (!variant) {
|
||||
return { success: false, error: `Variant '${name}' not found` };
|
||||
}
|
||||
|
||||
return { success: true, variant };
|
||||
return { success: true, variant: variant ?? undefined };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
@@ -203,10 +222,39 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
|
||||
return { success: false, error: `Variant '${name}' not found` };
|
||||
}
|
||||
|
||||
// Update model in settings file if provided
|
||||
if (updates.model !== undefined && existing.settings) {
|
||||
if (existing.type === 'composite') {
|
||||
console.log(
|
||||
warn(
|
||||
'Cannot update composite variant properties directly. Remove and recreate, or edit config.yaml.'
|
||||
)
|
||||
);
|
||||
return { success: false, error: 'Composite variant update not supported' };
|
||||
}
|
||||
|
||||
const providerChanged =
|
||||
updates.provider !== undefined && updates.provider !== existing.provider;
|
||||
const hasModelUpdate = updates.model !== undefined && updates.model.trim().length > 0;
|
||||
|
||||
if (providerChanged && !hasModelUpdate) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Changing provider requires model update in the same request',
|
||||
};
|
||||
}
|
||||
|
||||
// Update settings file
|
||||
if (existing.settings) {
|
||||
const settingsPath = existing.settings.replace(/^~/, os.homedir());
|
||||
updateSettingsModel(settingsPath, updates.model);
|
||||
if (providerChanged) {
|
||||
updateSettingsProviderAndModel(
|
||||
settingsPath,
|
||||
updates.provider as CLIProxyProfileName,
|
||||
updates.model?.trim() || '',
|
||||
existing.port
|
||||
);
|
||||
} else if (updates.model !== undefined) {
|
||||
updateSettingsModel(settingsPath, updates.model);
|
||||
}
|
||||
}
|
||||
|
||||
// Update config entry if provider or account changed
|
||||
@@ -245,7 +293,7 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
|
||||
success: true,
|
||||
variant: {
|
||||
provider: updates.provider ?? existing.provider,
|
||||
model: updates.model ?? existing.model,
|
||||
model: updates.model?.trim() || existing.model,
|
||||
account: updates.account !== undefined ? updates.account : existing.account,
|
||||
port: existing.port,
|
||||
settings: existing.settings,
|
||||
@@ -255,3 +303,173 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Composite variant creation options */
|
||||
export interface CreateCompositeVariantOptions {
|
||||
name: string;
|
||||
defaultTier: 'opus' | 'sonnet' | 'haiku';
|
||||
tiers: {
|
||||
opus: CompositeTierConfig;
|
||||
sonnet: CompositeTierConfig;
|
||||
haiku: CompositeTierConfig;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new composite CLIProxy variant.
|
||||
* Mixes different providers per tier using CLIProxyAPI root endpoints.
|
||||
*/
|
||||
export function createCompositeVariant(
|
||||
options: CreateCompositeVariantOptions
|
||||
): VariantOperationResult {
|
||||
if (!isUnifiedMode()) {
|
||||
throw new Error(
|
||||
'Composite variants require unified config (config.yaml). Run "ccs migrate" first.'
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
const backendError = validateProviderBackend(tiers[tier].provider);
|
||||
if (backendError) {
|
||||
return { success: false, error: `${tier} tier: ${backendError}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Allocate unique port for this composite variant
|
||||
const port = getNextAvailablePort();
|
||||
|
||||
// Create settings file with root URL + per-tier models
|
||||
const settingsPath = createCompositeSettingsFile(name, tiers, defaultTier, port);
|
||||
|
||||
// Save composite config to unified config
|
||||
const compositeConfig: CompositeVariantConfig = {
|
||||
type: 'composite',
|
||||
default_tier: defaultTier,
|
||||
tiers,
|
||||
settings: getCompositeRelativeSettingsPath(name),
|
||||
port,
|
||||
};
|
||||
saveCompositeVariantUnified(name, compositeConfig);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
settingsPath,
|
||||
variant: {
|
||||
provider: tiers[defaultTier].provider,
|
||||
type: 'composite',
|
||||
default_tier: defaultTier,
|
||||
tiers,
|
||||
port,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Update options for composite variant */
|
||||
export interface UpdateCompositeVariantOptions {
|
||||
defaultTier?: 'opus' | 'sonnet' | 'haiku';
|
||||
tiers?: Partial<Record<'opus' | 'sonnet' | 'haiku', CompositeTierConfig>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing composite CLIProxy variant.
|
||||
* Merges changes with existing config and regenerates settings file.
|
||||
*/
|
||||
export function updateCompositeVariant(
|
||||
name: string,
|
||||
updates: UpdateCompositeVariantOptions
|
||||
): VariantOperationResult {
|
||||
if (!isUnifiedMode()) {
|
||||
throw new Error('Composite variants require unified config (config.yaml).');
|
||||
}
|
||||
|
||||
try {
|
||||
const variants = listVariantsFromConfig();
|
||||
const existing = variants[name];
|
||||
|
||||
if (!existing) {
|
||||
return { success: false, error: `Variant '${name}' not found` };
|
||||
}
|
||||
|
||||
if (existing.type !== 'composite' || !existing.tiers) {
|
||||
return { success: false, error: `Variant '${name}' is not a composite variant` };
|
||||
}
|
||||
|
||||
// Deep merge tiers to preserve optional fields (fallback, thinking, account)
|
||||
const mergedTiers = {
|
||||
opus: { ...existing.tiers.opus, ...updates.tiers?.opus },
|
||||
sonnet: { ...existing.tiers.sonnet, ...updates.tiers?.sonnet },
|
||||
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) {
|
||||
const backendError = validateProviderBackend(mergedTiers[tier].provider);
|
||||
if (backendError) {
|
||||
return { success: false, error: `${tier} tier: ${backendError}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve existing settings path when configured; otherwise use default path.
|
||||
const settingsRef = existing.settings || getCompositeRelativeSettingsPath(name);
|
||||
|
||||
// Create new settings file with updated config
|
||||
const settingsPath = createCompositeSettingsFile(
|
||||
name,
|
||||
mergedTiers,
|
||||
newDefaultTier,
|
||||
existing.port,
|
||||
settingsRef
|
||||
);
|
||||
|
||||
// Save updated composite config to unified config
|
||||
const compositeConfig: CompositeVariantConfig = {
|
||||
type: 'composite',
|
||||
default_tier: newDefaultTier,
|
||||
tiers: mergedTiers,
|
||||
settings: settingsRef,
|
||||
port: existing.port,
|
||||
};
|
||||
saveCompositeVariantUnified(name, compositeConfig);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
settingsPath,
|
||||
variant: {
|
||||
provider: mergedTiers[newDefaultTier].provider,
|
||||
type: 'composite',
|
||||
default_tier: newDefaultTier,
|
||||
tiers: mergedTiers,
|
||||
port: existing.port,
|
||||
settings: settingsRef,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,15 @@ import { getCcsDir } from '../../utils/config-manager';
|
||||
import { expandPath } from '../../utils/helpers';
|
||||
import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { CompositeTierConfig } from '../../config/unified-config-types';
|
||||
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
|
||||
import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector';
|
||||
import { getEffectiveApiKey } from '../auth-token-manager';
|
||||
import { warn } from '../../utils/ui';
|
||||
|
||||
/** Environment settings structure */
|
||||
interface SettingsEnv {
|
||||
[key: string]: string;
|
||||
ANTHROPIC_BASE_URL: string;
|
||||
ANTHROPIC_AUTH_TOKEN: string;
|
||||
ANTHROPIC_MODEL: string;
|
||||
@@ -27,7 +31,8 @@ interface SettingsEnv {
|
||||
}
|
||||
|
||||
interface SettingsFile {
|
||||
env: SettingsEnv;
|
||||
env: Record<string, string>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,10 +65,13 @@ function ensureDir(dir: string): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Write settings file atomically
|
||||
* Write settings file atomically using temp file + rename.
|
||||
* The renameSync is atomic on POSIX systems, preventing partial writes on crash.
|
||||
*/
|
||||
function writeSettings(filePath: string, settings: SettingsFile): void {
|
||||
fs.writeFileSync(filePath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
||||
const tempPath = `${filePath}.tmp.${process.pid}`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
||||
fs.renameSync(tempPath, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,6 +152,104 @@ export function createSettingsFileUnified(
|
||||
return settingsPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build settings env object for a composite variant.
|
||||
* Uses root URL (no /api/provider/ path) for model-based routing.
|
||||
*/
|
||||
function buildCompositeSettingsEnv(
|
||||
tiers: { opus: CompositeTierConfig; sonnet: CompositeTierConfig; haiku: CompositeTierConfig },
|
||||
defaultTier: 'opus' | 'sonnet' | 'haiku',
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): SettingsEnv {
|
||||
const defaultModel = tiers[defaultTier].model;
|
||||
|
||||
return {
|
||||
// Root URL — CLIProxyAPI routes based on model name, no provider prefix
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
|
||||
ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(),
|
||||
ANTHROPIC_MODEL: defaultModel,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: tiers.opus.model,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: tiers.sonnet.model,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: tiers.haiku.model,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create settings.json file for a composite variant.
|
||||
*/
|
||||
export function createCompositeSettingsFile(
|
||||
name: string,
|
||||
tiers: { opus: CompositeTierConfig; sonnet: CompositeTierConfig; haiku: CompositeTierConfig },
|
||||
defaultTier: 'opus' | 'sonnet' | 'haiku',
|
||||
port: number = CLIPROXY_DEFAULT_PORT,
|
||||
settingsPathOverride?: string
|
||||
): string {
|
||||
const ccsDir = getCcsDir();
|
||||
const defaultSettingsPath = path.join(ccsDir, `composite-${name}.settings.json`);
|
||||
const settingsPath = settingsPathOverride
|
||||
? (() => {
|
||||
const expanded = expandPath(settingsPathOverride);
|
||||
return path.isAbsolute(expanded) ? expanded : path.join(ccsDir, expanded);
|
||||
})()
|
||||
: defaultSettingsPath;
|
||||
const settingsDir = path.dirname(settingsPath);
|
||||
|
||||
const coreEnv = buildCompositeSettingsEnv(tiers, defaultTier, port);
|
||||
let settings: SettingsFile = { env: coreEnv };
|
||||
|
||||
// Preserve non-core env vars and non-env fields (hooks/presets/etc.) when regenerating.
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(settingsPath, 'utf-8');
|
||||
const parsed = JSON.parse(content) as SettingsFile;
|
||||
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const existingEnv =
|
||||
parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
|
||||
? (parsed.env as Record<string, string>)
|
||||
: {};
|
||||
const {
|
||||
ANTHROPIC_BASE_URL: _baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: _authToken,
|
||||
ANTHROPIC_MODEL: _model,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: _opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: _sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: _haiku,
|
||||
...extraEnv
|
||||
} = existingEnv;
|
||||
|
||||
settings = {
|
||||
...parsed,
|
||||
env: {
|
||||
...extraEnv,
|
||||
...coreEnv,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON — overwrite with a clean settings object.
|
||||
}
|
||||
}
|
||||
|
||||
ensureDir(settingsDir);
|
||||
writeSettings(settingsPath, settings);
|
||||
|
||||
// Hook injectors target ~/.ccs/<profile>.settings.json; only run for default path.
|
||||
if (path.resolve(settingsPath) === path.resolve(defaultSettingsPath)) {
|
||||
ensureProfileHooks(`composite-${name}`);
|
||||
ensureImageAnalyzerHooks(`composite-${name}`);
|
||||
}
|
||||
|
||||
return settingsPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relative settings path for a composite variant
|
||||
*/
|
||||
export function getCompositeRelativeSettingsPath(name: string): string {
|
||||
return `~/.ccs/composite-${name}.settings.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete settings file if it exists.
|
||||
* Uses expandPath() for cross-platform path handling.
|
||||
@@ -161,6 +267,14 @@ export function deleteSettingsFile(settingsPath: string): boolean {
|
||||
* Update model in an existing settings file
|
||||
*/
|
||||
export function updateSettingsModel(settingsPath: string, model: string): void {
|
||||
const fileName = path.basename(settingsPath);
|
||||
if (fileName.startsWith('composite-')) {
|
||||
console.log(
|
||||
warn('Cannot update model for composite variant. Edit config.yaml tiers directly.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPath = settingsPath.replace(/^~/, os.homedir());
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
return;
|
||||
@@ -180,8 +294,68 @@ export function updateSettingsModel(settingsPath: string, model: string): void {
|
||||
delete (settings.env as unknown as Record<string, string>).ANTHROPIC_MODEL;
|
||||
}
|
||||
|
||||
fs.writeFileSync(resolvedPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
||||
const tempPath = `${resolvedPath}.tmp.${process.pid}`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
||||
fs.renameSync(tempPath, resolvedPath);
|
||||
} catch {
|
||||
// Ignore errors - settings file may be invalid
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update provider + model core env vars in an existing single-provider settings file.
|
||||
* Preserves non-core env vars and top-level settings keys (hooks, presets, etc.).
|
||||
*/
|
||||
export function updateSettingsProviderAndModel(
|
||||
settingsPath: string,
|
||||
provider: CLIProxyProfileName,
|
||||
model: string,
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): void {
|
||||
const resolvedPath = expandPath(settingsPath);
|
||||
const fileName = path.basename(resolvedPath);
|
||||
if (fileName.startsWith('composite-')) {
|
||||
console.log(
|
||||
warn('Cannot update provider/model for composite variant. Edit config.yaml tiers directly.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const coreEnv = buildSettingsEnv(provider, model, port);
|
||||
let settings: SettingsFile = { env: coreEnv };
|
||||
|
||||
if (fs.existsSync(resolvedPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(resolvedPath, 'utf8');
|
||||
const parsed = JSON.parse(content) as SettingsFile;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const existingEnv =
|
||||
parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
|
||||
? (parsed.env as Record<string, string>)
|
||||
: {};
|
||||
const {
|
||||
ANTHROPIC_BASE_URL: _baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: _authToken,
|
||||
ANTHROPIC_MODEL: _model,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: _opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: _sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: _haiku,
|
||||
...extraEnv
|
||||
} = existingEnv;
|
||||
|
||||
settings = {
|
||||
...parsed,
|
||||
env: {
|
||||
...extraEnv,
|
||||
...coreEnv,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Keep default and overwrite malformed file.
|
||||
}
|
||||
}
|
||||
|
||||
ensureDir(path.dirname(resolvedPath));
|
||||
writeSettings(resolvedPath, settings);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Types for CLIProxyAPI binary management and execution
|
||||
*/
|
||||
|
||||
import { CompositeTierConfig } from '../config/unified-config-types';
|
||||
|
||||
/**
|
||||
* Supported operating systems
|
||||
*/
|
||||
@@ -181,6 +183,18 @@ export interface ExecutorConfig {
|
||||
pollInterval: number;
|
||||
/** Custom settings path for user-defined CLIProxy variants */
|
||||
customSettingsPath?: string;
|
||||
/** Composite variant: true when mixing providers per tier */
|
||||
isComposite?: boolean;
|
||||
/** Composite variant: per-tier provider+model mappings */
|
||||
compositeTiers?: {
|
||||
opus: CompositeTierConfig;
|
||||
sonnet: CompositeTierConfig;
|
||||
haiku: CompositeTierConfig;
|
||||
};
|
||||
/** Composite variant: which tier is the default */
|
||||
compositeDefaultTier?: 'opus' | 'sonnet' | 'haiku';
|
||||
/** Original profile/variant name (e.g., "my-mix" for composite variants) */
|
||||
profileName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,8 +43,9 @@ export async function handleList(): Promise<void> {
|
||||
console.log(subheader('Custom Variants'));
|
||||
const rows = variantNames.map((name) => {
|
||||
const variant = variants[name];
|
||||
const providerDisplay = variant.type === 'composite' ? 'composite' : variant.provider;
|
||||
const portStr = variant.port ? String(variant.port) : '-';
|
||||
return [name, variant.provider, portStr, variant.settings || '-'];
|
||||
return [name, providerDisplay, portStr, variant.settings || '-'];
|
||||
});
|
||||
console.log(
|
||||
table(rows, { head: ['Variant', 'Provider', 'Port', 'Settings'], colWidths: [15, 12, 8, 30] })
|
||||
|
||||
@@ -26,6 +26,8 @@ export async function showHelp(): Promise<void> {
|
||||
'Profile Commands:',
|
||||
[
|
||||
['create [name]', 'Create new CLIProxy variant profile'],
|
||||
['create --composite', 'Create composite variant (mix providers per tier)'],
|
||||
['edit [name]', 'Edit an existing CLIProxy variant profile'],
|
||||
['list', 'List all CLIProxy variant profiles'],
|
||||
['remove <name>', 'Remove a CLIProxy variant profile'],
|
||||
],
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
handlePauseAccount,
|
||||
handleResumeAccount,
|
||||
} from './quota-subcommand';
|
||||
import { handleCreate, handleRemove } from './variant-subcommand';
|
||||
import { handleCreate, handleRemove, handleEdit } from './variant-subcommand';
|
||||
import { handleProxyStatus, handleStop } from './proxy-lifecycle-subcommand';
|
||||
import { showStatus, handleInstallVersion, handleInstallLatest } from './install-subcommand';
|
||||
import { showHelp } from './help-subcommand';
|
||||
@@ -161,6 +161,11 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'edit') {
|
||||
await handleEdit(remainingArgs.slice(1), effectiveBackend);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'list' || command === 'ls') {
|
||||
await handleList();
|
||||
return;
|
||||
|
||||
@@ -20,9 +20,12 @@ import {
|
||||
variantExists,
|
||||
listVariants,
|
||||
createVariant,
|
||||
createCompositeVariant,
|
||||
updateCompositeVariant,
|
||||
removeVariant,
|
||||
} from '../../cliproxy/services';
|
||||
import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector';
|
||||
import { CompositeTierConfig } from '../../config/unified-config-types';
|
||||
|
||||
interface CliproxyProfileArgs {
|
||||
name?: string;
|
||||
@@ -31,6 +34,7 @@ interface CliproxyProfileArgs {
|
||||
account?: string;
|
||||
force?: boolean;
|
||||
yes?: boolean;
|
||||
composite?: boolean;
|
||||
}
|
||||
|
||||
function parseProfileArgs(args: string[]): CliproxyProfileArgs {
|
||||
@@ -47,6 +51,8 @@ function parseProfileArgs(args: string[]): CliproxyProfileArgs {
|
||||
result.force = true;
|
||||
} else if (arg === '--yes' || arg === '-y') {
|
||||
result.yes = true;
|
||||
} else if (arg === '--composite') {
|
||||
result.composite = true;
|
||||
} else if (!arg.startsWith('-') && !result.name) {
|
||||
result.name = arg;
|
||||
}
|
||||
@@ -68,6 +74,71 @@ function getBackendLabel(backend: CLIProxyBackend): string {
|
||||
return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive prompt to select provider + model for a single tier.
|
||||
* Returns a CompositeTierConfig, or null if user cancelled auth.
|
||||
*/
|
||||
async function selectTierConfig(
|
||||
tierName: string,
|
||||
verbose: boolean
|
||||
): Promise<CompositeTierConfig | null> {
|
||||
console.log(header(`${tierName.charAt(0).toUpperCase() + tierName.slice(1)} Tier`));
|
||||
|
||||
// Select provider
|
||||
const providerOptions = CLIPROXY_PROFILES.map((p) => ({
|
||||
id: p,
|
||||
label: p.charAt(0).toUpperCase() + p.slice(1),
|
||||
}));
|
||||
const provider = (await InteractivePrompt.selectFromList(
|
||||
`Provider for ${tierName}:`,
|
||||
providerOptions
|
||||
)) as CLIProxyProfileName;
|
||||
|
||||
// Check auth
|
||||
const providerAccounts = getProviderAccounts(provider as CLIProxyProvider);
|
||||
if (providerAccounts.length === 0) {
|
||||
console.log('');
|
||||
console.log(warn(`No accounts authenticated for ${provider}`));
|
||||
const shouldAuth = await InteractivePrompt.confirm(`Authenticate with ${provider} now?`, {
|
||||
default: true,
|
||||
});
|
||||
if (!shouldAuth) {
|
||||
console.log(info(`Skipping auth. Run: ${color(`ccs ${provider} --auth`, 'command')}`));
|
||||
return null;
|
||||
}
|
||||
const newAccount = await triggerOAuth(provider as CLIProxyProvider, {
|
||||
add: true,
|
||||
verbose,
|
||||
});
|
||||
if (!newAccount) {
|
||||
console.log(fail('Authentication failed'));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`));
|
||||
}
|
||||
|
||||
// Select model
|
||||
let model: string | undefined;
|
||||
if (supportsModelConfig(provider as CLIProxyProvider)) {
|
||||
const catalog = getProviderCatalog(provider as CLIProxyProvider);
|
||||
if (catalog) {
|
||||
const modelOptions = catalog.models.map((m) => ({ id: m.id, label: formatModelOption(m) }));
|
||||
const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel);
|
||||
model = await InteractivePrompt.selectFromList(`Model for ${tierName}:`, modelOptions, {
|
||||
defaultIndex: defaultIdx >= 0 ? defaultIdx : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
model = await InteractivePrompt.input(`Model name for ${tierName}`, {
|
||||
validate: (val) => (val ? null : 'Model is required'),
|
||||
});
|
||||
}
|
||||
|
||||
console.log('');
|
||||
return { provider, model };
|
||||
}
|
||||
|
||||
export async function handleCreate(
|
||||
args: string[],
|
||||
backend: CLIProxyBackend = DEFAULT_BACKEND
|
||||
@@ -97,6 +168,71 @@ export async function handleCreate(
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Composite mode: select provider+model per tier
|
||||
if (parsedArgs.composite) {
|
||||
console.log(info('Composite variant — select provider and model for each tier'));
|
||||
console.log('');
|
||||
|
||||
const verbose = args.includes('--verbose');
|
||||
const opus = await selectTierConfig('opus', verbose);
|
||||
if (!opus) {
|
||||
return; // User cancelled auth
|
||||
}
|
||||
const sonnet = await selectTierConfig('sonnet', verbose);
|
||||
if (!sonnet) {
|
||||
return; // User cancelled auth
|
||||
}
|
||||
const haiku = await selectTierConfig('haiku', verbose);
|
||||
if (!haiku) {
|
||||
return; // User cancelled auth
|
||||
}
|
||||
|
||||
// Select default tier
|
||||
const tierOptions = [
|
||||
{ id: 'opus' as const, label: `Opus (${opus.provider}: ${opus.model})` },
|
||||
{ id: 'sonnet' as const, label: `Sonnet (${sonnet.provider}: ${sonnet.model})` },
|
||||
{ id: 'haiku' as const, label: `Haiku (${haiku.provider}: ${haiku.model})` },
|
||||
];
|
||||
const defaultTier = (await InteractivePrompt.selectFromList(
|
||||
'Default tier (ANTHROPIC_MODEL):',
|
||||
tierOptions
|
||||
)) as 'opus' | 'sonnet' | 'haiku';
|
||||
|
||||
console.log('');
|
||||
console.log(info(`Creating composite ${getBackendLabel(backend)} variant...`));
|
||||
const result = createCompositeVariant({
|
||||
name,
|
||||
defaultTier,
|
||||
tiers: { opus, sonnet, haiku },
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.log(fail(`Failed to create composite variant: ${result.error}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
const tiers = result.variant?.tiers;
|
||||
const tierSummary = tiers
|
||||
? `Opus: ${tiers.opus.provider} / ${tiers.opus.model}\n` +
|
||||
`Sonnet: ${tiers.sonnet.provider} / ${tiers.sonnet.model}\n` +
|
||||
`Haiku: ${tiers.haiku.provider} / ${tiers.haiku.model}\n` +
|
||||
`Default: ${defaultTier}`
|
||||
: '';
|
||||
const portInfo = result.variant?.port ? `\nPort: ${result.variant.port}` : '';
|
||||
console.log(
|
||||
infoBox(
|
||||
`Variant: ${name} (composite)\n${tierSummary}${portInfo}\nConfig: ~/.ccs/config.yaml`,
|
||||
'Composite Variant Created'
|
||||
)
|
||||
);
|
||||
console.log('');
|
||||
console.log(header('Usage'));
|
||||
console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`);
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Provider selection
|
||||
let provider = parsedArgs.provider;
|
||||
if (!provider) {
|
||||
@@ -260,7 +396,11 @@ export async function handleRemove(args: string[]): Promise<void> {
|
||||
console.log(header('Remove CLIProxy Variant'));
|
||||
console.log('');
|
||||
console.log('Available variants:');
|
||||
variantNames.forEach((n, i) => console.log(` ${i + 1}. ${n} (${variants[n].provider})`));
|
||||
variantNames.forEach((n, i) => {
|
||||
const v = variants[n];
|
||||
const label = v.type === 'composite' ? 'composite' : v.provider;
|
||||
console.log(` ${i + 1}. ${n} (${label})`);
|
||||
});
|
||||
console.log('');
|
||||
name = await InteractivePrompt.input('Variant name to remove', {
|
||||
validate: (val) => {
|
||||
@@ -282,7 +422,16 @@ export async function handleRemove(args: string[]): Promise<void> {
|
||||
const variant = variants[name];
|
||||
console.log('');
|
||||
console.log(`Variant '${color(name, 'command')}' will be removed.`);
|
||||
console.log(` Provider: ${variant.provider}`);
|
||||
if (variant.type === 'composite') {
|
||||
console.log(` Type: composite`);
|
||||
if (variant.tiers) {
|
||||
console.log(` Opus: ${variant.tiers.opus.provider} / ${variant.tiers.opus.model}`);
|
||||
console.log(` Sonnet: ${variant.tiers.sonnet.provider} / ${variant.tiers.sonnet.model}`);
|
||||
console.log(` Haiku: ${variant.tiers.haiku.provider} / ${variant.tiers.haiku.model}`);
|
||||
}
|
||||
} else {
|
||||
console.log(` Provider: ${variant.provider}`);
|
||||
}
|
||||
if (variant.port) {
|
||||
console.log(` Port: ${variant.port}`);
|
||||
}
|
||||
@@ -305,3 +454,215 @@ export async function handleRemove(args: string[]): Promise<void> {
|
||||
console.log(ok(`Variant removed: ${name}`));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
export async function handleEdit(
|
||||
args: string[],
|
||||
backend: CLIProxyBackend = DEFAULT_BACKEND
|
||||
): Promise<void> {
|
||||
await initUI();
|
||||
const parsedArgs = parseProfileArgs(args);
|
||||
const variants = listVariants();
|
||||
const variantNames = Object.keys(variants);
|
||||
|
||||
if (variantNames.length === 0) {
|
||||
console.log(warn('No CLIProxy variants to edit'));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let name = parsedArgs.name;
|
||||
if (!name) {
|
||||
console.log(header(`Edit ${getBackendLabel(backend)} Variant`));
|
||||
console.log('');
|
||||
console.log('Available variants:');
|
||||
variantNames.forEach((n, i) => {
|
||||
const v = variants[n];
|
||||
const label = v.type === 'composite' ? 'composite' : v.provider;
|
||||
console.log(` ${i + 1}. ${n} (${label})`);
|
||||
});
|
||||
console.log('');
|
||||
name = await InteractivePrompt.input('Variant name to edit', {
|
||||
validate: (val) => {
|
||||
if (!val) return 'Variant name is required';
|
||||
if (!variantNames.includes(val)) return `Variant '${val}' not found`;
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!variantNames.includes(name)) {
|
||||
console.log(fail(`Variant '${name}' not found`));
|
||||
console.log('');
|
||||
console.log('Available variants:');
|
||||
variantNames.forEach((n) => console.log(` - ${n}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const variant = variants[name];
|
||||
|
||||
// If not composite, use existing updateVariant() flow (interactive prompts)
|
||||
if (variant.type !== 'composite') {
|
||||
console.log(header(`Edit Variant: ${name}`));
|
||||
console.log('');
|
||||
console.log(`Current provider: ${variant.provider}`);
|
||||
if (variant.model) {
|
||||
console.log(`Current model: ${variant.model}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
const changeProvider = await InteractivePrompt.confirm('Change provider?', { default: false });
|
||||
let newProvider: CLIProxyProfileName | undefined = undefined;
|
||||
if (changeProvider) {
|
||||
const providerOptions = CLIPROXY_PROFILES.map((p) => ({
|
||||
id: p,
|
||||
label: p.charAt(0).toUpperCase() + p.slice(1),
|
||||
}));
|
||||
newProvider = (await InteractivePrompt.selectFromList(
|
||||
'Select new provider:',
|
||||
providerOptions
|
||||
)) as CLIProxyProfileName;
|
||||
}
|
||||
|
||||
const providerChanged = !!(newProvider && newProvider !== variant.provider);
|
||||
if (providerChanged) {
|
||||
console.log(info('Provider changed. Model selection is required.'));
|
||||
}
|
||||
|
||||
const changeModel = providerChanged
|
||||
? true
|
||||
: await InteractivePrompt.confirm('Change model?', { default: false });
|
||||
let newModel = variant.model || '';
|
||||
if (changeModel) {
|
||||
const providerForModel = newProvider || (variant.provider as CLIProxyProfileName);
|
||||
if (supportsModelConfig(providerForModel as CLIProxyProvider)) {
|
||||
const catalog = getProviderCatalog(providerForModel as CLIProxyProvider);
|
||||
if (catalog) {
|
||||
const modelOptions = catalog.models.map((m) => ({
|
||||
id: m.id,
|
||||
label: formatModelOption(m),
|
||||
}));
|
||||
const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel);
|
||||
newModel = await InteractivePrompt.selectFromList('Select new model:', modelOptions, {
|
||||
defaultIndex: defaultIdx >= 0 ? defaultIdx : 0,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
newModel = await InteractivePrompt.input('New model name', {
|
||||
validate: (val) => (val ? null : 'Model is required'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(info(`Updating ${getBackendLabel(backend)} variant...`));
|
||||
// Use existing updateVariant from variant-service for single-provider variants
|
||||
const { updateVariant } = await import('../../cliproxy/services/variant-service');
|
||||
const result = updateVariant(name, {
|
||||
provider: newProvider,
|
||||
model: changeModel ? newModel : undefined,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.log(fail(`Failed to update variant: ${result.error}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(ok(`Variant updated: ${name}`));
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Composite variant edit flow
|
||||
console.log(header(`Edit Composite Variant: ${name}`));
|
||||
console.log('');
|
||||
if (!variant.tiers) {
|
||||
console.log(fail('Invalid composite variant: missing tier configuration'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(info('Current tier configuration:'));
|
||||
console.log(` Opus: ${variant.tiers.opus.provider} / ${variant.tiers.opus.model}`);
|
||||
console.log(` Sonnet: ${variant.tiers.sonnet.provider} / ${variant.tiers.sonnet.model}`);
|
||||
console.log(` Haiku: ${variant.tiers.haiku.provider} / ${variant.tiers.haiku.model}`);
|
||||
console.log(` Default: ${variant.default_tier}`);
|
||||
console.log('');
|
||||
|
||||
const verbose = args.includes('--verbose');
|
||||
const updatedTiers: Partial<Record<'opus' | 'sonnet' | 'haiku', CompositeTierConfig>> = {};
|
||||
|
||||
// Ask per-tier edits
|
||||
for (const tierName of ['opus', 'sonnet', 'haiku'] as const) {
|
||||
const shouldEdit = await InteractivePrompt.confirm(`Edit ${tierName} tier?`, {
|
||||
default: false,
|
||||
});
|
||||
if (shouldEdit) {
|
||||
const newConfig = await selectTierConfig(tierName, verbose);
|
||||
if (!newConfig) {
|
||||
console.log(fail('Edit cancelled'));
|
||||
process.exit(0);
|
||||
}
|
||||
updatedTiers[tierName] = newConfig;
|
||||
}
|
||||
}
|
||||
|
||||
// Ask for default tier change
|
||||
let newDefaultTier = variant.default_tier;
|
||||
const changeDefault = await InteractivePrompt.confirm('Change default tier?', { default: false });
|
||||
if (changeDefault) {
|
||||
const finalTiers = {
|
||||
opus: updatedTiers.opus ?? variant.tiers.opus,
|
||||
sonnet: updatedTiers.sonnet ?? variant.tiers.sonnet,
|
||||
haiku: updatedTiers.haiku ?? variant.tiers.haiku,
|
||||
};
|
||||
const tierOptions = [
|
||||
{
|
||||
id: 'opus' as const,
|
||||
label: `Opus (${finalTiers.opus.provider}: ${finalTiers.opus.model})`,
|
||||
},
|
||||
{
|
||||
id: 'sonnet' as const,
|
||||
label: `Sonnet (${finalTiers.sonnet.provider}: ${finalTiers.sonnet.model})`,
|
||||
},
|
||||
{
|
||||
id: 'haiku' as const,
|
||||
label: `Haiku (${finalTiers.haiku.provider}: ${finalTiers.haiku.model})`,
|
||||
},
|
||||
];
|
||||
newDefaultTier = (await InteractivePrompt.selectFromList(
|
||||
'Default tier (ANTHROPIC_MODEL):',
|
||||
tierOptions
|
||||
)) as 'opus' | 'sonnet' | 'haiku';
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(info(`Updating composite ${getBackendLabel(backend)} variant...`));
|
||||
const result = updateCompositeVariant(name, {
|
||||
tiers: updatedTiers,
|
||||
defaultTier: changeDefault ? newDefaultTier : undefined,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.log(fail(`Failed to update composite variant: ${result.error}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
const finalVariant = result.variant;
|
||||
if (finalVariant && finalVariant.tiers) {
|
||||
const tierSummary =
|
||||
`Opus: ${finalVariant.tiers.opus.provider} / ${finalVariant.tiers.opus.model}\n` +
|
||||
`Sonnet: ${finalVariant.tiers.sonnet.provider} / ${finalVariant.tiers.sonnet.model}\n` +
|
||||
`Haiku: ${finalVariant.tiers.haiku.provider} / ${finalVariant.tiers.haiku.model}\n` +
|
||||
`Default: ${finalVariant.default_tier}`;
|
||||
const portInfo = finalVariant.port ? `\nPort: ${finalVariant.port}` : '';
|
||||
console.log(
|
||||
infoBox(
|
||||
`Variant: ${name} (composite)\n${tierSummary}${portInfo}\nConfig: ~/.ccs/config.yaml`,
|
||||
'Composite Variant Updated'
|
||||
)
|
||||
);
|
||||
} else {
|
||||
console.log(ok(`Composite variant updated: ${name}`));
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
ImageAnalysisConfig,
|
||||
CursorConfig,
|
||||
} from './unified-config-types';
|
||||
import { validateCompositeTiers } from '../cliproxy/composite-validator';
|
||||
import { isUnifiedConfigEnabled } from './feature-flags';
|
||||
|
||||
const CONFIG_YAML = 'config.yaml';
|
||||
@@ -206,6 +207,27 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate composite variant provider strings.
|
||||
* Warns about invalid providers in composite variant configurations.
|
||||
*/
|
||||
function validateCompositeVariants(config: UnifiedConfig): void {
|
||||
const variants = config.cliproxy?.variants;
|
||||
if (!variants) return;
|
||||
|
||||
for (const [name, variant] of Object.entries(variants)) {
|
||||
if ('type' in variant && variant.type === 'composite') {
|
||||
const error = validateCompositeTiers(variant.tiers, {
|
||||
defaultTier: variant.default_tier,
|
||||
requireAllTiers: true,
|
||||
});
|
||||
if (error) {
|
||||
console.warn(`[!] Variant '${name}': invalid composite config (${error})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge partial config with defaults.
|
||||
* Preserves existing data while filling in missing sections.
|
||||
@@ -413,7 +435,10 @@ export function loadOrCreateUnifiedConfig(): UnifiedConfig {
|
||||
const existing = loadUnifiedConfig();
|
||||
if (existing) {
|
||||
// Merge with defaults to fill any missing sections
|
||||
return mergeWithDefaults(existing);
|
||||
const merged = mergeWithDefaults(existing);
|
||||
// Validate composite variant provider strings
|
||||
validateCompositeVariants(merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Create empty config
|
||||
|
||||
@@ -21,6 +21,21 @@
|
||||
*/
|
||||
export const UNIFIED_CONFIG_VERSION = 8;
|
||||
|
||||
/**
|
||||
* Supported CLIProxy providers.
|
||||
* Includes all OAuth-based providers supported by CLIProxyAPI.
|
||||
*/
|
||||
export const CLIPROXY_SUPPORTED_PROVIDERS = [
|
||||
'gemini',
|
||||
'codex',
|
||||
'agy',
|
||||
'qwen',
|
||||
'iflow',
|
||||
'kiro',
|
||||
'ghcp',
|
||||
'claude',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Account configuration (formerly in profiles.json).
|
||||
* Represents an isolated Claude instance via CLAUDE_CONFIG_DIR.
|
||||
@@ -72,6 +87,51 @@ export interface CLIProxyVariantConfig {
|
||||
auth?: CLIProxyAuthConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-tier provider+model mapping for composite variants.
|
||||
*/
|
||||
export interface CompositeTierConfig {
|
||||
/** Provider for this tier */
|
||||
provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp' | 'claude';
|
||||
/** Model ID to use for this tier */
|
||||
model: string;
|
||||
/** Account nickname (optional, references oauth_accounts) */
|
||||
account?: string;
|
||||
/** Fallback provider+model if primary fails */
|
||||
fallback?: {
|
||||
provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp' | 'claude';
|
||||
model: string;
|
||||
account?: string;
|
||||
};
|
||||
/** Per-tier thinking budget override (e.g. 'xhigh', 'medium', 'off') */
|
||||
thinking?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite variant configuration.
|
||||
* Mixes different providers per Claude tier (opus, sonnet, haiku) in a single profile.
|
||||
* Uses CLIProxyAPI root endpoints (/v1/messages) for model-based routing
|
||||
* instead of provider-specific endpoints (/api/provider/{provider}).
|
||||
*/
|
||||
export interface CompositeVariantConfig {
|
||||
/** Discriminator for composite type */
|
||||
type: 'composite';
|
||||
/** Which tier ANTHROPIC_MODEL equals (default must be one of the three) */
|
||||
default_tier: 'opus' | 'sonnet' | 'haiku';
|
||||
/** Per-tier provider+model mapping */
|
||||
tiers: {
|
||||
opus: CompositeTierConfig;
|
||||
sonnet: CompositeTierConfig;
|
||||
haiku: CompositeTierConfig;
|
||||
};
|
||||
/** Path to settings file */
|
||||
settings?: string;
|
||||
/** Shared port for the composite profile */
|
||||
port?: number;
|
||||
/** Per-variant auth override (optional) */
|
||||
auth?: CLIProxyAuthConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLIProxy authentication configuration.
|
||||
* Allows customization of API key and management secret for CLIProxyAPI.
|
||||
@@ -122,8 +182,8 @@ export interface CLIProxyConfig {
|
||||
oauth_accounts: OAuthAccounts;
|
||||
/** Built-in providers (read-only, for reference) */
|
||||
providers: readonly string[];
|
||||
/** User-defined provider variants */
|
||||
variants: Record<string, CLIProxyVariantConfig>;
|
||||
/** User-defined provider variants (single-provider or composite) */
|
||||
variants: Record<string, CLIProxyVariantConfig | CompositeVariantConfig>;
|
||||
/** Logging configuration (disabled by default) */
|
||||
logging?: CLIProxyLoggingConfig;
|
||||
/** Kiro: disable incognito browser mode (use normal browser to save credentials) */
|
||||
@@ -700,7 +760,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
|
||||
cliproxy: {
|
||||
backend: 'plus',
|
||||
oauth_accounts: {},
|
||||
providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp'],
|
||||
providers: [...CLIPROXY_SUPPORTED_PROVIDERS],
|
||||
variants: {},
|
||||
logging: {
|
||||
enabled: false,
|
||||
|
||||
@@ -12,6 +12,7 @@ export type {
|
||||
EnvValue,
|
||||
ProfileMetadata,
|
||||
ProfilesRegistry,
|
||||
CLIProxyVariantConfig,
|
||||
CLIProxyVariantsConfig,
|
||||
} from './config';
|
||||
export { isConfig, isSettings } from './config';
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { Config, isConfig, Settings, isSettings, CLIProxyVariantsConfig } from '../types';
|
||||
import {
|
||||
Config,
|
||||
isConfig,
|
||||
Settings,
|
||||
isSettings,
|
||||
CLIProxyVariantsConfig,
|
||||
CLIProxyVariantConfig,
|
||||
} from '../types';
|
||||
import { expandPath, error } from './helpers';
|
||||
import { info } from './ui';
|
||||
import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
@@ -191,12 +198,22 @@ export function loadConfigSafe(): Config {
|
||||
if (unifiedConfig.cliproxy?.variants) {
|
||||
cliproxy = {};
|
||||
for (const [name, variant] of Object.entries(unifiedConfig.cliproxy.variants)) {
|
||||
cliproxy[name] = {
|
||||
provider: variant.provider,
|
||||
settings: variant.settings,
|
||||
account: variant.account,
|
||||
port: variant.port,
|
||||
};
|
||||
if ('type' in variant && variant.type === 'composite') {
|
||||
// Composite variants: use default tier's provider
|
||||
cliproxy[name] = {
|
||||
provider: variant.tiers[variant.default_tier].provider,
|
||||
settings: variant.settings,
|
||||
port: variant.port,
|
||||
};
|
||||
} else {
|
||||
const single = variant as CLIProxyVariantConfig;
|
||||
cliproxy[name] = {
|
||||
provider: single.provider,
|
||||
settings: single.settings,
|
||||
account: single.account,
|
||||
port: single.port,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,13 @@ import {
|
||||
listVariants,
|
||||
validateProfileName,
|
||||
updateVariant,
|
||||
createCompositeVariant,
|
||||
updateCompositeVariant,
|
||||
} from '../../cliproxy/services/variant-service';
|
||||
import {
|
||||
validateCompositeDefaultTier,
|
||||
validateCompositeTiers,
|
||||
} from '../../cliproxy/composite-validator';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -30,6 +36,9 @@ router.get('/', (_req: Request, res: Response) => {
|
||||
account: variant.account || 'default',
|
||||
port: variant.port, // Include port for port isolation
|
||||
model: variant.model,
|
||||
type: variant.type,
|
||||
default_tier: variant.default_tier,
|
||||
tiers: variant.tiers,
|
||||
}));
|
||||
|
||||
res.json({ variants: variantList });
|
||||
@@ -40,10 +49,10 @@ router.get('/', (_req: Request, res: Response) => {
|
||||
* Uses variant-service for proper port allocation
|
||||
*/
|
||||
router.post('/', (req: Request, res: Response): void => {
|
||||
const { name, provider, model, account } = req.body;
|
||||
const { name, provider, model, account, type, default_tier, tiers } = req.body;
|
||||
|
||||
if (!name || !provider) {
|
||||
res.status(400).json({ error: 'Missing required fields: name, provider' });
|
||||
if (!name) {
|
||||
res.status(400).json({ error: 'Missing required field: name' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -63,6 +72,53 @@ router.post('/', (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle composite variant creation
|
||||
if (type === 'composite') {
|
||||
if (!default_tier || !tiers) {
|
||||
res.status(400).json({ error: 'Missing required fields: default_tier, tiers' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate tiers shape, providers, and default_tier (all tiers required for create)
|
||||
const tierError = validateCompositeTiers(tiers, {
|
||||
defaultTier: default_tier,
|
||||
requireAllTiers: true,
|
||||
});
|
||||
if (tierError) {
|
||||
res.status(400).json({ error: tierError });
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = createCompositeVariant({ name, defaultTier: default_tier, tiers });
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: (error as Error).message });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
res.status(409).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
name,
|
||||
type: 'composite',
|
||||
default_tier,
|
||||
tiers,
|
||||
settings: result.settingsPath,
|
||||
port: result.variant?.port,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle single provider variant creation
|
||||
if (!provider) {
|
||||
res.status(400).json({ error: 'Missing required field: provider' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Require model for variant creation (prevents empty model causing issues)
|
||||
if (!model || !model.trim()) {
|
||||
res.status(400).json({ error: 'Missing required field: model' });
|
||||
@@ -90,17 +146,78 @@ router.post('/', (req: Request, res: Response): void => {
|
||||
/**
|
||||
* PUT /api/cliproxy/:name - Update cliproxy variant
|
||||
* Uses variant-service for consistent behavior with CLI
|
||||
*
|
||||
* TODO: Add file-based locking (e.g., proper-lockfile) to prevent concurrent modification
|
||||
* Current behavior: last-write-wins if two requests modify same variant simultaneously
|
||||
*/
|
||||
router.put('/:name', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const { provider, account, model } = req.body;
|
||||
const { provider, account, model, default_tier, tiers } = req.body;
|
||||
|
||||
// Use variant-service for proper update handling
|
||||
// Check if variant is composite - use updateCompositeVariant if so
|
||||
const variants = listVariants();
|
||||
const existing = variants[name];
|
||||
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: `Variant '${name}' not found` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing.type === 'composite') {
|
||||
if (!default_tier && !tiers) {
|
||||
res.status(400).json({ error: 'Must provide at least default_tier or tiers' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate tiers shape, providers, and default_tier if provided
|
||||
if (tiers) {
|
||||
const tierError = validateCompositeTiers(tiers, {
|
||||
defaultTier: default_tier,
|
||||
});
|
||||
if (tierError) {
|
||||
res.status(400).json({ error: tierError });
|
||||
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,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const persisted = result.variant;
|
||||
res.json({
|
||||
name,
|
||||
type: 'composite',
|
||||
default_tier: persisted?.default_tier,
|
||||
tiers: persisted?.tiers,
|
||||
settings: persisted?.settings,
|
||||
port: persisted?.port,
|
||||
updated: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Use variant-service for proper update handling (single provider)
|
||||
const result = updateVariant(name, { provider, account, model });
|
||||
|
||||
if (!result.success) {
|
||||
res.status(404).json({ error: result.error });
|
||||
const status = result.error?.includes('not found') ? 404 : 400;
|
||||
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/');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Composite Variant Fallback Tests
|
||||
*
|
||||
* Tests fallback detection and application for composite variants
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
isProviderError,
|
||||
detectFailedTier,
|
||||
PROVIDER_ERROR_PATTERNS,
|
||||
} from '../../../src/cliproxy/executor/retry-handler';
|
||||
import { applyFallback } from '../../../src/cliproxy/executor/env-resolver';
|
||||
import { CompositeTierConfig } from '../../../src/config/unified-config-types';
|
||||
|
||||
// ========================================
|
||||
// isProviderError
|
||||
// ========================================
|
||||
|
||||
describe('isProviderError', () => {
|
||||
it('should return false for exit code 0 (success)', () => {
|
||||
const result = isProviderError(0, 'Error: 500 Internal Server Error');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect 4xx error codes', () => {
|
||||
const stderr = 'Error: 401 Unauthorized';
|
||||
const result = isProviderError(1, stderr);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect 5xx error codes', () => {
|
||||
const stderr = 'Error: 503 Service Unavailable';
|
||||
const result = isProviderError(1, stderr);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect overloaded errors', () => {
|
||||
const stderr = 'Provider is currently overloaded. Please try again later.';
|
||||
const result = isProviderError(1, stderr);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect quota exceeded errors', () => {
|
||||
const stderr = 'quota has been exceeded for this account';
|
||||
const result = isProviderError(1, stderr);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect connection refused errors', () => {
|
||||
const stderr = 'ECONNREFUSED 127.0.0.1:8317';
|
||||
const result = isProviderError(1, stderr);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect rate limit errors', () => {
|
||||
const stderr = 'Rate limit exceeded. Please slow down.';
|
||||
const result = isProviderError(1, stderr);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for normal user exit', () => {
|
||||
const stderr = 'User cancelled the operation';
|
||||
const result = isProviderError(1, stderr);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty stderr with non-zero exit', () => {
|
||||
const result = isProviderError(1, '');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should be case insensitive for error patterns', () => {
|
||||
const stderr = 'ERROR: OVERLOADED';
|
||||
const result = isProviderError(1, stderr);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle multiple error patterns in same stderr', () => {
|
||||
const stderr = 'Error: 429 rate limit exceeded';
|
||||
const result = isProviderError(1, stderr);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// detectFailedTier
|
||||
// ========================================
|
||||
|
||||
describe('detectFailedTier', () => {
|
||||
const tiers: {
|
||||
opus: CompositeTierConfig;
|
||||
sonnet: CompositeTierConfig;
|
||||
haiku: CompositeTierConfig;
|
||||
} = {
|
||||
opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' },
|
||||
sonnet: { provider: 'gemini', model: 'gemini-3-pro-preview' },
|
||||
haiku: { provider: 'codex', model: 'gpt-4o-mini' },
|
||||
};
|
||||
|
||||
it('should detect opus tier from model name in stderr', () => {
|
||||
const stderr = 'Error calling model claude-opus-4-6-thinking: 503 overloaded';
|
||||
const result = detectFailedTier(stderr, tiers);
|
||||
expect(result).toBe('opus');
|
||||
});
|
||||
|
||||
it('should detect sonnet tier from model name in stderr', () => {
|
||||
const stderr = 'Failed to connect: gemini-3-pro-preview returned 500';
|
||||
const result = detectFailedTier(stderr, tiers);
|
||||
expect(result).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('should detect haiku tier from model name in stderr', () => {
|
||||
const stderr = 'gpt-4o-mini: rate limit exceeded';
|
||||
const result = detectFailedTier(stderr, tiers);
|
||||
expect(result).toBe('haiku');
|
||||
});
|
||||
|
||||
it('should return null when no tier model found in stderr', () => {
|
||||
const stderr = 'Generic error with no model mention';
|
||||
const result = detectFailedTier(stderr, tiers);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null for empty stderr', () => {
|
||||
const result = detectFailedTier('', tiers);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('should match first tier when multiple models mentioned', () => {
|
||||
const stderr =
|
||||
'Tried claude-opus-4-6-thinking, then gemini-3-pro-preview, both failed';
|
||||
const result = detectFailedTier(stderr, tiers);
|
||||
expect(result).toBe('opus'); // First match
|
||||
});
|
||||
|
||||
it('should handle model names with suffixes (thinking budgets)', () => {
|
||||
const tiersWithBudget: typeof tiers = {
|
||||
opus: { provider: 'agy', model: 'claude-opus-4-6-thinking(high)' },
|
||||
sonnet: { provider: 'gemini', model: 'gemini-3-pro-preview(medium)' },
|
||||
haiku: { provider: 'codex', model: 'gpt-4o-mini' },
|
||||
};
|
||||
|
||||
// Stderr might not include suffix
|
||||
const stderr = 'Error with claude-opus-4-6-thinking: timeout';
|
||||
const result = detectFailedTier(stderr, tiersWithBudget);
|
||||
// Should still match because model name is substring
|
||||
expect(result).toBe('opus');
|
||||
});
|
||||
|
||||
it('should strip complex thinking suffix with comma-separated params', () => {
|
||||
const tiersWithComplexBudget: typeof tiers = {
|
||||
opus: { provider: 'agy', model: 'claude-opus-4-6-thinking(32768,extended)' },
|
||||
sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking(high)' },
|
||||
haiku: { provider: 'agy', model: 'claude-3-5-haiku' },
|
||||
};
|
||||
|
||||
// Stderr contains base model name without suffix
|
||||
const stderr = 'Error: claude-opus-4-6-thinking overloaded';
|
||||
const result = detectFailedTier(stderr, tiersWithComplexBudget);
|
||||
// Should match because regex strips (32768,extended) to get base name
|
||||
expect(result).toBe('opus');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// applyFallback
|
||||
// ========================================
|
||||
|
||||
describe('applyFallback', () => {
|
||||
it('should update opus tier env var', () => {
|
||||
const env = {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const result = applyFallback(env, 'opus', {
|
||||
provider: 'gemini',
|
||||
model: 'gemini-3-pro-preview',
|
||||
});
|
||||
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gemini-3-pro-preview');
|
||||
// Should not modify other tiers
|
||||
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('should update sonnet tier env var', () => {
|
||||
const env = {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const result = applyFallback(env, 'sonnet', {
|
||||
provider: 'codex',
|
||||
model: 'gpt-4o',
|
||||
});
|
||||
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-4o');
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
|
||||
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001');
|
||||
});
|
||||
|
||||
it('should update haiku tier env var', () => {
|
||||
const env = {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
|
||||
ANTHROPIC_MODEL: 'claude-haiku-4-5-20251001',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const result = applyFallback(env, 'haiku', {
|
||||
provider: 'gemini',
|
||||
model: 'gemini-3-flash-preview',
|
||||
});
|
||||
|
||||
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gemini-3-flash-preview');
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking');
|
||||
});
|
||||
|
||||
it('should update ANTHROPIC_MODEL when failed tier is default tier', () => {
|
||||
const env = {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const result = applyFallback(env, 'opus', {
|
||||
provider: 'gemini',
|
||||
model: 'gemini-3-pro-preview',
|
||||
});
|
||||
|
||||
// Both tier model and default model should be updated
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gemini-3-pro-preview');
|
||||
expect(result.ANTHROPIC_MODEL).toBe('gemini-3-pro-preview');
|
||||
});
|
||||
|
||||
it('should NOT update ANTHROPIC_MODEL when failed tier is not default tier', () => {
|
||||
const env = {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', // Sonnet is default
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const result = applyFallback(env, 'opus', {
|
||||
provider: 'gemini',
|
||||
model: 'gemini-3-pro-preview',
|
||||
});
|
||||
|
||||
// Opus tier should be updated but ANTHROPIC_MODEL should remain sonnet
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gemini-3-pro-preview');
|
||||
expect(result.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking');
|
||||
});
|
||||
|
||||
it('should preserve other env vars', () => {
|
||||
const env = {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-6-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',
|
||||
CUSTOM_VAR: 'custom_value',
|
||||
ANOTHER_VAR: '12345',
|
||||
};
|
||||
|
||||
const result = applyFallback(env, 'opus', {
|
||||
provider: 'gemini',
|
||||
model: 'gemini-3-pro-preview',
|
||||
});
|
||||
|
||||
expect(result.CUSTOM_VAR).toBe('custom_value');
|
||||
expect(result.ANOTHER_VAR).toBe('12345');
|
||||
expect(result.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8318');
|
||||
});
|
||||
|
||||
it('should not mutate original env object', () => {
|
||||
const env = {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-6-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 original = { ...env };
|
||||
applyFallback(env, 'opus', {
|
||||
provider: 'gemini',
|
||||
model: 'gemini-3-pro-preview',
|
||||
});
|
||||
|
||||
// Original env should be unchanged
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe(original.ANTHROPIC_DEFAULT_OPUS_MODEL);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// PROVIDER_ERROR_PATTERNS
|
||||
// ========================================
|
||||
|
||||
describe('PROVIDER_ERROR_PATTERNS', () => {
|
||||
it('should export error pattern regexes', () => {
|
||||
expect(PROVIDER_ERROR_PATTERNS).toBeDefined();
|
||||
expect(Array.isArray(PROVIDER_ERROR_PATTERNS)).toBe(true);
|
||||
expect(PROVIDER_ERROR_PATTERNS.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should match 4xx errors', () => {
|
||||
const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('Error: 401'));
|
||||
expect(pattern).toBeDefined();
|
||||
});
|
||||
|
||||
it('should match 5xx errors', () => {
|
||||
const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('Error: 503'));
|
||||
expect(pattern).toBeDefined();
|
||||
});
|
||||
|
||||
it('should match overloaded keyword', () => {
|
||||
const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('overloaded'));
|
||||
expect(pattern).toBeDefined();
|
||||
});
|
||||
|
||||
it('should match quota exceeded', () => {
|
||||
const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('quota exceeded'));
|
||||
expect(pattern).toBeDefined();
|
||||
});
|
||||
|
||||
it('should match ECONNREFUSED', () => {
|
||||
const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('ECONNREFUSED'));
|
||||
expect(pattern).toBeDefined();
|
||||
});
|
||||
|
||||
it('should match rate limit', () => {
|
||||
const pattern = PROVIDER_ERROR_PATTERNS.find((p) => p.test('rate limit'));
|
||||
expect(pattern).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* Composite Variant Thinking Configuration Tests
|
||||
*
|
||||
* Tests per-tier thinking configuration for composite variants
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
applyThinkingConfig,
|
||||
applyThinkingSuffix,
|
||||
detectTierFromModel,
|
||||
getThinkingValueForTier,
|
||||
} from '../../../src/cliproxy/config/thinking-config';
|
||||
import { CLIProxyProvider } from '../../../src/cliproxy/types';
|
||||
|
||||
// ========================================
|
||||
// applyThinkingSuffix
|
||||
// ========================================
|
||||
|
||||
describe('applyThinkingSuffix', () => {
|
||||
it('should append level name to model', () => {
|
||||
const result = applyThinkingSuffix('gemini-3-pro-preview', 'high');
|
||||
expect(result).toBe('gemini-3-pro-preview(high)');
|
||||
});
|
||||
|
||||
it('should append numeric budget to model', () => {
|
||||
const result = applyThinkingSuffix('claude-opus-4-6-thinking', 8192);
|
||||
expect(result).toBe('claude-opus-4-6-thinking(8192)');
|
||||
});
|
||||
|
||||
it('should not append suffix if model already has one', () => {
|
||||
const result = applyThinkingSuffix('gemini-3-pro-preview(medium)', 'high');
|
||||
expect(result).toBe('gemini-3-pro-preview(medium)');
|
||||
});
|
||||
|
||||
it('should not append suffix if model already has numeric budget', () => {
|
||||
const result = applyThinkingSuffix('claude-opus-4(8192)', 16384);
|
||||
expect(result).toBe('claude-opus-4(8192)');
|
||||
});
|
||||
|
||||
it('should handle empty parentheses as NOT having suffix', () => {
|
||||
const result = applyThinkingSuffix('model()', 'high');
|
||||
// Empty parens don't match regex /\([^)]+\)$/ (requires non-empty content)
|
||||
expect(result).toBe('model()(high)');
|
||||
});
|
||||
|
||||
it('should append to model with hyphens', () => {
|
||||
const result = applyThinkingSuffix('gpt-4o-mini', 'low');
|
||||
expect(result).toBe('gpt-4o-mini(low)');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// detectTierFromModel
|
||||
// ========================================
|
||||
|
||||
describe('detectTierFromModel', () => {
|
||||
it('should detect opus from model name containing "opus"', () => {
|
||||
const result = detectTierFromModel('claude-opus-4-6-thinking');
|
||||
expect(result).toBe('opus');
|
||||
});
|
||||
|
||||
it('should detect sonnet from model name containing "sonnet"', () => {
|
||||
const result = detectTierFromModel('claude-sonnet-4-5-thinking');
|
||||
expect(result).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('should detect haiku from model name containing "haiku"', () => {
|
||||
const result = detectTierFromModel('claude-haiku-4-5-20251001');
|
||||
expect(result).toBe('haiku');
|
||||
});
|
||||
|
||||
it('should default to sonnet for unknown model names', () => {
|
||||
const result = detectTierFromModel('gpt-4o-mini');
|
||||
expect(result).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('should default to sonnet for gemini models', () => {
|
||||
const result = detectTierFromModel('gemini-3-pro-preview');
|
||||
expect(result).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
const result = detectTierFromModel('CLAUDE-OPUS-4');
|
||||
expect(result).toBe('opus');
|
||||
});
|
||||
|
||||
it('should detect haiku even with uppercase', () => {
|
||||
const result = detectTierFromModel('CLAUDE-HAIKU-4');
|
||||
expect(result).toBe('haiku');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// getThinkingValueForTier (mock config)
|
||||
// ========================================
|
||||
|
||||
describe('getThinkingValueForTier', () => {
|
||||
// Note: This function reads from unified config, so we're testing the logic
|
||||
// For full integration tests, see composite-variant-service.test.ts
|
||||
|
||||
it('should return tier default when no provider override', () => {
|
||||
const thinkingConfig = {
|
||||
mode: 'auto' as const,
|
||||
tier_defaults: {
|
||||
opus: 'high',
|
||||
sonnet: 'medium',
|
||||
haiku: 'low',
|
||||
},
|
||||
};
|
||||
|
||||
const result = getThinkingValueForTier('opus', 'agy' as CLIProxyProvider, thinkingConfig);
|
||||
expect(result).toBe('high');
|
||||
});
|
||||
|
||||
it('should return provider-specific override when configured', () => {
|
||||
const thinkingConfig = {
|
||||
mode: 'auto' as const,
|
||||
tier_defaults: {
|
||||
opus: 'high',
|
||||
sonnet: 'medium',
|
||||
haiku: 'low',
|
||||
},
|
||||
provider_overrides: {
|
||||
gemini: {
|
||||
opus: 'xhigh',
|
||||
sonnet: 'high',
|
||||
haiku: 'medium',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = getThinkingValueForTier('opus', 'gemini' as CLIProxyProvider, thinkingConfig);
|
||||
expect(result).toBe('xhigh');
|
||||
});
|
||||
|
||||
it('should fall back to tier default when provider has no override for tier', () => {
|
||||
const thinkingConfig = {
|
||||
mode: 'auto' as const,
|
||||
tier_defaults: {
|
||||
opus: 'high',
|
||||
sonnet: 'medium',
|
||||
haiku: 'low',
|
||||
},
|
||||
provider_overrides: {
|
||||
gemini: {
|
||||
opus: 'xhigh',
|
||||
// No sonnet override
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = getThinkingValueForTier('sonnet', 'gemini' as CLIProxyProvider, thinkingConfig);
|
||||
expect(result).toBe('medium');
|
||||
});
|
||||
|
||||
it('should use centralized defaults when tier_defaults undefined', () => {
|
||||
const thinkingConfig = {
|
||||
mode: 'auto' as const,
|
||||
// tier_defaults is optional - centralized defaults kick in
|
||||
};
|
||||
|
||||
const result = getThinkingValueForTier('opus', 'agy' as CLIProxyProvider, thinkingConfig);
|
||||
// DEFAULT_THINKING_TIER_DEFAULTS.opus = 'high'
|
||||
expect(result).toBe('high');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// applyThinkingConfig with compositeTierThinking
|
||||
// ========================================
|
||||
|
||||
describe('applyThinkingConfig - composite variant integration', () => {
|
||||
it('should apply per-tier thinking from compositeTierThinking parameter', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const compositeTierThinking = {
|
||||
opus: 'xhigh',
|
||||
sonnet: 'medium',
|
||||
haiku: 'low',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'agy' as CLIProxyProvider,
|
||||
undefined, // No CLI override
|
||||
compositeTierThinking
|
||||
);
|
||||
|
||||
// Tier models use raw compositeTierThinking value (no validation in tier loop)
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(xhigh)');
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(medium)');
|
||||
// Haiku doesn't support thinking per model-catalog — no suffix
|
||||
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001');
|
||||
// ANTHROPIC_MODEL is validated: 'medium' → 8192 for budget-type models
|
||||
expect(result.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking(8192)');
|
||||
});
|
||||
|
||||
it('should apply partial per-tier thinking (only some tiers specified)', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const compositeTierThinking = {
|
||||
opus: 'xhigh',
|
||||
// sonnet and haiku will fall back to global config
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'agy' as CLIProxyProvider,
|
||||
undefined,
|
||||
compositeTierThinking
|
||||
);
|
||||
|
||||
// Tier models use raw value (no validation in tier loop)
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(xhigh)');
|
||||
// Sonnet gets defaults from global config (mode=auto)
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toContain('claude-sonnet-4-5-thinking');
|
||||
// Haiku doesn't support thinking — stays unchanged
|
||||
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001');
|
||||
});
|
||||
|
||||
it('should allow per-tier thinking to be "off"', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const compositeTierThinking = {
|
||||
opus: 'high',
|
||||
sonnet: 'medium',
|
||||
haiku: 'off', // Explicitly disabled for haiku
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'agy' as CLIProxyProvider,
|
||||
undefined,
|
||||
compositeTierThinking
|
||||
);
|
||||
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(high)');
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(medium)');
|
||||
// Haiku should NOT have suffix because thinking is not supported for haiku
|
||||
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001');
|
||||
});
|
||||
|
||||
it('should prioritize CLI override over per-tier thinking', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const compositeTierThinking = {
|
||||
opus: 'xhigh',
|
||||
sonnet: 'medium',
|
||||
haiku: 'low',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'agy' as CLIProxyProvider,
|
||||
'minimal', // CLI override takes priority
|
||||
compositeTierThinking
|
||||
);
|
||||
|
||||
// All thinking-capable tiers should use CLI override
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(minimal)');
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(minimal)');
|
||||
// Haiku doesn't support thinking — stays unchanged regardless of CLI override
|
||||
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001');
|
||||
});
|
||||
|
||||
it('should disable all tier thinking when CLI override is explicitly off', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const compositeTierThinking = {
|
||||
opus: 'xhigh',
|
||||
sonnet: 'medium',
|
||||
haiku: 'low',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'agy' as CLIProxyProvider,
|
||||
'off',
|
||||
compositeTierThinking
|
||||
);
|
||||
|
||||
expect(result.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking');
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking');
|
||||
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001');
|
||||
});
|
||||
|
||||
it('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',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const compositeTierThinking = {
|
||||
opus: '32768',
|
||||
sonnet: '8192',
|
||||
haiku: '512',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'agy' as CLIProxyProvider,
|
||||
undefined,
|
||||
compositeTierThinking
|
||||
);
|
||||
|
||||
// Tier models use raw string values (no validation in tier loop)
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(32768)');
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(8192)');
|
||||
// Haiku doesn't support thinking — stays unchanged
|
||||
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001');
|
||||
});
|
||||
|
||||
it('should not apply thinking when mode is off and no override', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const compositeTierThinking = {
|
||||
opus: 'high',
|
||||
sonnet: 'medium',
|
||||
haiku: 'low',
|
||||
};
|
||||
|
||||
// Mock thinking config with mode=off by calling without thinkingOverride
|
||||
// and ensuring global config mode is 'off'
|
||||
// This test would require mocking getThinkingConfig()
|
||||
// For now, test the behavior when compositeTierThinking is undefined
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'agy' as CLIProxyProvider,
|
||||
undefined,
|
||||
undefined // No per-tier thinking
|
||||
);
|
||||
|
||||
// When mode=auto and no compositeTierThinking, defaults apply
|
||||
// This depends on global config - skipping full integration test here
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('should skip thinking for models that do not support it', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'gpt-4o-mini', // Model that doesn't support thinking
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-4o-mini',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-4o-mini',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-4o-mini',
|
||||
};
|
||||
|
||||
const compositeTierThinking = {
|
||||
opus: 'high',
|
||||
sonnet: 'medium',
|
||||
haiku: 'low',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'codex' as CLIProxyProvider,
|
||||
undefined,
|
||||
compositeTierThinking
|
||||
);
|
||||
|
||||
// Models should remain unchanged (no thinking suffix) because they don't support it
|
||||
// Note: This depends on supportsThinking() logic in model-catalog.ts
|
||||
// For models that don't support thinking, no suffix should be added
|
||||
expect(result.ANTHROPIC_MODEL).toBe('gpt-4o-mini');
|
||||
});
|
||||
|
||||
it('should update ANTHROPIC_MODEL when it matches a tier model', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-6-thinking', // Matches opus tier
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const compositeTierThinking = {
|
||||
opus: 'xhigh',
|
||||
sonnet: 'medium',
|
||||
haiku: 'low',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'agy' as CLIProxyProvider,
|
||||
undefined,
|
||||
compositeTierThinking
|
||||
);
|
||||
|
||||
// ANTHROPIC_MODEL is validated: xhigh → 32768 for budget-type models
|
||||
expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4-6-thinking(32768)');
|
||||
// Tier model uses raw value (no validation in tier loop)
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(xhigh)');
|
||||
});
|
||||
|
||||
it('should preserve models that already have thinking suffix', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking(high)', // Already has suffix
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking(high)',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const compositeTierThinking = {
|
||||
opus: 'xhigh',
|
||||
sonnet: 'medium', // Won't override existing suffix
|
||||
haiku: 'low',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'agy' as CLIProxyProvider,
|
||||
undefined,
|
||||
compositeTierThinking
|
||||
);
|
||||
|
||||
// Base model check uses ANTHROPIC_MODEL with suffix — supportsThinking
|
||||
// may not recognize suffixed model names, causing early return.
|
||||
// All models preserved as-is.
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(high)');
|
||||
expect(result.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking(high)');
|
||||
// Opus stays unchanged because function returned early
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-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'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,669 @@
|
||||
/**
|
||||
* Unit tests for composite variant service operations
|
||||
*/
|
||||
|
||||
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 {
|
||||
createCompositeVariant,
|
||||
updateCompositeVariant,
|
||||
} from '../../../src/cliproxy/services/variant-service';
|
||||
import {
|
||||
saveCompositeVariantUnified,
|
||||
listVariantsFromConfig,
|
||||
} from '../../../src/cliproxy/services/variant-config-adapter';
|
||||
import { CompositeVariantConfig } from '../../../src/config/unified-config-types';
|
||||
|
||||
describe('updateCompositeVariant', () => {
|
||||
let tmpDir: string;
|
||||
let originalCcsDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create temp directory for isolated config
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-composite-test-'));
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
// Use CCS_DIR (not CCS_HOME which appends .ccs)
|
||||
process.env.CCS_DIR = tmpDir;
|
||||
|
||||
// Create unified config file
|
||||
const configDir = tmpDir;
|
||||
const configPath = path.join(configDir, 'config.yaml');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
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(() => {
|
||||
// Restore original CCS_DIR
|
||||
if (originalCcsDir !== undefined) {
|
||||
process.env.CCS_DIR = originalCcsDir;
|
||||
} else {
|
||||
delete process.env.CCS_DIR;
|
||||
}
|
||||
|
||||
// Clean up temp directory
|
||||
if (tmpDir && fs.existsSync(tmpDir)) {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should update partial tier config (only opus model)', () => {
|
||||
// Setup: Create initial composite variant
|
||||
const initialConfig: CompositeVariantConfig = {
|
||||
type: 'composite',
|
||||
default_tier: 'sonnet',
|
||||
tiers: {
|
||||
opus: { provider: 'gemini', model: 'gemini-3-pro-preview' },
|
||||
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);
|
||||
|
||||
// Create dummy settings file to avoid deletion error
|
||||
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'
|
||||
);
|
||||
|
||||
// Test: Update only opus tier
|
||||
const result = updateCompositeVariant('test', {
|
||||
tiers: {
|
||||
opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.variant?.tiers?.opus.model).toBe('claude-opus-4-6-thinking');
|
||||
expect(result.variant?.tiers?.sonnet.model).toBe('claude-sonnet-4-5-thinking'); // Unchanged
|
||||
expect(result.variant?.tiers?.haiku.model).toBe('claude-haiku-4-5-20251001'); // Unchanged
|
||||
});
|
||||
|
||||
it('should update default tier', () => {
|
||||
// Setup: Create initial composite variant
|
||||
const initialConfig: CompositeVariantConfig = {
|
||||
type: 'composite',
|
||||
default_tier: 'sonnet',
|
||||
tiers: {
|
||||
opus: { provider: 'gemini', model: 'gemini-3-pro-preview' },
|
||||
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);
|
||||
|
||||
// Create dummy settings file
|
||||
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'
|
||||
);
|
||||
|
||||
// Test: Update default tier
|
||||
const result = updateCompositeVariant('test', {
|
||||
defaultTier: 'opus',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.variant?.default_tier).toBe('opus');
|
||||
expect(result.variant?.provider).toBe('gemini'); // Provider from opus tier
|
||||
});
|
||||
|
||||
it('should update all tiers', () => {
|
||||
// Setup: Create initial composite variant
|
||||
const initialConfig: CompositeVariantConfig = {
|
||||
type: 'composite',
|
||||
default_tier: 'sonnet',
|
||||
tiers: {
|
||||
opus: { provider: 'gemini', model: 'gemini-3-pro-preview' },
|
||||
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);
|
||||
|
||||
// Create dummy settings file
|
||||
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'
|
||||
);
|
||||
|
||||
// Test: Update all tiers
|
||||
const result = updateCompositeVariant('test', {
|
||||
tiers: {
|
||||
opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' },
|
||||
sonnet: { provider: 'codex', model: 'codex-sonnet-4-5' },
|
||||
haiku: { provider: 'gemini', model: 'gemini-2.5-flash' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.variant?.tiers?.opus.provider).toBe('agy');
|
||||
expect(result.variant?.tiers?.sonnet.provider).toBe('codex');
|
||||
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 preserve existing custom settings path and custom settings fields', () => {
|
||||
const customSettingsPath = path.join(tmpDir, 'custom', 'my-composite.settings.json');
|
||||
const initialConfig: CompositeVariantConfig = {
|
||||
type: 'composite',
|
||||
default_tier: 'sonnet',
|
||||
tiers: {
|
||||
opus: { provider: 'gemini', model: 'gemini-3-pro-preview' },
|
||||
sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' },
|
||||
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
|
||||
},
|
||||
settings: customSettingsPath,
|
||||
port: 8318,
|
||||
};
|
||||
saveCompositeVariantUnified('test', initialConfig);
|
||||
|
||||
fs.mkdirSync(path.dirname(customSettingsPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
customSettingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3-pro-preview',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
CUSTOM_ENV: 'preserve-this',
|
||||
},
|
||||
hooks: { PreToolUse: [{ matcher: 'WebSearch', hooks: [] }] },
|
||||
customPreset: true,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const result = updateCompositeVariant('test', {
|
||||
tiers: {
|
||||
sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking(high)' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.variant?.settings).toBe(customSettingsPath);
|
||||
|
||||
const updatedSettings = JSON.parse(fs.readFileSync(customSettingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
hooks: { PreToolUse: unknown[] };
|
||||
customPreset: boolean;
|
||||
};
|
||||
expect(updatedSettings.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking(high)');
|
||||
expect(updatedSettings.env.CUSTOM_ENV).toBe('preserve-this');
|
||||
expect(updatedSettings.hooks.PreToolUse.length).toBe(1);
|
||||
expect(updatedSettings.customPreset).toBe(true);
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
expect(variants.test.settings).toBe(customSettingsPath);
|
||||
});
|
||||
|
||||
it('should return error when variant does not exist', () => {
|
||||
const result = updateCompositeVariant('nonexistent', {
|
||||
tiers: {
|
||||
opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should return error when variant is not composite type', () => {
|
||||
// Setup: Create non-composite variant in unified config
|
||||
const configPath = path.join(tmpDir, 'config.yaml');
|
||||
const yamlContent = `version: 2
|
||||
accounts: {}
|
||||
profiles: {}
|
||||
preferences:
|
||||
theme: system
|
||||
telemetry: false
|
||||
auto_update: true
|
||||
cliproxy:
|
||||
oauth_accounts: {}
|
||||
providers:
|
||||
- gemini
|
||||
- codex
|
||||
- agy
|
||||
variants:
|
||||
simple:
|
||||
provider: gemini
|
||||
settings: cliproxy/simple.settings.json
|
||||
port: 8318
|
||||
`;
|
||||
fs.writeFileSync(configPath, yamlContent, 'utf-8');
|
||||
|
||||
const result = updateCompositeVariant('simple', {
|
||||
tiers: {
|
||||
opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('not a composite variant');
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-composite-test-'));
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
process.env.CCS_DIR = tmpDir;
|
||||
|
||||
// Create unified config file
|
||||
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('should save composite variant to unified config', () => {
|
||||
const config: CompositeVariantConfig = {
|
||||
type: 'composite',
|
||||
default_tier: 'sonnet',
|
||||
tiers: {
|
||||
opus: { provider: 'gemini', model: 'gemini-3-pro-preview' },
|
||||
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', config);
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
expect(variants.test).toBeDefined();
|
||||
expect(variants.test.type).toBe('composite');
|
||||
expect(variants.test.default_tier).toBe('sonnet');
|
||||
expect(variants.test.tiers?.opus.model).toBe('gemini-3-pro-preview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listVariantsFromConfig - composite variants', () => {
|
||||
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;
|
||||
});
|
||||
|
||||
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('should list composite variants with fallback field', () => {
|
||||
// Create unified config with composite variant that has fallback
|
||||
const configPath = path.join(tmpDir, 'config.yaml');
|
||||
const yamlContent = `version: 2
|
||||
accounts: {}
|
||||
profiles: {}
|
||||
preferences:
|
||||
theme: system
|
||||
telemetry: false
|
||||
auto_update: true
|
||||
cliproxy:
|
||||
oauth_accounts: {}
|
||||
providers:
|
||||
- gemini
|
||||
- codex
|
||||
- agy
|
||||
variants:
|
||||
test:
|
||||
type: composite
|
||||
default_tier: sonnet
|
||||
tiers:
|
||||
opus:
|
||||
provider: agy
|
||||
model: claude-opus-4-6-thinking
|
||||
fallback:
|
||||
provider: gemini
|
||||
model: gemini-3-pro-preview
|
||||
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
|
||||
`;
|
||||
fs.writeFileSync(configPath, yamlContent, 'utf-8');
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
expect(variants.test).toBeDefined();
|
||||
expect(variants.test.hasFallback).toBe(true);
|
||||
});
|
||||
|
||||
it('should list composite variants with thinking field', () => {
|
||||
// Create unified config with composite variant that has per-tier thinking
|
||||
const configPath = path.join(tmpDir, 'config.yaml');
|
||||
const yamlContent = `version: 2
|
||||
accounts: {}
|
||||
profiles: {}
|
||||
preferences:
|
||||
theme: system
|
||||
telemetry: false
|
||||
auto_update: true
|
||||
cliproxy:
|
||||
oauth_accounts: {}
|
||||
providers:
|
||||
- gemini
|
||||
- codex
|
||||
- agy
|
||||
variants:
|
||||
test:
|
||||
type: composite
|
||||
default_tier: sonnet
|
||||
tiers:
|
||||
opus:
|
||||
provider: agy
|
||||
model: claude-opus-4-6-thinking
|
||||
thinking: xhigh
|
||||
sonnet:
|
||||
provider: agy
|
||||
model: claude-sonnet-4-5-thinking
|
||||
thinking: medium
|
||||
haiku:
|
||||
provider: agy
|
||||
model: claude-haiku-4-5-20251001
|
||||
thinking: off
|
||||
settings: cliproxy/composite-test.settings.json
|
||||
port: 8318
|
||||
`;
|
||||
fs.writeFileSync(configPath, yamlContent, 'utf-8');
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
expect(variants.test).toBeDefined();
|
||||
expect(variants.test.tiers?.opus.thinking).toBe('xhigh');
|
||||
expect(variants.test.tiers?.sonnet.thinking).toBe('medium');
|
||||
expect(variants.test.tiers?.haiku.thinking).toBe('off');
|
||||
});
|
||||
|
||||
it('should have hasFallback=false when no fallbacks configured', () => {
|
||||
// Create unified config with composite variant WITHOUT fallback
|
||||
const configPath = path.join(tmpDir, 'config.yaml');
|
||||
const yamlContent = `version: 2
|
||||
accounts: {}
|
||||
profiles: {}
|
||||
preferences:
|
||||
theme: system
|
||||
telemetry: false
|
||||
auto_update: true
|
||||
cliproxy:
|
||||
oauth_accounts: {}
|
||||
providers:
|
||||
- gemini
|
||||
- codex
|
||||
- agy
|
||||
variants:
|
||||
test:
|
||||
type: composite
|
||||
default_tier: sonnet
|
||||
tiers:
|
||||
opus:
|
||||
provider: agy
|
||||
model: claude-opus-4-6-thinking
|
||||
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
|
||||
`;
|
||||
fs.writeFileSync(configPath, yamlContent, 'utf-8');
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
expect(variants.test).toBeDefined();
|
||||
expect(variants.test.hasFallback).toBe(false);
|
||||
});
|
||||
|
||||
it('should skip malformed composite variant and keep valid variants', () => {
|
||||
const configPath = path.join(tmpDir, 'config.yaml');
|
||||
const yamlContent = `version: 2
|
||||
accounts: {}
|
||||
profiles: {}
|
||||
preferences:
|
||||
theme: system
|
||||
telemetry: false
|
||||
auto_update: true
|
||||
cliproxy:
|
||||
oauth_accounts: {}
|
||||
providers:
|
||||
- gemini
|
||||
- codex
|
||||
- agy
|
||||
variants:
|
||||
bad:
|
||||
type: composite
|
||||
default_tier: sonnet
|
||||
tiers:
|
||||
opus:
|
||||
provider: agy
|
||||
model: claude-opus-4-6-thinking
|
||||
sonnet:
|
||||
provider: agy
|
||||
model: claude-sonnet-4-5-thinking
|
||||
settings: cliproxy/bad.settings.json
|
||||
port: 8318
|
||||
good:
|
||||
provider: gemini
|
||||
settings: cliproxy/good.settings.json
|
||||
port: 8319
|
||||
`;
|
||||
fs.writeFileSync(configPath, yamlContent, 'utf-8');
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
expect(variants.bad).toBeUndefined();
|
||||
expect(variants.good).toBeDefined();
|
||||
expect(variants.good.provider).toBe('gemini');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Unit tests for single-variant provider/model update behavior.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { updateVariant } from '../../../src/cliproxy/services/variant-service';
|
||||
import { loadOrCreateUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||
|
||||
describe('updateVariant - provider/model consistency', () => {
|
||||
let tmpDir: string;
|
||||
let originalCcsDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-variant-update-test-'));
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
process.env.CCS_DIR = tmpDir;
|
||||
|
||||
const settingsPath = path.join(tmpDir, 'gemini-demo.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318/api/provider/gemini',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'gemini-2.5-pro',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-2.5-pro',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-2.5-pro',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-2.5-flash',
|
||||
CUSTOM_FLAG: 'keep-me',
|
||||
},
|
||||
hooks: { PreToolUse: [{ matcher: 'WebSearch', hooks: [] }] },
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'config.yaml'),
|
||||
`version: 2
|
||||
accounts: {}
|
||||
profiles: {}
|
||||
preferences:
|
||||
theme: system
|
||||
telemetry: false
|
||||
auto_update: true
|
||||
cliproxy:
|
||||
oauth_accounts: {}
|
||||
providers:
|
||||
- gemini
|
||||
- codex
|
||||
- agy
|
||||
variants:
|
||||
demo:
|
||||
provider: gemini
|
||||
settings: ${settingsPath}
|
||||
port: 8318
|
||||
`,
|
||||
'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('rejects provider change without model update', () => {
|
||||
const result = updateVariant('demo', { provider: 'codex' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Changing provider requires model update');
|
||||
});
|
||||
|
||||
it('updates provider and regenerates provider-specific core env in same settings file', () => {
|
||||
const result = updateVariant('demo', {
|
||||
provider: 'codex',
|
||||
model: 'gpt-5.1-codex-mini',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.variant?.provider).toBe('codex');
|
||||
expect(result.variant?.model).toBe('gpt-5.1-codex-mini');
|
||||
|
||||
const settingsPath = path.join(tmpDir, 'gemini-demo.settings.json');
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
hooks: { PreToolUse: unknown[] };
|
||||
};
|
||||
|
||||
expect(settings.env.ANTHROPIC_BASE_URL).toContain('/api/provider/codex');
|
||||
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.1-codex-mini');
|
||||
expect(settings.env.CUSTOM_FLAG).toBe('keep-me');
|
||||
expect(settings.hooks.PreToolUse.length).toBe(1);
|
||||
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
expect(config.cliproxy?.variants?.demo?.provider).toBe('codex');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
const assert = require('assert');
|
||||
const GlmtTransformer = require('../../../dist/glmt/glmt-transformer').default;
|
||||
const { getCcsDir } = require('../../../dist/utils/config-manager');
|
||||
|
||||
describe('GlmtTransformer', () => {
|
||||
describe('Request transformation', () => {
|
||||
@@ -202,10 +203,9 @@ describe('GlmtTransformer', () => {
|
||||
delete process.env.CCS_DEBUG;
|
||||
});
|
||||
|
||||
it('uses getCcsDir()/logs by default', () => {
|
||||
it('uses CCS logs directory by default', () => {
|
||||
const transformer = new GlmtTransformer();
|
||||
const path = require('path');
|
||||
const { getCcsDir } = require('../../../dist/utils/config-manager');
|
||||
const expectedPath = path.join(getCcsDir(), 'logs');
|
||||
assert.strictEqual(transformer.debugLogDir, expectedPath);
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
const { getCcsDir } = require('../../../dist/utils/config-manager');
|
||||
const os = require('os');
|
||||
|
||||
describe('Beta Channel Implementation (Phase 3)', function () {
|
||||
let updateCheckerModule;
|
||||
@@ -25,6 +25,8 @@ describe('Beta Channel Implementation (Phase 3)', function () {
|
||||
let originalHttpsGet;
|
||||
let mockFileSystem = {};
|
||||
let httpsRequests = [];
|
||||
let cacheFilePath;
|
||||
let cacheDirPath;
|
||||
|
||||
beforeAll(async function () {
|
||||
// Note: Build is handled by CI before tests run (bun run build:all)
|
||||
@@ -36,6 +38,8 @@ describe('Beta Channel Implementation (Phase 3)', function () {
|
||||
// Reset mocks
|
||||
mockFileSystem = {};
|
||||
httpsRequests = [];
|
||||
cacheFilePath = undefined;
|
||||
cacheDirPath = undefined;
|
||||
|
||||
// Store original functions
|
||||
originalFsExistsSync = fs.existsSync;
|
||||
@@ -46,26 +50,44 @@ describe('Beta Channel Implementation (Phase 3)', function () {
|
||||
|
||||
// Mock fs.existsSync
|
||||
fs.existsSync = (filePath) => {
|
||||
return mockFileSystem[filePath] !== undefined;
|
||||
const key = String(filePath);
|
||||
if (key.endsWith('/update-check.json') || key.endsWith('\\update-check.json')) {
|
||||
cacheFilePath = key;
|
||||
cacheDirPath = path.dirname(key);
|
||||
}
|
||||
if (key.endsWith('/cache') || key.endsWith('\\cache')) {
|
||||
cacheDirPath = key;
|
||||
}
|
||||
return mockFileSystem[key] !== undefined;
|
||||
};
|
||||
|
||||
// Mock fs.readFileSync
|
||||
fs.readFileSync = (filePath, encoding) => {
|
||||
if (mockFileSystem[filePath]) {
|
||||
return mockFileSystem[filePath];
|
||||
const key = String(filePath);
|
||||
if (mockFileSystem[key]) {
|
||||
return mockFileSystem[key];
|
||||
}
|
||||
throw new Error(`ENOENT: no such file or directory, open '${filePath}'`);
|
||||
};
|
||||
|
||||
// Mock fs.writeFileSync
|
||||
fs.writeFileSync = (filePath, data, encoding) => {
|
||||
mockFileSystem[filePath] = data;
|
||||
const key = String(filePath);
|
||||
if (key.endsWith('/update-check.json') || key.endsWith('\\update-check.json')) {
|
||||
cacheFilePath = key;
|
||||
cacheDirPath = path.dirname(key);
|
||||
}
|
||||
mockFileSystem[key] = data;
|
||||
};
|
||||
|
||||
// Mock fs.mkdirSync
|
||||
fs.mkdirSync = (dirPath, options) => {
|
||||
const key = String(dirPath);
|
||||
if (key.endsWith('/cache') || key.endsWith('\\cache')) {
|
||||
cacheDirPath = key;
|
||||
}
|
||||
// Just mark as created for testing
|
||||
mockFileSystem[dirPath] = 'directory';
|
||||
mockFileSystem[key] = 'directory';
|
||||
};
|
||||
|
||||
// Mock https.get
|
||||
@@ -110,6 +132,9 @@ describe('Beta Channel Implementation (Phase 3)', function () {
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Force path discovery from module internals to avoid hardcoded ~/.ccs assumptions.
|
||||
updateCheckerModule.readCache();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
@@ -261,6 +286,10 @@ describe('Beta Channel Implementation (Phase 3)', function () {
|
||||
});
|
||||
|
||||
describe('checkForUpdates with targetTag parameter', function () {
|
||||
function getCacheFilePath() {
|
||||
return cacheFilePath || path.join(os.homedir(), '.ccs', 'cache', 'update-check.json');
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
// Set up a fresh cache
|
||||
const cacheData = {
|
||||
@@ -268,7 +297,7 @@ describe('Beta Channel Implementation (Phase 3)', function () {
|
||||
latest_version: null,
|
||||
dismissed_version: null
|
||||
};
|
||||
mockFileSystem[path.join(getCcsDir(), 'cache', 'update-check.json')] = JSON.stringify(cacheData);
|
||||
mockFileSystem[getCacheFilePath()] = JSON.stringify(cacheData);
|
||||
});
|
||||
|
||||
it('should use latest tag when targetTag is "latest"', async function () {
|
||||
@@ -355,7 +384,7 @@ describe('Beta Channel Implementation (Phase 3)', function () {
|
||||
await updateCheckerModule.checkForUpdates('5.4.0', true, 'npm', 'dev');
|
||||
|
||||
// Check cache was updated with dev version
|
||||
const cachePath = path.join(getCcsDir(), 'cache', 'update-check.json');
|
||||
const cachePath = getCacheFilePath();
|
||||
const cacheData = JSON.parse(mockFileSystem[cachePath]);
|
||||
|
||||
// Dev versions are now stored in dev_version field (not latest_version)
|
||||
@@ -371,7 +400,7 @@ describe('Beta Channel Implementation (Phase 3)', function () {
|
||||
dev_version: '5.5.0',
|
||||
dismissed_version: null
|
||||
};
|
||||
mockFileSystem[path.join(getCcsDir(), 'cache', 'update-check.json')] = JSON.stringify(cacheData);
|
||||
mockFileSystem[getCacheFilePath()] = JSON.stringify(cacheData);
|
||||
|
||||
// Call with force=false to use cache
|
||||
const result = await updateCheckerModule.checkForUpdates('5.4.0', false, 'npm', 'dev');
|
||||
@@ -458,9 +487,17 @@ describe('Beta Channel Implementation (Phase 3)', function () {
|
||||
});
|
||||
|
||||
describe('Cache functionality', function () {
|
||||
function getCacheFilePath() {
|
||||
return cacheFilePath || path.join(os.homedir(), '.ccs', 'cache', 'update-check.json');
|
||||
}
|
||||
|
||||
function getCacheDirPath() {
|
||||
return cacheDirPath || path.dirname(getCacheFilePath());
|
||||
}
|
||||
|
||||
it('should create cache directory if not exists', async function () {
|
||||
// Ensure no cache exists
|
||||
const cacheDir = path.join(getCcsDir(), 'cache');
|
||||
const cacheDir = getCacheDirPath();
|
||||
delete mockFileSystem[cacheDir];
|
||||
|
||||
await updateCheckerModule.checkForUpdates('5.4.0', true, 'npm', 'latest');
|
||||
@@ -477,7 +514,7 @@ describe('Beta Channel Implementation (Phase 3)', function () {
|
||||
dev_version: '5.5.0',
|
||||
dismissed_version: '5.5.0'
|
||||
};
|
||||
mockFileSystem[path.join(getCcsDir(), 'cache', 'update-check.json')] = JSON.stringify(cacheData);
|
||||
mockFileSystem[getCacheFilePath()] = JSON.stringify(cacheData);
|
||||
|
||||
const result = await updateCheckerModule.checkForUpdates('5.4.1', false, 'npm', 'dev');
|
||||
|
||||
@@ -488,7 +525,7 @@ describe('Beta Channel Implementation (Phase 3)', function () {
|
||||
|
||||
it('should handle corrupted cache gracefully', async function () {
|
||||
// Set up corrupted cache
|
||||
mockFileSystem[path.join(getCcsDir(), 'cache', 'update-check.json')] = 'invalid json';
|
||||
mockFileSystem[getCacheFilePath()] = 'invalid json';
|
||||
|
||||
// Should not throw error
|
||||
const result = await updateCheckerModule.checkForUpdates('5.4.0', true, 'npm', 'latest');
|
||||
@@ -497,4 +534,4 @@ describe('Beta Channel Implementation (Phase 3)', function () {
|
||||
assert(result.status === 'update_available' || result.status === 'no_update');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
/**
|
||||
* CLIProxy Variant Dialog Component
|
||||
* Phase 03: REST API Routes & CRUD
|
||||
* Phase 05: Dashboard UI full CRUD for composite variants
|
||||
* Phase 06: Multi-Account Support
|
||||
*/
|
||||
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||
import { usePrivacy } from '@/contexts/privacy-context';
|
||||
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
|
||||
|
||||
const schema = z.object({
|
||||
const singleProviderSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Name is required')
|
||||
@@ -25,7 +28,33 @@ const schema = z.object({
|
||||
account: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
const compositeSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Name is required')
|
||||
.regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid variant name'),
|
||||
default_tier: z.enum(['opus', 'sonnet', 'haiku'], { message: 'Default tier is required' }),
|
||||
tiers: z.object({
|
||||
opus: z.object({
|
||||
provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }),
|
||||
model: z.string().trim().min(1, 'Model is required'),
|
||||
account: z.string().optional(),
|
||||
}),
|
||||
sonnet: z.object({
|
||||
provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }),
|
||||
model: z.string().trim().min(1, 'Model is required'),
|
||||
account: z.string().optional(),
|
||||
}),
|
||||
haiku: z.object({
|
||||
provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }),
|
||||
model: z.string().trim().min(1, 'Model is required'),
|
||||
account: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
type SingleProviderFormData = z.infer<typeof singleProviderSchema>;
|
||||
type CompositeFormData = z.infer<typeof compositeSchema>;
|
||||
|
||||
interface CliproxyDialogProps {
|
||||
open: boolean;
|
||||
@@ -41,112 +70,235 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
||||
const createMutation = useCreateVariant();
|
||||
const { data: authData } = useCliproxyAuth();
|
||||
const { privacyMode } = usePrivacy();
|
||||
const [mode, setMode] = useState<'single' | 'composite'>('single');
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors },
|
||||
reset,
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
const singleForm = useForm<SingleProviderFormData>({
|
||||
resolver: zodResolver(singleProviderSchema),
|
||||
});
|
||||
|
||||
// Watch provider to show relevant accounts
|
||||
const selectedProvider = useWatch({ control, name: 'provider' });
|
||||
const compositeForm = useForm<CompositeFormData>({
|
||||
resolver: zodResolver(compositeSchema),
|
||||
defaultValues: {
|
||||
default_tier: 'opus',
|
||||
tiers: {
|
||||
opus: { provider: 'gemini', model: '' },
|
||||
sonnet: { provider: 'gemini', model: '' },
|
||||
haiku: { provider: 'gemini', model: '' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Get accounts for selected provider
|
||||
const selectedProvider = useWatch({ control: singleForm.control, name: 'provider' });
|
||||
const providerAuth = authData?.authStatus.find((s) => s.provider === selectedProvider);
|
||||
const providerAccounts = providerAuth?.accounts || [];
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
const onSubmitSingle = async (data: SingleProviderFormData) => {
|
||||
try {
|
||||
await createMutation.mutateAsync(data);
|
||||
reset();
|
||||
singleForm.reset();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to create variant:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmitComposite = async (data: CompositeFormData) => {
|
||||
try {
|
||||
await createMutation.mutateAsync({
|
||||
name: data.name,
|
||||
provider: data.tiers[data.default_tier].provider,
|
||||
type: 'composite',
|
||||
default_tier: data.default_tier,
|
||||
tiers: data.tiers,
|
||||
});
|
||||
compositeForm.reset();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to create composite variant:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create CLIProxy Variant</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" {...register('name')} placeholder="my-gemini" />
|
||||
{errors.name && <span className="text-xs text-red-500">{errors.name.message}</span>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="provider">Provider</Label>
|
||||
<select
|
||||
id="provider"
|
||||
{...register('provider')}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={mode === 'single' ? 'default' : 'outline'}
|
||||
onClick={() => setMode('single')}
|
||||
>
|
||||
<option value="">Select provider...</option>
|
||||
{providerOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.provider && (
|
||||
<span className="text-xs text-red-500">{errors.provider.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Account selector - only show if provider has accounts */}
|
||||
{selectedProvider && providerAccounts.length > 0 && (
|
||||
<div>
|
||||
<Label htmlFor="account">Account</Label>
|
||||
<select
|
||||
id="account"
|
||||
{...register('account')}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">Use default account</option>
|
||||
{providerAccounts.map((acc) => (
|
||||
<option key={acc.id} value={acc.id}>
|
||||
{privacyMode ? '••••••' : acc.email || acc.id}
|
||||
{acc.isDefault ? ' (default)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-muted-foreground mt-1 block">
|
||||
Select which OAuth account this variant should use
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show message if provider selected but no accounts */}
|
||||
{selectedProvider && providerAccounts.length === 0 && providerAuth && (
|
||||
<div className="text-sm text-amber-600 bg-amber-50 dark:bg-amber-950/20 p-3 rounded-md">
|
||||
No accounts authenticated for {providerAuth.displayName}.
|
||||
<br />
|
||||
<code className="text-xs bg-muted px-1 rounded">ccs {selectedProvider} --auth</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="model">Model (optional)</Label>
|
||||
<Input id="model" {...register('model')} placeholder="gemini-2.5-pro" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
Single Provider
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Creating...' : 'Create'}
|
||||
<Button
|
||||
type="button"
|
||||
variant={mode === 'composite' ? 'default' : 'outline'}
|
||||
onClick={() => setMode('composite')}
|
||||
>
|
||||
Composite (Multi-Provider)
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{mode === 'single' ? (
|
||||
<form onSubmit={singleForm.handleSubmit(onSubmitSingle)} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" {...singleForm.register('name')} placeholder="my-gemini" />
|
||||
{singleForm.formState.errors.name && (
|
||||
<span className="text-xs text-red-500">
|
||||
{singleForm.formState.errors.name.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="provider">Provider</Label>
|
||||
<select
|
||||
id="provider"
|
||||
{...singleForm.register('provider')}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">Select provider...</option>
|
||||
{providerOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{singleForm.formState.errors.provider && (
|
||||
<span className="text-xs text-red-500">
|
||||
{singleForm.formState.errors.provider.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedProvider && providerAccounts.length > 0 && (
|
||||
<div>
|
||||
<Label htmlFor="account">Account</Label>
|
||||
<select
|
||||
id="account"
|
||||
{...singleForm.register('account')}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">Use default account</option>
|
||||
{providerAccounts.map((acc) => (
|
||||
<option key={acc.id} value={acc.id}>
|
||||
{privacyMode ? '••••••' : acc.email || acc.id}
|
||||
{acc.isDefault ? ' (default)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="model">Model (optional)</Label>
|
||||
<Input id="model" {...singleForm.register('model')} placeholder="gemini-2.5-pro" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={compositeForm.handleSubmit(onSubmitComposite)} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="comp-name">Name</Label>
|
||||
<Input
|
||||
id="comp-name"
|
||||
{...compositeForm.register('name')}
|
||||
placeholder="my-composite"
|
||||
/>
|
||||
{compositeForm.formState.errors.name && (
|
||||
<span className="text-xs text-red-500">
|
||||
{compositeForm.formState.errors.name.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Tier Configuration</Label>
|
||||
<Tabs defaultValue="opus" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="opus">Opus</TabsTrigger>
|
||||
<TabsTrigger value="sonnet">Sonnet</TabsTrigger>
|
||||
<TabsTrigger value="haiku">Haiku</TabsTrigger>
|
||||
</TabsList>
|
||||
{(['opus', 'sonnet', 'haiku'] as const).map((tier) => (
|
||||
<TabsContent key={tier} value={tier} className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor={`${tier}-provider`}>Provider</Label>
|
||||
<select
|
||||
id={`${tier}-provider`}
|
||||
{...compositeForm.register(`tiers.${tier}.provider`)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
{providerOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`${tier}-model`}>Model</Label>
|
||||
<Input
|
||||
id={`${tier}-model`}
|
||||
{...compositeForm.register(`tiers.${tier}.model`)}
|
||||
placeholder="model-id"
|
||||
/>
|
||||
{compositeForm.formState.errors.tiers?.[tier]?.model && (
|
||||
<span className="text-xs text-red-500">
|
||||
{compositeForm.formState.errors.tiers[tier]?.model?.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`${tier}-account`}>Account (optional)</Label>
|
||||
<Input
|
||||
id={`${tier}-account`}
|
||||
{...compositeForm.register(`tiers.${tier}.account`)}
|
||||
placeholder="account-id"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="default-tier">Default Tier</Label>
|
||||
<select
|
||||
id="default-tier"
|
||||
{...compositeForm.register('default_tier')}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="opus">Opus</option>
|
||||
<option value="sonnet">Sonnet</option>
|
||||
<option value="haiku">Haiku</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* CLIProxy Variant Edit Dialog Component
|
||||
* Phase 05: Dashboard UI full CRUD for composite variants
|
||||
*/
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { useUpdateVariant } from '@/hooks/use-cliproxy';
|
||||
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
|
||||
import type { Variant } from '@/lib/api-client';
|
||||
|
||||
const singleProviderSchema = z.object({
|
||||
provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }),
|
||||
model: z.string().optional(),
|
||||
account: z.string().optional(),
|
||||
});
|
||||
|
||||
const compositeSchema = z.object({
|
||||
default_tier: z.enum(['opus', 'sonnet', 'haiku'], { message: 'Default tier is required' }),
|
||||
tiers: z.object({
|
||||
opus: z.object({
|
||||
provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }),
|
||||
model: z.string().trim().min(1, 'Model is required'),
|
||||
account: z.string().optional(),
|
||||
}),
|
||||
sonnet: z.object({
|
||||
provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }),
|
||||
model: z.string().trim().min(1, 'Model is required'),
|
||||
account: z.string().optional(),
|
||||
}),
|
||||
haiku: z.object({
|
||||
provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }),
|
||||
model: z.string().trim().min(1, 'Model is required'),
|
||||
account: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
type SingleProviderFormData = z.infer<typeof singleProviderSchema>;
|
||||
type CompositeFormData = z.infer<typeof compositeSchema>;
|
||||
|
||||
interface CliproxyEditDialogProps {
|
||||
variant: Variant | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const providerOptions = CLIPROXY_PROVIDERS.map((id) => ({
|
||||
value: id,
|
||||
label: getProviderDisplayName(id),
|
||||
}));
|
||||
|
||||
export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEditDialogProps) {
|
||||
const updateMutation = useUpdateVariant();
|
||||
const isComposite = variant?.type === 'composite';
|
||||
|
||||
const singleForm = useForm<SingleProviderFormData>({
|
||||
resolver: zodResolver(singleProviderSchema),
|
||||
});
|
||||
|
||||
const compositeForm = useForm<CompositeFormData>({
|
||||
resolver: zodResolver(compositeSchema),
|
||||
});
|
||||
|
||||
// Pre-populate form when variant changes
|
||||
useEffect(() => {
|
||||
if (!variant) return;
|
||||
|
||||
if (isComposite && variant.tiers && variant.default_tier) {
|
||||
const mapTier = (t: { provider: string; model: string; account?: string }) => ({
|
||||
provider: t.provider as (typeof CLIPROXY_PROVIDERS)[number],
|
||||
model: t.model,
|
||||
account: t.account || '',
|
||||
});
|
||||
compositeForm.reset({
|
||||
default_tier: variant.default_tier,
|
||||
tiers: {
|
||||
opus: mapTier(variant.tiers.opus),
|
||||
sonnet: mapTier(variant.tiers.sonnet),
|
||||
haiku: mapTier(variant.tiers.haiku),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
singleForm.reset({
|
||||
provider: variant.provider,
|
||||
model: variant.model ?? undefined,
|
||||
account: variant.account ?? undefined,
|
||||
});
|
||||
}
|
||||
}, [variant, isComposite, singleForm, compositeForm]);
|
||||
|
||||
const onSubmitSingle = async (data: SingleProviderFormData) => {
|
||||
if (!variant) return;
|
||||
// Filter out undefined values - backend interprets undefined as "no change"
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(data).filter(([, v]) => v !== undefined && v !== '')
|
||||
) as SingleProviderFormData;
|
||||
try {
|
||||
await updateMutation.mutateAsync({ name: variant.name, data: payload });
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to update variant:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmitComposite = async (data: CompositeFormData) => {
|
||||
if (!variant) return;
|
||||
try {
|
||||
await updateMutation.mutateAsync({
|
||||
name: variant.name,
|
||||
data: {
|
||||
default_tier: data.default_tier,
|
||||
tiers: data.tiers,
|
||||
},
|
||||
});
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to update composite variant:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!variant) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Edit {isComposite ? 'Composite' : 'Single'} Variant: {variant.name}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isComposite ? (
|
||||
<form onSubmit={compositeForm.handleSubmit(onSubmitComposite)} className="space-y-4">
|
||||
<div>
|
||||
<Label>Tier Configuration</Label>
|
||||
<Tabs defaultValue="opus" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="opus">Opus</TabsTrigger>
|
||||
<TabsTrigger value="sonnet">Sonnet</TabsTrigger>
|
||||
<TabsTrigger value="haiku">Haiku</TabsTrigger>
|
||||
</TabsList>
|
||||
{(['opus', 'sonnet', 'haiku'] as const).map((tier) => (
|
||||
<TabsContent key={tier} value={tier} className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor={`edit-${tier}-provider`}>Provider</Label>
|
||||
<select
|
||||
id={`edit-${tier}-provider`}
|
||||
{...compositeForm.register(`tiers.${tier}.provider`)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
{providerOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`edit-${tier}-model`}>Model</Label>
|
||||
<Input
|
||||
id={`edit-${tier}-model`}
|
||||
{...compositeForm.register(`tiers.${tier}.model`)}
|
||||
placeholder="model-id"
|
||||
/>
|
||||
{compositeForm.formState.errors.tiers?.[tier]?.model && (
|
||||
<span className="text-xs text-red-500">
|
||||
{compositeForm.formState.errors.tiers[tier]?.model?.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`edit-${tier}-account`}>Account (optional)</Label>
|
||||
<Input
|
||||
id={`edit-${tier}-account`}
|
||||
{...compositeForm.register(`tiers.${tier}.account`)}
|
||||
placeholder="account-id"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="edit-default-tier">Default Tier</Label>
|
||||
<select
|
||||
id="edit-default-tier"
|
||||
{...compositeForm.register('default_tier')}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="opus">Opus</option>
|
||||
<option value="sonnet">Sonnet</option>
|
||||
<option value="haiku">Haiku</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={singleForm.handleSubmit(onSubmitSingle)} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="edit-provider">Provider</Label>
|
||||
<select
|
||||
id="edit-provider"
|
||||
{...singleForm.register('provider')}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
{providerOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="edit-model">Model</Label>
|
||||
<Input id="edit-model" {...singleForm.register('model')} placeholder="model-id" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="edit-account">Account (optional)</Label>
|
||||
<Input
|
||||
id="edit-account"
|
||||
{...singleForm.register('account')}
|
||||
placeholder="account-id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* CLIProxy Variants Table Component
|
||||
* Phase 03: REST API Routes & CRUD
|
||||
* Phase 05: Dashboard UI full CRUD for composite variants
|
||||
* Phase 06: Multi-Account Support
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table';
|
||||
import {
|
||||
Table,
|
||||
@@ -21,8 +23,9 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { MoreHorizontal, Trash2, User } from 'lucide-react';
|
||||
import { MoreHorizontal, Trash2, User, Pencil } from 'lucide-react';
|
||||
import { useDeleteVariant } from '@/hooks/use-cliproxy';
|
||||
import { CliproxyEditDialog } from './cliproxy-edit-dialog';
|
||||
import type { Variant } from '@/lib/api-client';
|
||||
|
||||
interface CliproxyTableProps {
|
||||
@@ -41,6 +44,7 @@ const providerLabels: Record<string, string> = {
|
||||
|
||||
export function CliproxyTable({ data }: CliproxyTableProps) {
|
||||
const deleteMutation = useDeleteVariant();
|
||||
const [editingVariant, setEditingVariant] = useState<Variant | null>(null);
|
||||
|
||||
const columns: ColumnDef<Variant>[] = [
|
||||
{
|
||||
@@ -51,7 +55,12 @@ export function CliproxyTable({ data }: CliproxyTableProps) {
|
||||
{
|
||||
accessorKey: 'provider',
|
||||
header: 'Provider',
|
||||
cell: ({ row }) => providerLabels[row.original.provider] || row.original.provider,
|
||||
cell: ({ row }) => {
|
||||
if (row.original.type === 'composite') {
|
||||
return <Badge variant="secondary">composite</Badge>;
|
||||
}
|
||||
return providerLabels[row.original.provider] || row.original.provider;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'account',
|
||||
@@ -91,6 +100,10 @@ export function CliproxyTable({ data }: CliproxyTableProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="bg-white dark:bg-zinc-950">
|
||||
<DropdownMenuItem onClick={() => setEditingVariant(row.original)}>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-red-600 focus:text-red-600 focus:bg-red-50 dark:focus:bg-red-950/30"
|
||||
onClick={() => deleteMutation.mutate(row.original.name)}
|
||||
@@ -124,33 +137,40 @@ export function CliproxyTable({ data }: CliproxyTableProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-md overflow-hidden bg-card">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<>
|
||||
<CliproxyEditDialog
|
||||
variant={editingVariant}
|
||||
open={!!editingVariant}
|
||||
onOpenChange={(open) => !open && setEditingVariant(null)}
|
||||
/>
|
||||
<div className="border rounded-md overflow-hidden bg-card">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,13 @@ export interface Variant {
|
||||
account?: string;
|
||||
port?: number;
|
||||
model?: string;
|
||||
type?: 'composite';
|
||||
default_tier?: 'opus' | 'sonnet' | 'haiku';
|
||||
tiers?: {
|
||||
opus: { provider: string; model: string; account?: string; thinking?: string };
|
||||
sonnet: { provider: string; model: string; account?: string; thinking?: string };
|
||||
haiku: { provider: string; model: string; account?: string; thinking?: string };
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateVariant {
|
||||
@@ -61,12 +68,26 @@ export interface CreateVariant {
|
||||
provider: CLIProxyProvider;
|
||||
model?: string;
|
||||
account?: string;
|
||||
type?: 'composite';
|
||||
default_tier?: 'opus' | 'sonnet' | 'haiku';
|
||||
tiers?: {
|
||||
opus: { provider: string; model: string; account?: string; thinking?: string };
|
||||
sonnet: { provider: string; model: string; account?: string; thinking?: string };
|
||||
haiku: { provider: string; model: string; account?: string; thinking?: string };
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateVariant {
|
||||
provider?: CLIProxyProvider;
|
||||
model?: string;
|
||||
account?: string;
|
||||
type?: 'composite';
|
||||
default_tier?: 'opus' | 'sonnet' | 'haiku';
|
||||
tiers?: {
|
||||
opus: { provider: string; model: string; account?: string; thinking?: string };
|
||||
sonnet: { provider: string; model: string; account?: string; thinking?: string };
|
||||
haiku: { provider: string; model: string; account?: string; thinking?: string };
|
||||
};
|
||||
}
|
||||
|
||||
/** OAuth account info for multi-account support */
|
||||
|
||||
Reference in New Issue
Block a user