From 21d6754ec6ae59002c2d393d41515dd105de692a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 20 Feb 2026 22:53:23 +0700 Subject: [PATCH 1/8] refactor(presets): centralize shared provider preset catalog - add shared preset catalog used by CLI and Dashboard - enforce alias/id integrity and canonical normalization - replace duplicated preset definitions in service and UI layers - allow Vite dev fs access to repo root for shared catalog imports --- src/api/services/index.ts | 3 + src/api/services/provider-presets.ts | 183 ++--------------- src/shared/provider-preset-catalog.ts | 280 ++++++++++++++++++++++++++ ui/src/lib/provider-presets.ts | 232 +++++---------------- ui/vite.config.ts | 6 + 5 files changed, 363 insertions(+), 341 deletions(-) create mode 100644 src/shared/provider-preset-catalog.ts 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/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/ui/src/lib/provider-presets.ts b/ui/src/lib/provider-presets.ts index 08a76334..dc23cc8b 100644 --- a/ui/src/lib/provider-presets.ts +++ b/ui/src/lib/provider-presets.ts @@ -1,199 +1,71 @@ /** * 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; - /** 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; + +const UI_PRESET_OVERRIDES = Object.freeze({ + ollama: { + apiKeyPlaceholder: '', + }, +}) satisfies Readonly>>; + +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://.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> = 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}/`) + ); + }); } diff --git a/ui/vite.config.ts b/ui/vite.config.ts index d905eb63..39c8d654 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -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': { From 5b12ce7f0065ff19eff46f61171db20f6e1170a3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 20 Feb 2026 22:53:50 +0700 Subject: [PATCH 2/8] fix(api): sanitize preset help text rendering - strip control characters from dynamic preset output - render preset help from shared registry data - include normalized alias hints without hardcoded lines --- src/commands/api-command.ts | 55 +++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 21 deletions(-) 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')}`); From 9ad8e64e85458a0154c1cac7bac2e3f082f9c7ac Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 20 Feb 2026 22:54:03 +0700 Subject: [PATCH 3/8] refactor(ui): centralize provider metadata and fallbacks - move logo, label, and device-code copy into provider-config - handle unknown or non-string providers without UI crashes - replace duplicated hardcoded mappings in cliproxy table/logo --- ui/src/components/cliproxy/cliproxy-table.tsx | 13 +- ui/src/components/cliproxy/provider-logo.tsx | 49 ++------ .../components/shared/device-code-dialog.tsx | 27 ++-- ui/src/hooks/use-device-code.ts | 25 ++-- ui/src/lib/provider-config.ts | 116 ++++++++++++++++-- 5 files changed, 145 insertions(+), 85 deletions(-) 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/shared/device-code-dialog.tsx b/ui/src/components/shared/device-code-dialog.tsx index 5f21cd50..809f4a43 100644 --- a/ui/src/components/shared/device-code-dialog.tsx +++ b/ui/src/components/shared/device-code-dialog.tsx @@ -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 = { - 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({