diff --git a/src/api/services/index.ts b/src/api/services/index.ts index c6ee337e..bdb07b20 100644 --- a/src/api/services/index.ts +++ b/src/api/services/index.ts @@ -36,9 +36,12 @@ export { pickOpenRouterModel, type OpenRouterSelection } from './openrouter-pick // Provider presets for CLI export { PROVIDER_PRESETS, + PRESET_ALIASES, OPENROUTER_BASE_URL, getPresetById, + getPresetAliases, getPresetIds, isValidPresetId, type ProviderPreset, + type PresetCategory, } from './provider-presets'; diff --git a/src/api/services/provider-presets.ts b/src/api/services/provider-presets.ts index 80038aa3..d1404020 100644 --- a/src/api/services/provider-presets.ts +++ b/src/api/services/provider-presets.ts @@ -2,177 +2,33 @@ * Provider Presets for CLI * * Pre-configured templates for common API providers. - * Mirrors the UI presets in ui/src/lib/provider-presets.ts + * Uses shared source-of-truth catalog in src/shared/provider-preset-catalog.ts. */ -import { resolveAliasToCanonical } from '../../utils/profile-compat'; +import { + OPENROUTER_BASE_URL, + PROVIDER_PRESET_ALIASES, + createProviderPresetDefinitions, + normalizeProviderPresetId, + type PresetCategory, + type ProviderPresetDefinition, +} from '../../shared/provider-preset-catalog'; -export type PresetCategory = 'recommended' | 'alternative'; - -export interface ProviderPreset { - id: string; - name: string; - description: string; - baseUrl: string; - defaultProfileName: string; - defaultModel: string; - apiKeyPlaceholder: string; - apiKeyHint: string; - category: PresetCategory; - /** Whether API key is required (default: true, set false for local providers) */ - requiresApiKey: boolean; - /** Additional env vars for thinking mode, etc. */ - extraEnv?: Record; - /** Enable always thinking mode */ - alwaysThinkingEnabled?: boolean; -} - -export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api'; +export { OPENROUTER_BASE_URL }; +export type { PresetCategory }; +export type ProviderPreset = ProviderPresetDefinition; /** * Provider presets available via CLI and UI - * - * NOTE: Keep in sync with ui/src/lib/provider-presets.ts */ -export const PROVIDER_PRESETS: ProviderPreset[] = [ - // Recommended - { - id: 'openrouter', - name: 'OpenRouter', - description: '349+ models from OpenAI, Anthropic, Google, Meta', - baseUrl: OPENROUTER_BASE_URL, - defaultProfileName: 'openrouter', - defaultModel: 'anthropic/claude-opus-4.5', - apiKeyPlaceholder: 'sk-or-...', - apiKeyHint: 'Get your API key at openrouter.ai/keys', - category: 'recommended', - requiresApiKey: true, - }, - { - id: 'ollama', - name: 'Ollama (Local)', - description: 'Local open-source models via Ollama (32K+ context)', - baseUrl: 'http://localhost:11434', - defaultProfileName: 'ollama', - defaultModel: 'qwen3-coder', - apiKeyPlaceholder: 'ollama', - apiKeyHint: 'Install Ollama from ollama.com - no API key needed for local', - category: 'recommended', - requiresApiKey: false, - }, - // Alternative providers - { - id: 'glm', - name: 'GLM', - description: 'Claude via Z.AI', - baseUrl: 'https://api.z.ai/api/anthropic', - defaultProfileName: 'glm', - defaultModel: 'glm-5', - apiKeyPlaceholder: 'ghp_...', - apiKeyHint: 'Get your API key from Z.AI', - category: 'alternative', - requiresApiKey: true, - }, - { - id: 'glmt', - name: 'GLMT', - description: 'GLM with Thinking mode support', - baseUrl: 'https://api.z.ai/api/coding/paas/v4/chat/completions', - defaultProfileName: 'glmt', - defaultModel: 'glm-5', - apiKeyPlaceholder: 'ghp_...', - apiKeyHint: 'Same API key as GLM', - category: 'alternative', - requiresApiKey: true, - extraEnv: { - ANTHROPIC_TEMPERATURE: '0.2', - ANTHROPIC_MAX_TOKENS: '65536', - MAX_THINKING_TOKENS: '32768', - ENABLE_STREAMING: 'true', - ANTHROPIC_SAFE_MODE: 'false', - API_TIMEOUT_MS: '3000000', - }, - alwaysThinkingEnabled: true, - }, - { - id: 'km', - name: 'Kimi', - description: 'Moonshot AI - Fast reasoning model', - baseUrl: 'https://api.kimi.com/coding/', - defaultProfileName: 'km', - defaultModel: 'kimi-k2-thinking-turbo', - apiKeyPlaceholder: 'sk-...', - apiKeyHint: 'Get your API key from Moonshot AI', - category: 'alternative', - requiresApiKey: true, - alwaysThinkingEnabled: true, - }, - { - id: 'foundry', - name: 'Azure Foundry', - description: 'Claude via Microsoft Azure AI Foundry', - baseUrl: 'https://.services.ai.azure.com/api/anthropic', - defaultProfileName: 'foundry', - defaultModel: 'claude-sonnet-4-5', - apiKeyPlaceholder: 'YOUR_AZURE_API_KEY', - apiKeyHint: 'Create resource at ai.azure.com, get API key from Keys tab', - category: 'alternative', - requiresApiKey: true, - }, - { - id: 'mm', - name: 'Minimax', - description: 'M2.1/M2.1-lightning/M2 - multilang coding (1M context)', - baseUrl: 'https://api.minimax.io/anthropic', - defaultProfileName: 'mm', - defaultModel: 'MiniMax-M2.1', - apiKeyPlaceholder: 'YOUR_MINIMAX_API_KEY_HERE', - apiKeyHint: 'Get your API key at platform.minimax.io', - category: 'alternative', - requiresApiKey: true, - }, - { - id: 'deepseek', - name: 'DeepSeek', - description: 'V3.2 and R1 reasoning model (128K context)', - baseUrl: 'https://api.deepseek.com/anthropic', - defaultProfileName: 'deepseek', - defaultModel: 'deepseek-chat', - apiKeyPlaceholder: 'sk-...', - apiKeyHint: 'Get your API key at platform.deepseek.com', - category: 'alternative', - requiresApiKey: true, - }, - { - id: 'qwen', - name: 'Qwen', - description: 'Alibaba Cloud - Qwen3 models (256K-1M context, thinking support)', - baseUrl: 'https://dashscope-intl.aliyuncs.com/apps/anthropic', - defaultProfileName: 'qwen', - defaultModel: 'qwen3-coder-plus', - apiKeyPlaceholder: 'sk-...', - apiKeyHint: 'Get your API key from Alibaba Cloud Model Studio', - category: 'alternative', - requiresApiKey: true, - }, - { - id: 'ollama-cloud', - name: 'Ollama Cloud', - description: 'Ollama cloud models via direct API (glm-5:cloud, minimax-m2.1:cloud)', - baseUrl: 'https://ollama.com', - defaultProfileName: 'ollama-cloud', - defaultModel: 'glm-5:cloud', - apiKeyPlaceholder: 'YOUR_OLLAMA_CLOUD_API_KEY', - apiKeyHint: 'Get your API key at ollama.com', - category: 'alternative', - requiresApiKey: true, - }, -]; +export const PROVIDER_PRESETS: readonly ProviderPreset[] = Object.freeze( + createProviderPresetDefinitions() +); +export const PRESET_ALIASES: Readonly> = PROVIDER_PRESET_ALIASES; /** Get preset by ID */ export function getPresetById(id: string): ProviderPreset | undefined { - const normalized = id.trim().toLowerCase(); - const canonical = resolveAliasToCanonical(normalized).toLowerCase(); + const canonical = normalizeProviderPresetId(id); return PROVIDER_PRESETS.find((p) => p.id === canonical); } @@ -181,6 +37,11 @@ export function getPresetIds(): string[] { return PROVIDER_PRESETS.map((p) => p.id); } +/** Get alias map (alias -> canonical preset ID). */ +export function getPresetAliases(): Readonly> { + return PRESET_ALIASES; +} + /** Check if preset ID is valid */ export function isValidPresetId(id: string): boolean { return getPresetById(id) !== undefined; diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index 15df8b61..cdd8c817 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -35,9 +35,12 @@ import { isUsingUnifiedConfig, isOpenRouterUrl, pickOpenRouterModel, + PROVIDER_PRESETS, getPresetById, + getPresetAliases, getPresetIds, type ModelMapping, + type ProviderPreset, } from '../api/services'; import { syncToLocalConfig } from '../cliproxy/sync/local-config-sync'; @@ -51,6 +54,22 @@ interface ApiCommandArgs { yes?: boolean; } +function sanitizeHelpText(value: string): string { + return value + .replace(/[\r\n\t]+/g, ' ') + .replace(/[\x00-\x1f\x7f]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function renderPresetHelpLine(preset: ProviderPreset, idWidth: number): string { + const presetId = sanitizeHelpText(preset.id) || 'unknown'; + const paddedId = presetId.padEnd(idWidth); + const presetName = sanitizeHelpText(preset.name) || 'Unknown preset'; + const presetDescription = sanitizeHelpText(preset.description) || 'No description'; + return ` ${color(paddedId, 'command')} ${presetName} - ${presetDescription}`; +} + /** Parse command line arguments for api commands */ function parseArgs(args: string[]): ApiCommandArgs { const result: ApiCommandArgs = {}; @@ -92,7 +111,7 @@ async function handleCreate(args: string[]): Promise { console.log(fail(`Unknown preset: ${parsedArgs.preset}`)); console.log(''); console.log('Available presets:'); - getPresetIds().forEach((id) => console.log(` - ${id}`)); + getPresetIds().forEach((id) => console.log(` - ${sanitizeHelpText(id)}`)); process.exit(1); } @@ -434,6 +453,11 @@ async function handleRemove(args: string[]): Promise { /** Show help for api commands */ async function showHelp(): Promise { await initUI(); + const presetIds = getPresetIds() + .map((id) => sanitizeHelpText(id)) + .filter(Boolean); + const presetAliases = getPresetAliases(); + const presetIdWidth = Math.max(0, ...presetIds.map((id) => id.length)) + 2; console.log(header('CCS API Management')); console.log(''); @@ -447,7 +471,7 @@ async function showHelp(): Promise { console.log(''); console.log(subheader('Options')); console.log( - ` ${color('--preset ', 'command')} Use provider preset (openrouter, ollama, ollama-cloud, glm, glmt, km, foundry, mm, deepseek, qwen)` + ` ${color('--preset ', 'command')} Use provider preset (${presetIds.join(', ')})` ); console.log(` ${color('--base-url ', 'command')} API base URL (create)`); console.log(` ${color('--api-key ', 'command')} API key (create)`); @@ -456,25 +480,14 @@ async function showHelp(): Promise { console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts`); console.log(''); console.log(subheader('Provider Presets')); - console.log( - ` ${color('openrouter', 'command')} OpenRouter - 349+ models (Claude, GPT, Gemini, Llama)` - ); - console.log( - ` ${color('ollama', 'command')} Ollama - Local open-source models (no API key)` - ); - console.log( - ` ${color('ollama-cloud', 'command')} Ollama Cloud - glm-5:cloud, qwen3-coder:480b` - ); - console.log(` ${color('glm', 'command')} GLM - Claude via Z.AI`); - console.log(` ${color('glmt', 'command')} GLMT - GLM with Thinking mode`); - console.log(` ${color('km', 'command')} Kimi - Moonshot AI reasoning model`); - console.log(` ${dim(' Legacy alias: --preset kimi (auto-mapped to km)')}`); - console.log(` ${color('foundry', 'command')} Azure Foundry - Claude via Microsoft Azure`); - console.log(` ${color('mm', 'command')} Minimax - M2 series with 1M context`); - console.log(` ${color('deepseek', 'command')} DeepSeek - V3.2 and R1 reasoning (128K)`); - console.log( - ` ${color('qwen', 'command')} Qwen - Alibaba Cloud qwen3-coder-plus (256K)` - ); + PROVIDER_PRESETS.forEach((preset) => console.log(renderPresetHelpLine(preset, presetIdWidth))); + Object.entries(presetAliases).forEach(([alias, canonical]) => { + const safeAlias = sanitizeHelpText(alias); + const safeCanonical = sanitizeHelpText(canonical); + console.log( + ` ${dim(`Legacy alias: --preset ${safeAlias} (auto-mapped to ${safeCanonical})`)}` + ); + }); console.log(''); console.log(subheader('Examples')); console.log(` ${dim('# Interactive wizard')}`); diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts new file mode 100644 index 00000000..b6c017e9 --- /dev/null +++ b/src/shared/provider-preset-catalog.ts @@ -0,0 +1,280 @@ +/** + * Shared provider preset catalog for CLI + Dashboard. + * + * Keep this file runtime-agnostic (no Node/browser APIs) so both + * backend and UI can import the same source of truth. + */ + +export type PresetCategory = 'recommended' | 'alternative'; + +export const PROVIDER_PRESET_IDS = [ + 'openrouter', + 'ollama', + 'glm', + 'glmt', + 'km', + 'foundry', + 'mm', + 'deepseek', + 'qwen', + 'ollama-cloud', +] as const; + +export type ProviderPresetId = (typeof PROVIDER_PRESET_IDS)[number]; + +export interface ProviderPresetDefinition { + id: ProviderPresetId; + name: string; + description: string; + baseUrl: string; + defaultProfileName: string; + defaultModel: string; + apiKeyPlaceholder: string; + apiKeyHint: string; + category: PresetCategory; + requiresApiKey: boolean; + /** Additional env vars for thinking mode, etc. */ + extraEnv?: Record; + /** Enable always thinking mode. */ + alwaysThinkingEnabled?: boolean; + /** UI metadata */ + badge?: string; + featured?: boolean; + icon?: string; +} + +export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api'; + +/** + * Legacy aliases mapped to canonical preset IDs. + * Keep this minimal and explicit to avoid hidden implicit behavior. + */ +export const PROVIDER_PRESET_ALIASES: Readonly> = Object.freeze({ + kimi: 'km', +}); + +const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [ + { + id: 'openrouter', + name: 'OpenRouter', + description: '349+ models from OpenAI, Anthropic, Google, Meta', + baseUrl: OPENROUTER_BASE_URL, + defaultProfileName: 'openrouter', + defaultModel: 'anthropic/claude-opus-4.5', + apiKeyPlaceholder: 'sk-or-...', + apiKeyHint: 'Get your API key at openrouter.ai/keys', + category: 'recommended', + requiresApiKey: true, + badge: '349+ models', + featured: true, + icon: '/icons/openrouter.svg', + }, + { + id: 'ollama', + name: 'Ollama (Local)', + description: 'Local open-source models via Ollama (32K+ context)', + baseUrl: 'http://localhost:11434', + defaultProfileName: 'ollama', + defaultModel: 'qwen3-coder', + apiKeyPlaceholder: 'ollama', + apiKeyHint: 'Install Ollama from ollama.com - no API key needed for local', + category: 'recommended', + requiresApiKey: false, + badge: 'Local', + featured: true, + icon: '/icons/ollama.svg', + }, + { + id: 'glm', + name: 'GLM', + description: 'Claude via Z.AI', + baseUrl: 'https://api.z.ai/api/anthropic', + defaultProfileName: 'glm', + defaultModel: 'glm-5', + apiKeyPlaceholder: 'ghp_...', + apiKeyHint: 'Get your API key from Z.AI', + category: 'alternative', + requiresApiKey: true, + badge: 'Z.AI', + icon: '/icons/zai.svg', + }, + { + id: 'glmt', + name: 'GLMT', + description: 'GLM with Thinking mode support', + baseUrl: 'https://api.z.ai/api/coding/paas/v4/chat/completions', + defaultProfileName: 'glmt', + defaultModel: 'glm-5', + apiKeyPlaceholder: 'ghp_...', + apiKeyHint: 'Same API key as GLM', + category: 'alternative', + requiresApiKey: true, + extraEnv: { + ANTHROPIC_TEMPERATURE: '0.2', + ANTHROPIC_MAX_TOKENS: '65536', + MAX_THINKING_TOKENS: '32768', + ENABLE_STREAMING: 'true', + ANTHROPIC_SAFE_MODE: 'false', + API_TIMEOUT_MS: '3000000', + }, + alwaysThinkingEnabled: true, + badge: 'Thinking', + icon: '/icons/zai.svg', + }, + { + id: 'km', + name: 'Kimi', + description: 'Moonshot AI - Fast reasoning model', + baseUrl: 'https://api.kimi.com/coding/', + defaultProfileName: 'km', + defaultModel: 'kimi-k2-thinking-turbo', + apiKeyPlaceholder: 'sk-...', + apiKeyHint: 'Get your API key from Moonshot AI', + category: 'alternative', + requiresApiKey: true, + alwaysThinkingEnabled: true, + badge: 'Reasoning', + icon: '/icons/kimi.svg', + }, + { + id: 'foundry', + name: 'Azure Foundry', + description: 'Claude via Microsoft Azure AI Foundry', + baseUrl: 'https://.services.ai.azure.com/api/anthropic', + defaultProfileName: 'foundry', + defaultModel: 'claude-sonnet-4-5', + apiKeyPlaceholder: 'YOUR_AZURE_API_KEY', + apiKeyHint: 'Create resource at ai.azure.com, get API key from Keys tab', + category: 'alternative', + requiresApiKey: true, + badge: 'Azure', + icon: '/icons/azure.svg', + }, + { + id: 'mm', + name: 'Minimax', + description: 'M2.1/M2.1-lightning/M2 - multilang coding (1M context)', + baseUrl: 'https://api.minimax.io/anthropic', + defaultProfileName: 'mm', + defaultModel: 'MiniMax-M2.1', + apiKeyPlaceholder: 'YOUR_MINIMAX_API_KEY_HERE', + apiKeyHint: 'Get your API key at platform.minimax.io', + category: 'alternative', + requiresApiKey: true, + badge: '1M context', + icon: '/icons/minimax.svg', + }, + { + id: 'deepseek', + name: 'DeepSeek', + description: 'V3.2 and R1 reasoning model (128K context)', + baseUrl: 'https://api.deepseek.com/anthropic', + defaultProfileName: 'deepseek', + defaultModel: 'deepseek-chat', + apiKeyPlaceholder: 'sk-...', + apiKeyHint: 'Get your API key at platform.deepseek.com', + category: 'alternative', + requiresApiKey: true, + badge: 'Reasoning', + icon: '/icons/deepseek.svg', + }, + { + id: 'qwen', + name: 'Qwen', + description: 'Alibaba Cloud - Qwen3 models (256K-1M context, thinking support)', + baseUrl: 'https://dashscope-intl.aliyuncs.com/apps/anthropic', + defaultProfileName: 'qwen', + defaultModel: 'qwen3-coder-plus', + apiKeyPlaceholder: 'sk-...', + apiKeyHint: 'Get your API key from Alibaba Cloud Model Studio', + category: 'alternative', + requiresApiKey: true, + badge: 'Alibaba', + icon: '/assets/providers/qwen-color.svg', + }, + { + id: 'ollama-cloud', + name: 'Ollama Cloud', + description: 'Ollama cloud models via direct API (glm-5:cloud, minimax-m2.1:cloud)', + baseUrl: 'https://ollama.com', + defaultProfileName: 'ollama-cloud', + defaultModel: 'glm-5:cloud', + apiKeyPlaceholder: 'YOUR_OLLAMA_CLOUD_API_KEY', + apiKeyHint: 'Get your API key at ollama.com', + category: 'alternative', + requiresApiKey: true, + badge: 'Cloud', + icon: '/icons/ollama.svg', + }, +]; + +function clonePresetDefinition(preset: ProviderPresetDefinition): ProviderPresetDefinition { + return { + ...preset, + extraEnv: preset.extraEnv ? { ...preset.extraEnv } : undefined, + }; +} + +function freezePresetDefinition(preset: ProviderPresetDefinition): ProviderPresetDefinition { + const cloned = clonePresetDefinition(preset); + if (cloned.extraEnv) { + Object.freeze(cloned.extraEnv); + } + return Object.freeze(cloned); +} + +function assertProviderPresetCatalogIntegrity( + definitions: readonly ProviderPresetDefinition[], + aliases: Readonly> +): void { + const presetIdSet = new Set(); + for (const definition of definitions) { + const normalizedId = definition.id.trim().toLowerCase(); + if (definition.id !== normalizedId) { + throw new Error(`Preset ID must be normalized: "${definition.id}"`); + } + if (presetIdSet.has(definition.id)) { + throw new Error(`Duplicate preset ID detected: "${definition.id}"`); + } + presetIdSet.add(definition.id); + } + + const normalizedAliasSet = new Set(); + for (const [alias, target] of Object.entries(aliases)) { + const normalizedAlias = alias.trim().toLowerCase(); + if (!normalizedAlias) { + throw new Error('Preset alias keys cannot be empty'); + } + if (alias !== normalizedAlias) { + throw new Error(`Preset alias must be normalized: "${alias}"`); + } + if (normalizedAliasSet.has(normalizedAlias)) { + throw new Error(`Duplicate normalized preset alias detected: "${alias}"`); + } + normalizedAliasSet.add(normalizedAlias); + + if (!presetIdSet.has(target)) { + throw new Error(`Preset alias "${alias}" points to unknown target "${target}"`); + } + if (presetIdSet.has(normalizedAlias)) { + throw new Error( + `Preset alias "${alias}" collides with canonical preset ID "${normalizedAlias}"` + ); + } + } +} + +assertProviderPresetCatalogIntegrity(RAW_PROVIDER_PRESET_DEFINITIONS, PROVIDER_PRESET_ALIASES); + +export const PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = Object.freeze( + RAW_PROVIDER_PRESET_DEFINITIONS.map(freezePresetDefinition) +); + +export function createProviderPresetDefinitions(): ProviderPresetDefinition[] { + return PROVIDER_PRESET_DEFINITIONS.map(clonePresetDefinition); +} + +export function normalizeProviderPresetId(id: string): string { + const normalized = id.trim().toLowerCase(); + return PROVIDER_PRESET_ALIASES[normalized] || normalized; +} diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index 5c6ba7bb..3950ae52 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -1,5 +1,6 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'; import { EventEmitter } from 'events'; +import * as childProcess from 'child_process'; type SpawnCall = { command: string; @@ -12,12 +13,17 @@ const originalPlatform = process.platform; let baselineSigintListeners: Array<(...args: unknown[]) => void> = []; let baselineSigtermListeners: Array<(...args: unknown[]) => void> = []; let baselineSighupListeners: Array<(...args: unknown[]) => void> = []; +const realSpawn = childProcess.spawn.bind(childProcess); +const realSpawnSync = childProcess.spawnSync.bind(childProcess); +const realExecSync = childProcess.execSync.bind(childProcess); function createMockChild(): EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; exitCode: number | null; killed: boolean; + pid: number; + unref: () => EventEmitter; kill: () => boolean; } { const child = new EventEmitter() as EventEmitter & { @@ -25,6 +31,8 @@ function createMockChild(): EventEmitter & { stderr: EventEmitter; exitCode: number | null; killed: boolean; + pid: number; + unref: () => EventEmitter; kill: () => boolean; }; @@ -32,6 +40,8 @@ function createMockChild(): EventEmitter & { child.stderr = new EventEmitter(); child.exitCode = null; child.killed = false; + child.pid = process.pid; + child.unref = () => child; child.kill = () => { child.killed = true; child.exitCode = 1; @@ -41,30 +51,57 @@ function createMockChild(): EventEmitter & { return child; } -mock.module('child_process', () => ({ - spawn: (...spawnArgs: unknown[]) => { - const command = String(spawnArgs[0] ?? ''); - const maybeArgs = spawnArgs[1]; - const args = Array.isArray(maybeArgs) ? (maybeArgs as string[]) : []; - const options = (Array.isArray(maybeArgs) ? spawnArgs[2] : spawnArgs[1]) as - | Record - | undefined; +function shouldMockCommand(command: string): boolean { + const normalized = command.toLowerCase(); + return normalized.includes('claude'); +} - spawnCalls.push({ command, args, options }); +function registerChildProcessMock(): void { + mock.module('child_process', () => ({ + ...childProcess, + spawn: (...spawnArgs: unknown[]) => { + const command = String(spawnArgs[0] ?? ''); + const maybeArgs = spawnArgs[1]; + const args = Array.isArray(maybeArgs) ? (maybeArgs as string[]) : []; + const options = (Array.isArray(maybeArgs) ? spawnArgs[2] : spawnArgs[1]) as + | Record + | undefined; - const child = createMockChild(); - setTimeout(() => child.emit('close', 0), 0); - return child; - }, - spawnSync: () => ({ status: 0 }), - execSync: () => '', -})); + if (!shouldMockCommand(command)) { + return realSpawn(command, args, options as Parameters[2]); + } + + spawnCalls.push({ command, args, options }); + + const child = createMockChild(); + setTimeout(() => child.emit('close', 0), 0); + return child; + }, + spawnSync: (...spawnArgs: unknown[]) => { + const command = String(spawnArgs[0] ?? ''); + const maybeArgs = spawnArgs[1]; + const args = Array.isArray(maybeArgs) ? (maybeArgs as string[]) : []; + const options = (Array.isArray(maybeArgs) ? spawnArgs[2] : spawnArgs[1]) as + | Record + | undefined; + + return realSpawnSync(command, args, options as Parameters[2]); + }, + execSync: (...execArgs: unknown[]) => + realExecSync( + execArgs[0] as Parameters[0], + execArgs[1] as Parameters[1] + ), + })); +} let execClaude: typeof import('../../../src/utils/shell-executor').execClaude; let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv; let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor; beforeAll(async () => { + registerChildProcessMock(); + const shellExecutor = await import('../../../src/utils/shell-executor'); execClaude = shellExecutor.execClaude; stripClaudeCodeEnv = shellExecutor.stripClaudeCodeEnv; diff --git a/ui/src/components/cliproxy/cliproxy-table.tsx b/ui/src/components/cliproxy/cliproxy-table.tsx index e3f5996e..15de4b23 100644 --- a/ui/src/components/cliproxy/cliproxy-table.tsx +++ b/ui/src/components/cliproxy/cliproxy-table.tsx @@ -27,21 +27,12 @@ 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'; +import { getProviderDisplayName } from '@/lib/provider-config'; interface CliproxyTableProps { data: Variant[]; } -const providerLabels: Record = { - gemini: 'Google Gemini', - codex: 'OpenAI Codex', - agy: 'Antigravity', - qwen: 'Alibaba Qwen', - iflow: 'iFlow', - kiro: 'Kiro (AWS)', - ghcp: 'GitHub Copilot (OAuth)', -}; - export function CliproxyTable({ data }: CliproxyTableProps) { const deleteMutation = useDeleteVariant(); const [editingVariant, setEditingVariant] = useState(null); @@ -59,7 +50,7 @@ export function CliproxyTable({ data }: CliproxyTableProps) { if (row.original.type === 'composite') { return composite; } - return providerLabels[row.original.provider] || row.original.provider; + return getProviderDisplayName(row.original.provider); }, }, { diff --git a/ui/src/components/cliproxy/provider-logo.tsx b/ui/src/components/cliproxy/provider-logo.tsx index e2b8528b..14c35b7b 100644 --- a/ui/src/components/cliproxy/provider-logo.tsx +++ b/ui/src/components/cliproxy/provider-logo.tsx @@ -4,6 +4,11 @@ */ import { cn } from '@/lib/utils'; +import { + getProviderFallbackVisual, + getProviderLogoAsset, + providerNeedsDarkLogoBackground, +} from '@/lib/provider-config'; interface ProviderLogoProps { provider: string; @@ -11,35 +16,6 @@ interface ProviderLogoProps { size?: 'sm' | 'md' | 'lg'; } -/** Provider image assets mapping */ -const PROVIDER_IMAGES: Record = { - gemini: '/assets/providers/gemini-color.svg', - codex: '/assets/providers/openai.svg', - agy: '/assets/providers/agy.png', - qwen: '/assets/providers/qwen-color.svg', - iflow: '/assets/providers/iflow.png', - kiro: '/assets/providers/kiro.png', - ghcp: '/assets/providers/copilot.svg', - claude: '/assets/providers/claude.svg', - kimi: '/assets/providers/kimi.svg', -}; - -/** Provider color configuration (for fallback only - no background for image logos) */ -const PROVIDER_CONFIG: Record = { - gemini: { text: 'text-blue-600', letter: 'G' }, - claude: { text: 'text-orange-600', letter: 'C' }, - codex: { text: 'text-emerald-600', letter: 'X' }, - agy: { text: 'text-violet-600', letter: 'A' }, - qwen: { text: 'text-cyan-600', letter: 'Q' }, - iflow: { text: 'text-indigo-600', letter: 'i' }, - kiro: { text: 'text-teal-600', letter: 'K' }, - ghcp: { text: 'text-green-600', letter: 'C' }, - kimi: { text: 'text-orange-500', letter: 'K' }, -}; - -/** Providers whose logos require a dark background */ -const DARK_BG_PROVIDERS = new Set(['kimi']); - /** Size configuration */ const SIZE_CONFIG = { sm: { container: 'w-6 h-6', icon: 'w-4 h-4', text: 'text-xs' }, @@ -48,19 +24,16 @@ const SIZE_CONFIG = { }; export function ProviderLogo({ provider, className, size = 'md' }: ProviderLogoProps) { - const providerKey = provider.toLowerCase(); - const config = PROVIDER_CONFIG[providerKey] || { - text: 'text-gray-600', - letter: provider[0]?.toUpperCase() || '?', - }; + const fallback = getProviderFallbackVisual(provider); const sizeConfig = SIZE_CONFIG[size]; - const imageSrc = PROVIDER_IMAGES[providerKey]; + const imageSrc = getProviderLogoAsset(provider); return (
) : ( - {config.letter} + + {fallback.letter} + )}
); diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx index 089f415f..6bb32bf1 100644 --- a/ui/src/components/profiles/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -4,7 +4,7 @@ */ /* eslint-disable react-hooks/set-state-in-effect */ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; import { useForm, useWatch } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; @@ -30,6 +30,7 @@ import { cn } from '@/lib/utils'; import { PROVIDER_PRESETS, getPresetsByCategory, + getPresetById, type ProviderPreset, } from '@/lib/provider-presets'; import { @@ -55,6 +56,7 @@ const schema = z.object({ }); type FormData = z.infer; +type PresetSelection = ProviderPreset['id'] | 'custom'; interface ProfileCreateDialogProps { open: boolean; @@ -65,6 +67,26 @@ interface ProfileCreateDialogProps { // Common URL mistakes to warn about const PROBLEMATIC_PATHS = ['/chat/completions', '/v1/messages', '/messages', '/completions']; +const CUSTOM_PRESET_ID = 'custom'; +const DEFAULT_PRESET_ID: ProviderPreset['id'] = 'openrouter'; + +const EMPTY_FORM_VALUES: FormData = { + name: '', + baseUrl: '', + apiKey: '', + model: '', + opusModel: '', + sonnetModel: '', + haikuModel: '', +}; + +const RECOMMENDED_PRESETS = getPresetsByCategory('recommended'); +const QUICK_TEMPLATE_PRESETS = PROVIDER_PRESETS.filter( + (preset) => preset.category !== 'recommended' +); +const QUICK_TEMPLATE_PRESET_IDS = new Set( + QUICK_TEMPLATE_PRESETS.map((preset) => preset.id) +); export function ProfileCreateDialog({ open, @@ -76,7 +98,7 @@ export function ProfileCreateDialog({ const [activeTab, setActiveTab] = useState('basic'); const [urlWarning, setUrlWarning] = useState(null); const [showApiKey, setShowApiKey] = useState(false); - const [selectedPreset, setSelectedPreset] = useState('openrouter'); + const [selectedPreset, setSelectedPreset] = useState(DEFAULT_PRESET_ID); const [modelSearch, setModelSearch] = useState(''); // OpenRouter models for model picker @@ -91,23 +113,34 @@ export function ProfileCreateDialog({ setValue, } = useForm({ resolver: zodResolver(schema), - defaultValues: { - name: '', - baseUrl: '', - apiKey: '', - model: '', - opusModel: '', - sonnetModel: '', - haikuModel: '', - }, + defaultValues: EMPTY_FORM_VALUES, }); const baseUrlValue = useWatch({ control, name: 'baseUrl' }); + const applyPresetToForm = useCallback( + (preset: ProviderPreset | null) => { + if (!preset) { + reset(EMPTY_FORM_VALUES); + return; + } + + reset({ + ...EMPTY_FORM_VALUES, + name: preset.defaultProfileName, + baseUrl: preset.baseUrl, + model: preset.defaultModel, + opusModel: preset.defaultModel, + sonnetModel: preset.defaultModel, + haikuModel: preset.defaultModel, + }); + }, + [reset] + ); // Get current preset config const currentPreset = useMemo(() => { - if (!selectedPreset || selectedPreset === 'custom') return null; - return PROVIDER_PRESETS.find((p) => p.id === selectedPreset); + if (selectedPreset === CUSTOM_PRESET_ID) return null; + return getPresetById(selectedPreset) ?? null; }, [selectedPreset]); // Filter models for OpenRouter search (newest first) @@ -124,7 +157,6 @@ export function ProfileCreateDialog({ // Reset form when dialog opens useEffect(() => { if (open) { - reset(); setActiveTab('basic'); setUrlWarning(null); setShowApiKey(false); @@ -132,45 +164,35 @@ export function ProfileCreateDialog({ // Set initial preset based on initialMode if (initialMode === 'normal') { - // Custom mode - clear form - setSelectedPreset('custom'); - setTimeout(() => { - setValue('name', ''); - setValue('baseUrl', ''); - }, 0); + setSelectedPreset(CUSTOM_PRESET_ID); + applyPresetToForm(null); } else { - // OpenRouter mode (default) - setSelectedPreset('openrouter'); - const openrouterPreset = PROVIDER_PRESETS.find((p) => p.id === 'openrouter'); - if (openrouterPreset) { - setTimeout(() => { - setValue('name', openrouterPreset.defaultProfileName); - setValue('baseUrl', openrouterPreset.baseUrl); - }, 0); + const defaultPreset = getPresetById(DEFAULT_PRESET_ID); + if (defaultPreset) { + setSelectedPreset(defaultPreset.id); + applyPresetToForm(defaultPreset); + return; } + + // Safe fallback if default preset is missing. + setSelectedPreset(CUSTOM_PRESET_ID); + applyPresetToForm(null); } } - }, [open, reset, setValue, initialMode]); + }, [open, initialMode, applyPresetToForm]); // Handle preset selection const handlePresetSelect = (presetId: string) => { - setSelectedPreset(presetId); - const preset = PROVIDER_PRESETS.find((p) => p.id === presetId); + const preset = getPresetById(presetId); + if (preset) { - setValue('name', preset.defaultProfileName); - setValue('baseUrl', preset.baseUrl); - if (preset.defaultModel) { - setValue('model', preset.defaultModel); - setValue('opusModel', preset.defaultModel); - setValue('sonnetModel', preset.defaultModel); - setValue('haikuModel', preset.defaultModel); - } - } else { - // Custom - setValue('name', ''); - setValue('baseUrl', ''); - setValue('model', ''); + setSelectedPreset(preset.id); + applyPresetToForm(preset); + return; } + + setSelectedPreset(CUSTOM_PRESET_ID); + applyPresetToForm(null); }; // Handle model selection from picker - applies to all 4 model tiers @@ -190,7 +212,7 @@ export function ProfileCreateDialog({ // Presets (OpenRouter, GLM, GLMT, Kimi) have vetted URLs that may require full paths useEffect(() => { // Only warn for custom URLs, not preset-selected ones - const isCustomUrl = selectedPreset === 'custom'; + const isCustomUrl = selectedPreset === CUSTOM_PRESET_ID; if (baseUrlValue && isCustomUrl) { const lowerUrl = baseUrlValue.toLowerCase(); for (const path of PROBLEMATIC_PATHS) { @@ -232,7 +254,9 @@ export function ProfileCreateDialog({ const hasModelErrors = !!errors.model || !!errors.opusModel || !!errors.sonnetModel || !!errors.haikuModel; - const isOpenRouter = selectedPreset === 'openrouter'; + const isQuickTemplateSelected = + selectedPreset !== CUSTOM_PRESET_ID && QUICK_TEMPLATE_PRESET_IDS.has(selectedPreset); + const isOpenRouter = currentPreset?.id === DEFAULT_PRESET_ID; return ( @@ -257,7 +281,7 @@ export function ProfileCreateDialog({
- {getPresetsByCategory('recommended').map((preset) => ( + {RECOMMENDED_PRESETS.map((preset) => ( handlePresetSelect('custom')} + onClick={() => handlePresetSelect(CUSTOM_PRESET_ID)} className={cn( 'flex items-center gap-2 px-4 py-2 rounded-md border-2 transition-all text-sm font-medium', - selectedPreset === 'custom' || - getPresetsByCategory('alternative').some((p) => p.id === selectedPreset) + selectedPreset === CUSTOM_PRESET_ID || isQuickTemplateSelected ? 'border-primary bg-primary/10 text-primary dark:bg-primary/20' : 'border-dashed border-muted-foreground/40 hover:border-primary/50 hover:bg-muted/50 text-muted-foreground hover:text-foreground' )} @@ -283,15 +306,14 @@ export function ProfileCreateDialog({
- {/* Show alternative presets when Custom is selected or an alternative is selected */} - {(selectedPreset === 'custom' || - getPresetsByCategory('alternative').some((p) => p.id === selectedPreset)) && ( + {/* Show quick templates when custom mode or non-recommended preset is selected */} + {(selectedPreset === CUSTOM_PRESET_ID || isQuickTemplateSelected) && (
- {getPresetsByCategory('alternative').map((preset) => ( + {QUICK_TEMPLATE_PRESETS.map((preset) => ( = { - ghcp: 'GitHub Copilot', - qwen: 'Qwen Code', -}; - -/** Provider specific instructions */ -const PROVIDER_INSTRUCTIONS: Record = { - ghcp: 'Sign in with your GitHub account that has Copilot access.', - qwen: 'Sign in with your Qwen account to authorize access.', -}; - export function DeviceCodeDialog({ open, onClose, @@ -91,9 +83,12 @@ export function DeviceCodeDialog({ window.open(verificationUrl, '_blank', 'noopener,noreferrer'); }, [verificationUrl]); - const providerDisplay = PROVIDER_DISPLAY_NAMES[provider] || provider; - const instructions = - PROVIDER_INSTRUCTIONS[provider] || 'Complete the authorization in your browser.'; + const providerDisplay = getDeviceCodeProviderDisplayName(provider); + const instructions = getDeviceCodeProviderInstruction(provider); + const openActionLabel = + providerDisplay === 'Unknown provider' + ? 'Open verification page' + : `Open ${providerDisplay.split(' ')[0]}`; // Format remaining time const formatTime = (seconds: number): string => { @@ -155,7 +150,7 @@ export function DeviceCodeDialog({