mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 00:14:15 +00:00
Merge pull request #593 from kaitranntt/kai/refactor-hardcoded-260220-225538
refactor(presets): reduce hardcoded provider logic
This commit is contained in:
14 files changed
+677
-521
No files matched your search
@@ -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';
|
||||
@@ -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
@@ -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')}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}/`)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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': {
|
||||
|
||||
Reference in new issue
Block a user