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': {