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:
Kai (Tam Nhu) Tran
2026-02-13 08:44:41 +07:00
committed by GitHub
36 changed files with 4193 additions and 262 deletions
+5 -5
View File
@@ -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
}
+5 -1
View File
@@ -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 || '-',
});
}
+59 -5
View File
@@ -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})`);
});
}
+4
View File
@@ -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
+118
View File
@@ -0,0 +1,118 @@
/**
* Shared validation helpers for composite CLIProxy variants.
* Used by API routes, service layer, and config loader to avoid contract drift.
*/
import { CLIPROXY_SUPPORTED_PROVIDERS, CompositeTierConfig } from '../config/unified-config-types';
import type { CLIProxyProvider } from './types';
export const VALID_COMPOSITE_TIERS = ['opus', 'sonnet', 'haiku'] as const;
export type CompositeTierName = (typeof VALID_COMPOSITE_TIERS)[number];
interface CompositeValidationOptions {
defaultTier?: unknown;
requireAllTiers?: boolean;
}
type CompositeTierInput = Partial<Record<CompositeTierName, CompositeTierConfig>>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isValidProvider(provider: unknown): provider is CLIProxyProvider {
return (
typeof provider === 'string' &&
CLIPROXY_SUPPORTED_PROVIDERS.includes(provider as CLIProxyProvider)
);
}
export function validateCompositeDefaultTier(defaultTier: unknown): string | null {
if (
defaultTier !== undefined &&
!VALID_COMPOSITE_TIERS.includes(defaultTier as CompositeTierName)
) {
return `Invalid default_tier '${String(defaultTier)}': must be one of ${VALID_COMPOSITE_TIERS.join(', ')}`;
}
return null;
}
/**
* Validate composite tier payload.
*
* Create mode (`requireAllTiers=true`): all tiers required.
* Update mode (`requireAllTiers=false`): partial tiers allowed.
*/
export function validateCompositeTiers(
tiers: unknown,
options: CompositeValidationOptions = {}
): string | null {
const { defaultTier, requireAllTiers = false } = options;
const defaultTierError = validateCompositeDefaultTier(defaultTier);
if (defaultTierError) {
return defaultTierError;
}
if (!isRecord(tiers)) {
return "Invalid tiers payload: expected object with tier keys ('opus', 'sonnet', 'haiku')";
}
const tierMap = tiers as CompositeTierInput;
for (const tier of VALID_COMPOSITE_TIERS) {
const tierValue = tierMap[tier];
if (requireAllTiers && tierValue === undefined) {
return `Missing required tier '${tier}': all tiers (opus, sonnet, haiku) required for create`;
}
if (tierValue === undefined) {
continue;
}
if (!isRecord(tierValue)) {
return `Invalid tier config for '${tier}': expected object with provider and model`;
}
const provider = tierValue.provider;
const model = tierValue.model;
if (typeof provider !== 'string' || typeof model !== 'string') {
return `Invalid tier config for '${tier}': requires 'provider' and 'model' strings`;
}
if (!model.trim()) {
return `Invalid model for tier '${tier}': model cannot be empty or whitespace`;
}
if (!isValidProvider(provider)) {
return `Invalid provider '${provider}' for tier '${tier}': must be one of ${CLIPROXY_SUPPORTED_PROVIDERS.join(', ')}`;
}
if (tierValue.fallback !== undefined) {
const fallback = tierValue.fallback;
if (!isRecord(fallback)) {
return `Invalid fallback config for tier '${tier}': expected object with provider and model`;
}
if (typeof fallback.provider !== 'string' || typeof fallback.model !== 'string') {
return `Invalid fallback config for tier '${tier}': requires 'provider' and 'model' strings`;
}
if (!fallback.model.trim()) {
return `Invalid fallback model for tier '${tier}': model cannot be empty or whitespace`;
}
if (!isValidProvider(fallback.provider)) {
return `Invalid fallback provider '${fallback.provider}' for tier '${tier}': must be one of ${CLIPROXY_SUPPORTED_PROVIDERS.join(', ')}`;
}
if (fallback.provider === provider && fallback.model === model) {
return `Circular fallback in tier '${tier}': fallback cannot point to same provider and model`;
}
}
}
return null;
}
+100
View File
@@ -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;
}
+81 -12
View File
@@ -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);
}
}
+124 -33
View File
@@ -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
View File
@@ -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);
+32
View File
@@ -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));
}
+4
View File
@@ -9,9 +9,13 @@ export {
variantExists,
listVariants,
createVariant,
createCompositeVariant,
updateCompositeVariant,
removeVariant,
type VariantConfig,
type VariantOperationResult,
type CreateCompositeVariantOptions,
type UpdateCompositeVariantOptions,
} from './variant-service';
// Proxy lifecycle
+115 -10
View File
@@ -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,
};
}
/**
+228 -10
View File
@@ -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 };
}
}
+178 -4
View File
@@ -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);
}
+14
View File
@@ -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;
}
/**
+2 -1
View File
@@ -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] })
+2
View File
@@ -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'],
],
+6 -1
View File
@@ -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;
+363 -2
View File
@@ -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('');
}
+26 -1
View File
@@ -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
+63 -3
View File
@@ -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,
+1
View File
@@ -12,6 +12,7 @@ export type {
EnvValue,
ProfileMetadata,
ProfilesRegistry,
CLIProxyVariantConfig,
CLIProxyVariantsConfig,
} from './config';
export { isConfig, isSettings } from './config';
+24 -7
View File
@@ -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,
};
}
}
}
+123 -6
View File
@@ -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');
});
});
+2 -2
View File
@@ -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');
});
});
});
});
+235 -83
View File
@@ -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>
);
}
+50 -30
View File
@@ -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>
</>
);
}
+21
View File
@@ -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 */