Merge pull request #593 from kaitranntt/kai/refactor-hardcoded-260220-225538

refactor(presets): reduce hardcoded provider logic
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-02-20 23:48:38 +07:00
commit 21e6b80286
14 files changed
+677 -521

No files matched your search

+3
View File
@@ -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';
+22 -161
View File
@@ -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<string, string>;
/** 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://<your-resource>.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<Record<string, string>> = 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<Record<string, string>> {
return PRESET_ALIASES;
}
/** Check if preset ID is valid */
export function isValidPresetId(id: string): boolean {
return getPresetById(id) !== undefined;
+34 -21
View File
@@ -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<void> {
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<void> {
/** Show help for api commands */
async function showHelp(): Promise<void> {
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<void> {
console.log('');
console.log(subheader('Options'));
console.log(
` ${color('--preset <id>', 'command')} Use provider preset (openrouter, ollama, ollama-cloud, glm, glmt, km, foundry, mm, deepseek, qwen)`
` ${color('--preset <id>', 'command')} Use provider preset (${presetIds.join(', ')})`
);
console.log(` ${color('--base-url <url>', 'command')} API base URL (create)`);
console.log(` ${color('--api-key <key>', 'command')} API key (create)`);
@@ -456,25 +480,14 @@ async function showHelp(): Promise<void> {
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')}`);
+280
View File
@@ -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<string, string>;
/** 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<Record<string, ProviderPresetId>> = 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://<your-resource>.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<Record<string, ProviderPresetId>>
): void {
const presetIdSet = new Set<string>();
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<string>();
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;
}
@@ -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<string, unknown>
| 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<string, unknown>
| 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<typeof childProcess.spawn>[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<string, unknown>
| undefined;
return realSpawnSync(command, args, options as Parameters<typeof childProcess.spawnSync>[2]);
},
execSync: (...execArgs: unknown[]) =>
realExecSync(
execArgs[0] as Parameters<typeof childProcess.execSync>[0],
execArgs[1] as Parameters<typeof childProcess.execSync>[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;
+2 -11
View File
@@ -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<string, string> = {
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<Variant | null>(null);
@@ -59,7 +50,7 @@ export function CliproxyTable({ data }: CliproxyTableProps) {
if (row.original.type === 'composite') {
return <Badge variant="secondary">composite</Badge>;
}
return providerLabels[row.original.provider] || row.original.provider;
return getProviderDisplayName(row.original.provider);
},
},
{
+12 -37
View File
@@ -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<string, string> = {
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<string, { text: string; letter: string }> = {
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 (
<div
className={cn(
'flex items-center justify-center rounded-md',
imageSrc && (DARK_BG_PROVIDERS.has(providerKey) ? 'bg-gray-900 p-1' : 'bg-white p-1'),
imageSrc &&
(providerNeedsDarkLogoBackground(provider) ? 'bg-gray-900 p-1' : 'bg-white p-1'),
sizeConfig.container,
className
)}
@@ -72,7 +45,9 @@ export function ProviderLogo({ provider, className, size = 'md' }: ProviderLogoP
className={cn(sizeConfig.icon, 'object-contain')}
/>
) : (
<span className={cn('font-semibold', config.text, sizeConfig.text)}>{config.letter}</span>
<span className={cn('font-semibold', fallback.textClass, sizeConfig.text)}>
{fallback.letter}
</span>
)}
</div>
);
@@ -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<typeof schema>;
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<string>(
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<string | null>(null);
const [showApiKey, setShowApiKey] = useState(false);
const [selectedPreset, setSelectedPreset] = useState<string | null>('openrouter');
const [selectedPreset, setSelectedPreset] = useState<PresetSelection>(DEFAULT_PRESET_ID);
const [modelSearch, setModelSearch] = useState('');
// OpenRouter models for model picker
@@ -91,23 +113,34 @@ export function ProfileCreateDialog({
setValue,
} = useForm<FormData>({
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -257,7 +281,7 @@ export function ProfileCreateDialog({
<div>
<Label className="text-xs text-muted-foreground mb-1.5 block">Provider</Label>
<div className="flex gap-2">
{getPresetsByCategory('recommended').map((preset) => (
{RECOMMENDED_PRESETS.map((preset) => (
<CompactPresetCard
key={preset.id}
preset={preset}
@@ -268,11 +292,10 @@ export function ProfileCreateDialog({
{/* Custom option */}
<button
type="button"
onClick={() => 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({
</div>
</div>
{/* 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) && (
<div className="pt-3 mt-2 border-t border-dashed border-muted-foreground/30">
<Label className="text-xs font-medium text-foreground/70 mb-2 block">
Quick Templates
</Label>
<div className="flex gap-2 flex-wrap">
{getPresetsByCategory('alternative').map((preset) => (
{QUICK_TEMPLATE_PRESETS.map((preset) => (
<CompactPresetCard
key={preset.id}
preset={preset}
+11 -16
View File
@@ -17,6 +17,10 @@ import {
import { Button } from '@/components/ui/button';
import { ExternalLink, Copy, Check, Loader2, KeyRound } from 'lucide-react';
import { toast } from 'sonner';
import {
getDeviceCodeProviderDisplayName,
getDeviceCodeProviderInstruction,
} from '@/lib/provider-config';
interface DeviceCodeDialogProps {
open: boolean;
@@ -28,18 +32,6 @@ interface DeviceCodeDialogProps {
expiresAt: number;
}
/** Provider display names */
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
ghcp: 'GitHub Copilot',
qwen: 'Qwen Code',
};
/** Provider specific instructions */
const PROVIDER_INSTRUCTIONS: Record<string, string> = {
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({
<div className="flex flex-col gap-3">
<Button onClick={handleOpenUrl} className="w-full">
<ExternalLink className="w-4 h-4 mr-2" />
Open {providerDisplay.split(' ')[0]}
{openActionLabel}
</Button>
<Button variant="outline" onClick={handleCopyCode} className="w-full">
{hasCopied ? (
+5 -4
View File
@@ -5,7 +5,7 @@
*/
import { cn } from '@/lib/utils';
import { PROVIDER_ASSETS, PROVIDER_COLORS } from '@/lib/provider-config';
import { getProviderLogoAsset, PROVIDER_COLORS } from '@/lib/provider-config';
interface ProviderIconProps {
provider: string;
@@ -22,7 +22,8 @@ export function ProviderIcon({
withBackground = false,
}: ProviderIconProps) {
const normalized = provider.toLowerCase();
const assetPath = PROVIDER_ASSETS[normalized];
const assetPath = getProviderLogoAsset(provider);
const providerColor = PROVIDER_COLORS[normalized as keyof typeof PROVIDER_COLORS] || '#6b7280';
// Icon size is smaller when inside background circle
const iconSize = withBackground ? Math.floor(size * 0.65) : size;
@@ -40,7 +41,7 @@ export function ProviderIcon({
<span
className="font-bold"
style={{
color: PROVIDER_COLORS[normalized] || '#6b7280',
color: providerColor,
fontSize: iconSize * 0.6,
}}
>
@@ -75,7 +76,7 @@ export function ProviderIcon({
);
}
const bgColor = PROVIDER_COLORS[normalized] || '#6b7280';
const bgColor = providerColor;
return (
<div
className={cn(
+13 -12
View File
@@ -8,6 +8,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { toast } from 'sonner';
import { getDeviceCodeProviderDisplayName } from '@/lib/provider-config';
export interface DeviceCodePrompt {
sessionId: string;
@@ -23,12 +24,13 @@ interface DeviceCodeState {
error: string | null;
}
/** Provider display names for user-friendly messages */
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
ghcp: 'GitHub Copilot',
kiro: 'Kiro (AWS)',
qwen: 'Qwen Code',
};
function coerceProvider(value: unknown): string {
if (typeof value !== 'string') {
return 'unknown';
}
const normalized = value.trim().toLowerCase();
return normalized || 'unknown';
}
export function useDeviceCode() {
const [state, setState] = useState<DeviceCodeState>({
@@ -44,14 +46,15 @@ export function useDeviceCode() {
if (data.type === 'deviceCodeReceived') {
console.log('[DeviceCode] Received prompt:', data.sessionId);
const displayName = PROVIDER_DISPLAY_NAMES[data.provider as string] || data.provider;
const provider = coerceProvider(data.provider);
const displayName = getDeviceCodeProviderDisplayName(provider);
toast.info(`${displayName} authorization required`);
setState({
isOpen: true,
prompt: {
sessionId: data.sessionId as string,
provider: data.provider as string,
provider,
userCode: data.userCode as string,
verificationUrl: data.verificationUrl as string,
expiresAt: data.expiresAt as number,
@@ -62,8 +65,7 @@ export function useDeviceCode() {
console.log('[DeviceCode] Auth completed:', data.sessionId);
setState((prev) => {
if (prev.prompt && prev.prompt.sessionId === data.sessionId) {
const displayName =
PROVIDER_DISPLAY_NAMES[prev.prompt.provider] || prev.prompt.provider;
const displayName = getDeviceCodeProviderDisplayName(prev.prompt.provider);
toast.success(`${displayName} authentication successful!`);
return { isOpen: false, prompt: null, error: null };
}
@@ -73,8 +75,7 @@ export function useDeviceCode() {
console.log('[DeviceCode] Auth failed:', data.sessionId, data.error);
setState((prev) => {
if (prev.prompt && prev.prompt.sessionId === data.sessionId) {
const displayName =
PROVIDER_DISPLAY_NAMES[prev.prompt.provider] || prev.prompt.provider;
const displayName = getDeviceCodeProviderDisplayName(prev.prompt.provider);
toast.error(`${displayName} authentication failed`);
return { isOpen: false, prompt: null, error: data.error as string };
}
+107 -9
View File
@@ -30,6 +30,10 @@ export function isValidProvider(provider: string): provider is CLIProxyProvider
return CLIPROXY_PROVIDERS.includes(provider as CLIProxyProvider);
}
function normalizeProviderInput(provider: unknown): string {
return typeof provider === 'string' ? provider.trim().toLowerCase() : '';
}
interface ProviderMetadata {
displayName: string;
description: string;
@@ -75,7 +79,7 @@ export const PROVIDER_METADATA: Record<CLIProxyProvider, ProviderMetadata> = {
};
// Map provider names to asset filenames (only providers with actual logos)
export const PROVIDER_ASSETS: Record<string, string> = {
export const PROVIDER_ASSETS: Record<CLIProxyProvider, string> = {
gemini: '/assets/providers/gemini-color.svg',
agy: '/assets/providers/agy.png',
codex: '/assets/providers/openai.svg',
@@ -87,6 +91,56 @@ export const PROVIDER_ASSETS: Record<string, string> = {
kimi: '/assets/providers/kimi.svg',
};
interface ProviderFallbackVisual {
textClass: string;
letter: string;
}
const DEFAULT_PROVIDER_FALLBACK_VISUAL: ProviderFallbackVisual = {
textClass: 'text-gray-600',
letter: '?',
};
/** Fallback visual style when a provider logo asset is unavailable. */
export const PROVIDER_FALLBACK_VISUALS: Record<CLIProxyProvider, ProviderFallbackVisual> = {
gemini: { textClass: 'text-blue-600', letter: 'G' },
claude: { textClass: 'text-orange-600', letter: 'C' },
codex: { textClass: 'text-emerald-600', letter: 'X' },
agy: { textClass: 'text-violet-600', letter: 'A' },
qwen: { textClass: 'text-cyan-600', letter: 'Q' },
iflow: { textClass: 'text-indigo-600', letter: 'i' },
kiro: { textClass: 'text-teal-600', letter: 'K' },
ghcp: { textClass: 'text-green-600', letter: 'C' },
kimi: { textClass: 'text-orange-500', letter: 'K' },
};
/** Providers whose logo looks better on dark background. */
export const PROVIDERS_WITH_DARK_LOGO_BG: ReadonlySet<CLIProxyProvider> = new Set(['kimi']);
export function getProviderLogoAsset(provider: unknown): string | undefined {
const normalized = normalizeProviderInput(provider);
if (!isValidProvider(normalized)) {
return undefined;
}
return PROVIDER_ASSETS[normalized];
}
export function getProviderFallbackVisual(provider: unknown): ProviderFallbackVisual {
const normalized = normalizeProviderInput(provider);
if (isValidProvider(normalized)) {
return PROVIDER_FALLBACK_VISUALS[normalized];
}
return {
...DEFAULT_PROVIDER_FALLBACK_VISUAL,
letter: normalized[0]?.toUpperCase() || DEFAULT_PROVIDER_FALLBACK_VISUAL.letter,
};
}
export function providerNeedsDarkLogoBackground(provider: unknown): boolean {
const normalized = normalizeProviderInput(provider);
return isValidProvider(normalized) && PROVIDERS_WITH_DARK_LOGO_BG.has(normalized);
}
// Provider brand colors
export const PROVIDER_COLORS: Record<string, string> = {
gemini: '#4285F4',
@@ -110,13 +164,17 @@ const PROVIDER_NAMES: Record<string, string> = {
};
// Map provider to display name
export function getProviderDisplayName(provider: string): string {
return PROVIDER_NAMES[provider.toLowerCase()] || provider;
export function getProviderDisplayName(provider: unknown): string {
const normalized = normalizeProviderInput(provider);
if (!normalized) {
return 'Unknown provider';
}
return PROVIDER_NAMES[normalized] || String(provider);
}
/** Map provider to user-facing short description */
export function getProviderDescription(provider: string): string {
const normalized = provider.toLowerCase();
export function getProviderDescription(provider: unknown): string {
const normalized = normalizeProviderInput(provider);
if (!isValidProvider(normalized)) return '';
return PROVIDER_METADATA[normalized].description;
}
@@ -127,17 +185,57 @@ export function getProviderDescription(provider: string): string {
*/
export const DEVICE_CODE_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro', 'qwen', 'kimi'];
const DEVICE_CODE_PROVIDER_DISPLAY_NAMES: Readonly<Partial<Record<CLIProxyProvider, string>>> =
Object.freeze({
ghcp: 'GitHub Copilot',
kiro: 'Kiro (AWS)',
qwen: 'Qwen Code',
});
const DEVICE_CODE_PROVIDER_INSTRUCTIONS: Readonly<Partial<Record<CLIProxyProvider, string>>> =
Object.freeze({
ghcp: 'Sign in with your GitHub account that has Copilot access.',
qwen: 'Sign in with your Qwen account to authorize access.',
kiro: 'Sign in with your selected Kiro auth provider to continue.',
kimi: 'Sign in with your Kimi account and finish the device authorization.',
});
/** Check if provider uses Device Code flow */
export function isDeviceCodeProvider(provider: string): boolean {
return DEVICE_CODE_PROVIDERS.includes(provider as CLIProxyProvider);
export function isDeviceCodeProvider(provider: unknown): boolean {
const normalized = normalizeProviderInput(provider);
return isValidProvider(normalized) && DEVICE_CODE_PROVIDERS.includes(normalized);
}
/** Provider display name tuned for device-code UX copy. */
export function getDeviceCodeProviderDisplayName(provider: unknown): string {
const normalized = normalizeProviderInput(provider);
if (!normalized) {
return 'Unknown provider';
}
if (isValidProvider(normalized)) {
return DEVICE_CODE_PROVIDER_DISPLAY_NAMES[normalized] || getProviderDisplayName(normalized);
}
return String(provider);
}
/** Provider-specific helper text for device-code dialog. */
export function getDeviceCodeProviderInstruction(provider: unknown): string {
const normalized = normalizeProviderInput(provider);
if (isValidProvider(normalized)) {
return (
DEVICE_CODE_PROVIDER_INSTRUCTIONS[normalized] || 'Complete the authorization in your browser.'
);
}
return 'Complete the authorization in your browser.';
}
/** Providers that require nickname because token payload may not include email. */
export const NICKNAME_REQUIRED_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro'];
/** Check if provider requires user-supplied nickname in auth flow */
export function isNicknameRequiredProvider(provider: string): boolean {
return NICKNAME_REQUIRED_PROVIDERS.includes(provider as CLIProxyProvider);
export function isNicknameRequiredProvider(provider: unknown): boolean {
const normalized = normalizeProviderInput(provider);
return isValidProvider(normalized) && NICKNAME_REQUIRED_PROVIDERS.includes(normalized);
}
/** Kiro auth methods exposed in CCS UI (aligned with CLIProxyAPIPlus support). */
+53 -180
View File
@@ -1,199 +1,72 @@
/**
* Provider Presets Configuration
* Pre-configured templates for common API providers
* Shared catalog from backend source-of-truth with UI-only presentation overrides.
*/
export type PresetCategory = 'recommended' | 'alternative';
import {
OPENROUTER_BASE_URL,
createProviderPresetDefinitions,
normalizeProviderPresetId,
type PresetCategory,
type ProviderPresetDefinition,
type ProviderPresetId,
} from '../../../src/shared/provider-preset-catalog';
export interface ProviderPreset {
id: string;
name: string;
description: string;
baseUrl: string;
defaultProfileName: string;
badge?: string;
featured?: boolean;
icon?: string;
defaultModel?: string;
requiresApiKey: boolean;
apiKeyPlaceholder: string;
apiKeyHint?: string;
category: PresetCategory;
/** Additional env vars for thinking mode, etc. */
extraEnv?: Record<string, string>;
/** Enable always thinking mode */
alwaysThinkingEnabled?: boolean;
export { OPENROUTER_BASE_URL };
export type { PresetCategory };
export type ProviderPreset = ProviderPresetDefinition;
/**
* UI-only overrides for presentation details that differ from CLI semantics.
* Keep this tiny; provider data itself belongs in shared catalog.
*/
type UiPresetOverride = Pick<ProviderPreset, 'apiKeyPlaceholder'>;
const UI_PRESET_OVERRIDES: Readonly<Partial<Record<ProviderPresetId, UiPresetOverride>>> =
Object.freeze({
ollama: {
apiKeyPlaceholder: '',
},
});
function withUiOverrides(preset: ProviderPresetDefinition): ProviderPreset {
const overrides = UI_PRESET_OVERRIDES[preset.id];
return overrides ? { ...preset, ...overrides } : { ...preset };
}
export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api';
const BASE_PROVIDER_PRESETS = createProviderPresetDefinitions();
export const PROVIDER_PRESETS: ProviderPreset[] = [
// Recommended - OpenRouter
{
id: 'openrouter',
name: 'OpenRouter',
description: '349+ models from OpenAI, Anthropic, Google, Meta',
baseUrl: OPENROUTER_BASE_URL,
defaultProfileName: 'openrouter',
badge: '349+ models',
featured: true,
icon: '/icons/openrouter.svg',
defaultModel: 'anthropic/claude-opus-4.5',
requiresApiKey: true,
apiKeyPlaceholder: 'sk-or-...',
apiKeyHint: 'Get your API key at openrouter.ai/keys',
category: 'recommended',
},
// Recommended - Ollama (Local)
{
id: 'ollama',
name: 'Ollama (Local)',
description: 'Local open-source models via Ollama (32K+ context)',
baseUrl: 'http://localhost:11434',
defaultProfileName: 'ollama',
badge: 'Local',
featured: true,
icon: '/icons/ollama.svg',
defaultModel: 'qwen3-coder',
requiresApiKey: false,
apiKeyPlaceholder: '',
apiKeyHint: 'No API key required for local Ollama',
category: 'recommended',
},
// Alternative providers - GLM/GLMT/Kimi
{
id: 'glm',
name: 'GLM',
description: 'Claude via Z.AI',
baseUrl: 'https://api.z.ai/api/anthropic',
defaultProfileName: 'glm',
badge: 'Z.AI',
icon: '/icons/zai.svg',
defaultModel: 'glm-5',
requiresApiKey: true,
apiKeyPlaceholder: 'ghp_...',
apiKeyHint: 'Get your API key from Z.AI',
category: 'alternative',
},
{
id: 'glmt',
name: 'GLMT',
description: 'GLM with Thinking mode support',
baseUrl: 'https://api.z.ai/api/coding/paas/v4/chat/completions',
defaultProfileName: 'glmt',
badge: 'Thinking',
icon: '/icons/zai.svg',
defaultModel: 'glm-5',
requiresApiKey: true,
apiKeyPlaceholder: 'ghp_...',
apiKeyHint: 'Same API key as GLM',
category: 'alternative',
},
{
id: 'km',
name: 'Kimi',
description: 'Moonshot AI - Fast reasoning model',
baseUrl: 'https://api.kimi.com/coding/',
defaultProfileName: 'km',
badge: 'Reasoning',
icon: '/icons/kimi.svg',
defaultModel: 'kimi-k2-thinking-turbo',
requiresApiKey: true,
apiKeyPlaceholder: 'sk-...',
apiKeyHint: 'Get your API key from Moonshot AI',
category: 'alternative',
alwaysThinkingEnabled: true,
},
{
id: 'foundry',
name: 'Azure Foundry',
description: 'Claude via Microsoft Azure AI Foundry',
baseUrl: 'https://<your-resource>.services.ai.azure.com/api/anthropic',
defaultProfileName: 'foundry',
badge: 'Azure',
icon: '/icons/azure.svg',
defaultModel: 'claude-sonnet-4-5',
requiresApiKey: true,
apiKeyPlaceholder: 'YOUR_AZURE_API_KEY',
apiKeyHint: 'Create resource at ai.azure.com, get API key from Keys tab',
category: 'alternative',
},
{
id: 'mm',
name: 'Minimax',
description: 'M2.1/M2.1-lightning/M2 - multilang coding (1M context)',
baseUrl: 'https://api.minimax.io/anthropic',
defaultProfileName: 'mm',
badge: '1M context',
icon: '/icons/minimax.svg',
defaultModel: 'MiniMax-M2.1',
requiresApiKey: true,
apiKeyPlaceholder: 'YOUR_MINIMAX_API_KEY_HERE',
apiKeyHint: 'Get your API key at platform.minimax.io',
category: 'alternative',
},
{
id: 'deepseek',
name: 'DeepSeek',
description: 'V3.2 and R1 reasoning model (128K context)',
baseUrl: 'https://api.deepseek.com/anthropic',
defaultProfileName: 'deepseek',
badge: 'Reasoning',
icon: '/icons/deepseek.svg',
defaultModel: 'deepseek-chat',
requiresApiKey: true,
apiKeyPlaceholder: 'sk-...',
apiKeyHint: 'Get your API key at platform.deepseek.com',
category: 'alternative',
},
{
id: 'qwen',
name: 'Qwen',
description: 'Alibaba Cloud - Qwen3 models (256K-1M context, thinking support)',
baseUrl: 'https://dashscope-intl.aliyuncs.com/apps/anthropic',
defaultProfileName: 'qwen',
badge: 'Alibaba',
icon: '/assets/providers/qwen-color.svg',
defaultModel: 'qwen3-coder-plus',
requiresApiKey: true,
apiKeyPlaceholder: 'sk-...',
apiKeyHint: 'Get your API key from Alibaba Cloud Model Studio',
category: 'alternative',
},
{
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',
badge: 'Cloud',
icon: '/icons/ollama.svg',
defaultModel: 'glm-5:cloud',
requiresApiKey: true,
apiKeyPlaceholder: 'YOUR_OLLAMA_CLOUD_API_KEY',
apiKeyHint: 'Get your API key at ollama.com',
category: 'alternative',
},
];
const LEGACY_PRESET_ALIASES: Readonly<Record<string, string>> = Object.freeze({
kimi: 'km',
});
export const PROVIDER_PRESETS: readonly ProviderPreset[] = Object.freeze(
BASE_PROVIDER_PRESETS.map(withUiOverrides)
);
/** Get presets by category */
export function getPresetsByCategory(category: PresetCategory): ProviderPreset[] {
return PROVIDER_PRESETS.filter((p) => p.category === category);
return PROVIDER_PRESETS.filter((preset) => preset.category === category);
}
/** Get preset by ID */
/** Get preset by ID (supports legacy aliases via shared alias map). */
export function getPresetById(id: string): ProviderPreset | undefined {
const normalized = id.trim().toLowerCase();
const canonical = LEGACY_PRESET_ALIASES[normalized] || normalized;
return PROVIDER_PRESETS.find((p) => p.id.toLowerCase() === canonical);
const canonical = normalizeProviderPresetId(id);
return PROVIDER_PRESETS.find((preset) => preset.id === canonical);
}
/** Check if a URL matches a known preset */
export function detectPresetFromUrl(baseUrl: string): ProviderPreset | undefined {
const normalizedUrl = baseUrl.toLowerCase().trim();
return PROVIDER_PRESETS.find((p) => normalizedUrl.includes(p.baseUrl.toLowerCase()));
const normalizedInput = baseUrl.trim().toLowerCase().replace(/\/+$/, '');
if (!normalizedInput) {
return undefined;
}
return PROVIDER_PRESETS.find((preset) => {
const normalizedPresetUrl = preset.baseUrl.trim().toLowerCase().replace(/\/+$/, '');
if (!normalizedPresetUrl) {
return false;
}
return (
normalizedInput === normalizedPresetUrl ||
normalizedInput.startsWith(`${normalizedPresetUrl}/`)
);
});
}
+6
View File
@@ -3,6 +3,9 @@ import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
const UI_ROOT = __dirname;
const REPO_ROOT = path.resolve(__dirname, '..');
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
@@ -54,6 +57,9 @@ export default defineConfig({
},
server: {
port: 5173,
fs: {
allow: [UI_ROOT, REPO_ROOT],
},
proxy: {
'/api': 'http://localhost:3000',
'/ws': {