diff --git a/apps/desktop/src/main/__tests__/claude-subscription-experimental-gate.test.ts b/apps/desktop/src/main/__tests__/claude-subscription-experimental-gate.test.ts index 61af6dc6ef..7e61a6b65f 100644 --- a/apps/desktop/src/main/__tests__/claude-subscription-experimental-gate.test.ts +++ b/apps/desktop/src/main/__tests__/claude-subscription-experimental-gate.test.ts @@ -18,6 +18,7 @@ import { strict as assert } from 'node:assert'; import { readFile } from 'node:fs/promises'; import { describe, it } from 'node:test'; import { resolve } from 'node:path'; +import { CATALOG_PROVIDER_TYPES } from '@maka/core'; import { readProviderSettingsCombinedSource } from './provider-contract-source-helpers.js'; import { readMainProcessCombinedSource } from './main-process-contract-source-helpers.js'; @@ -254,17 +255,11 @@ describe('experimental kill-switch (kenji 1da909d5 + 45b31e16)', () => { }); it('ProvidersPanel keeps OAuth login out of CATALOG_PROVIDER_TYPES and surfaces it as account connections', async () => { - const [src, core] = await Promise.all([ - readProviderSettingsCombinedSource(), - readFile(resolve(REPO_ROOT, 'packages', 'core', 'src', 'llm-connections.ts'), 'utf8'), - ]); - const catalogMatch = core.match(/export const CATALOG_PROVIDER_TYPES: ProviderType\[] = \[([\s\S]*?)\];/); - assert.ok(catalogMatch, 'CATALOG_PROVIDER_TYPES must exist'); - const catalogBody = catalogMatch[1]!; + const src = await readProviderSettingsCombinedSource(); for (const provider of ['claude-subscription', 'codex-subscription', 'gemini-cli']) { - assert.doesNotMatch( - catalogBody, - new RegExp(`'${provider}'`), + assert.equal( + CATALOG_PROVIDER_TYPES.includes(provider as (typeof CATALOG_PROVIDER_TYPES)[number]), + false, `${provider} must stay out of the visible model provider catalog until its send path is actually open`, ); } diff --git a/apps/desktop/src/renderer/settings/ProvidersPanel.tsx b/apps/desktop/src/renderer/settings/ProvidersPanel.tsx index 13427a04db..78d245b3d7 100644 --- a/apps/desktop/src/renderer/settings/ProvidersPanel.tsx +++ b/apps/desktop/src/renderer/settings/ProvidersPanel.tsx @@ -3,6 +3,7 @@ import { ArrowLeft, ChevronRight, Plus, Search } from '@maka/ui/icons'; import { CATALOG_PROVIDER_TYPES, PROVIDER_DEFAULTS, + RECOMMENDED_PROVIDER_TYPES, type LlmConnection, type ProviderCatalogGroup, type ProviderType, @@ -48,16 +49,6 @@ const CATALOG_TABS: Array<{ id: CatalogCategory; label: string }> = [ { id: 'local', label: '本地' }, ]; -const RECOMMENDED_PROVIDER_TYPES: ProviderType[] = [ - 'siliconflow', - 'anthropic', - 'openai', - 'google', - 'kimi-coding-plan', - 'deepseek', - 'ollama', -]; - export function ProvidersPanel({ bridge }: { bridge: ConnectionsBridge }) { const [connections, setConnections] = useState([]); const [defaultSlug, setDefaultSlug] = useState(null); @@ -186,21 +177,12 @@ export function ProvidersPanel({ bridge }: { bridge: ConnectionsBridge }) { const configuredByType = (type: ProviderType) => connections.filter((connection) => connection.providerType === type).length; - function providerCatalogGroup(type: ProviderType): Exclude { - const definition = PROVIDER_DEFAULTS[type]; - if (definition.catalogGroup && definition.catalogGroup !== 'recommended') return definition.catalogGroup; - if (definition.category === 'local') return 'local'; - if (definition.catalogBadge === 'Coding') return 'plans'; - if (definition.category === 'custom') return 'aggregators'; - return 'api'; - } - function providersForCategory(category: CatalogCategory): ProviderType[] { const source = category === 'recommended' ? RECOMMENDED_PROVIDER_TYPES : CATALOG_PROVIDER_TYPES; const normalizedQuery = catalogQuery.trim().toLocaleLowerCase(); return source.filter((type) => { if (!CATALOG_PROVIDER_TYPES.includes(type)) return false; - if (category !== 'recommended' && providerCatalogGroup(type) !== category) return false; + if (category !== 'recommended' && PROVIDER_DEFAULTS[type].catalogGroup !== category) return false; if (!normalizedQuery) return true; const display = providerDisplay(type); return [type, display.name, display.description, PROVIDER_DEFAULTS[type].label] diff --git a/packages/core/src/__tests__/llm-connections.test.ts b/packages/core/src/__tests__/llm-connections.test.ts index 5d583855d6..963c071e22 100644 --- a/packages/core/src/__tests__/llm-connections.test.ts +++ b/packages/core/src/__tests__/llm-connections.test.ts @@ -14,12 +14,89 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { + CATALOG_PROVIDER_TYPES, PROVIDER_DEFAULTS, + PROVIDER_REGISTRY, + READY_PROVIDER_TYPES, + RECOMMENDED_PROVIDER_TYPES, normalizeConnectionBaseUrl, persistedBaseUrl, validateConnectionBaseUrl, } from '../llm-connections.js'; +describe('provider compatibility contract', () => { + it('keeps persisted provider ids and existing provider ordering stable', () => { + assert.deepEqual(Object.keys(PROVIDER_DEFAULTS), [ + 'anthropic', + 'kimi-coding-plan', + 'openai', + 'google', + 'deepseek', + 'moonshot', + 'zai-coding-plan', + 'MiniMax', + 'MiniMax-cn', + 'siliconflow', + 'litellm', + 'ollama', + 'openai-compatible', + 'claude-subscription', + 'codex-subscription', + 'gemini-cli', + ]); + assert.deepEqual(READY_PROVIDER_TYPES, [ + 'anthropic', + 'openai', + 'google', + 'deepseek', + 'moonshot', + 'zai-coding-plan', + 'MiniMax', + 'MiniMax-cn', + 'siliconflow', + 'ollama', + 'kimi-coding-plan', + 'openai-compatible', + ]); + assert.deepEqual(CATALOG_PROVIDER_TYPES, [ + 'kimi-coding-plan', + 'deepseek', + 'moonshot', + 'zai-coding-plan', + 'MiniMax', + 'MiniMax-cn', + 'siliconflow', + 'anthropic', + 'openai', + 'google', + 'ollama', + 'litellm', + 'openai-compatible', + ]); + }); + + it('derives catalog, recommendation, runtime, and discovery behavior from one registry', () => { + assert.equal(PROVIDER_DEFAULTS, PROVIDER_REGISTRY, 'the compatibility export must not copy registry state'); + assert.deepEqual(RECOMMENDED_PROVIDER_TYPES, [ + 'siliconflow', + 'anthropic', + 'openai', + 'google', + 'kimi-coding-plan', + 'deepseek', + 'ollama', + ]); + assert.equal(PROVIDER_REGISTRY['kimi-coding-plan'].catalogGroup, 'plans'); + assert.equal(PROVIDER_REGISTRY.siliconflow.catalogGroup, 'aggregators'); + assert.equal(PROVIDER_REGISTRY.ollama.catalogGroup, 'local'); + assert.equal(PROVIDER_REGISTRY.siliconflow.runtimeAdapter.kind, 'openai-compatible'); + assert.equal(PROVIDER_REGISTRY.siliconflow.modelDiscovery.kind, 'protocol'); + assert.deepEqual(PROVIDER_REGISTRY.siliconflow.modelDiscovery.query, { sub_type: 'chat' }); + assert.equal(PROVIDER_REGISTRY.ollama.modelDiscovery.kind, 'ollama'); + assert.equal(PROVIDER_REGISTRY['codex-subscription'].modelDiscovery.kind, 'fallback'); + }); +}); + describe('validateConnectionBaseUrl (PR-UI-IPC-1, @kenji msg 35260e29)', () => { describe('accept (returns null)', () => { it('undefined → null (no override; fall back to provider default)', () => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6a227681e0..5358b5cc24 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -766,8 +766,10 @@ export type { } from './llm-connections.js'; export { CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS, + PROVIDER_REGISTRY, PROVIDER_DEFAULTS, CATALOG_PROVIDER_TYPES, + RECOMMENDED_PROVIDER_TYPES, READY_PROVIDER_TYPES, backendKindOf, effectiveBaseUrl, diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index 7cc56efbe3..64e49e139a 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -6,31 +6,32 @@ */ import type { BackendKind } from './session.js'; -import { SILICONFLOW_PROVIDER_DEFAULTS } from './provider-registry.js'; +import { + CATALOG_PROVIDER_TYPES, + PROVIDER_REGISTRY, + READY_PROVIDER_TYPES, + RECOMMENDED_PROVIDER_TYPES, + type ProviderCatalogGroup, + type ProviderCategory, + type ProviderDefaults, + type ProviderRuntimeAdapter, + type ProviderType, +} from './provider-registry.js'; export type { BackendKind } from './session.js'; - -export type ProviderType = - | 'anthropic' - | 'kimi-coding-plan' - | 'openai' - | 'google' - | 'deepseek' - | 'moonshot' - | 'zai-coding-plan' - | 'MiniMax' - | 'MiniMax-cn' - | 'siliconflow' - | 'litellm' - | 'ollama' - | 'openai-compatible' - | 'claude-subscription' - | 'codex-subscription' - | 'gemini-cli'; - -export type ProviderCategory = 'oauth' | 'domestic' | 'overseas' | 'local' | 'custom'; -export type ProviderCatalogGroup = 'recommended' | 'plans' | 'api' | 'aggregators' | 'local'; -export type ProviderRuntimeAdapter = 'anthropic' | 'openai' | 'openai-compatible' | 'google'; +export { + CATALOG_PROVIDER_TYPES, + PROVIDER_REGISTRY, + READY_PROVIDER_TYPES, + RECOMMENDED_PROVIDER_TYPES, +}; +export type { + ProviderCatalogGroup, + ProviderCategory, + ProviderDefaults, + ProviderRuntimeAdapter, + ProviderType, +}; export type ConnectionAuth = | { kind: 'api_key'; apiKey: string } @@ -99,260 +100,11 @@ export interface ConnectionTestResult { errorClass?: ConnectionTestErrorClass; } -export interface ProviderDefaults { - label: string; - description: string; - baseUrl: string; - authKind: ConnectionAuth['kind']; - backendKind: BackendKind; - fallbackModels: string[]; - status: 'ready' | 'phase3-experimental'; - protocol: 'anthropic' | 'openai' | 'google'; - runtimeAdapter?: ProviderRuntimeAdapter; - category: ProviderCategory; - catalogGroup?: ProviderCatalogGroup; - catalogBadge?: string; - signupUrl?: string; - modelsDevId?: string; -} - export const CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS = new Set([ 'gpt-5-codex', ]); -export const PROVIDER_DEFAULTS: Record = { - anthropic: { - label: 'Anthropic', - description: 'Claude API key access for production agents.', - baseUrl: 'https://api.anthropic.com', - authKind: 'api_key', - backendKind: 'ai-sdk', - fallbackModels: [ - 'claude-sonnet-4-5-20250929', - 'claude-opus-4-1-20250805', - 'claude-haiku-4-5-20251001', - 'claude-3-5-haiku-20241022', - ], - status: 'ready', - protocol: 'anthropic', - category: 'overseas', - catalogBadge: 'API', - signupUrl: 'https://console.anthropic.com/settings/keys', - }, - 'kimi-coding-plan': { - label: 'Kimi Coding Plan', - description: 'Kimi for Coding over Anthropic-compatible protocol.', - baseUrl: 'https://api.kimi.com/coding/v1', - authKind: 'api_key', - backendKind: 'ai-sdk', - fallbackModels: ['kimi-for-coding'], - status: 'ready', - protocol: 'anthropic', - category: 'domestic', - catalogBadge: 'Coding', - signupUrl: 'https://www.kimi.com/code/console', - }, - openai: { - label: 'OpenAI', - description: 'GPT API key access, including Responses API models.', - baseUrl: 'https://api.openai.com/v1', - authKind: 'api_key', - backendKind: 'ai-sdk', - fallbackModels: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'gpt-5'], - status: 'ready', - protocol: 'openai', - category: 'overseas', - catalogBadge: 'API', - signupUrl: 'https://platform.openai.com/api-keys', - }, - google: { - label: 'Google Gemini', - description: 'Gemini API key access from Google AI Studio.', - baseUrl: 'https://generativelanguage.googleapis.com/v1beta', - authKind: 'api_key', - backendKind: 'ai-sdk', - fallbackModels: ['gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-1.5-pro'], - status: 'ready', - protocol: 'google', - category: 'overseas', - catalogBadge: 'API', - signupUrl: 'https://aistudio.google.com/app/apikey', - }, - deepseek: { - label: 'DeepSeek', - description: 'DeepSeek chat and reasoning models.', - baseUrl: 'https://api.deepseek.com', - authKind: 'api_key', - backendKind: 'ai-sdk', - fallbackModels: ['deepseek-chat', 'deepseek-reasoner'], - status: 'ready', - protocol: 'openai', - category: 'domestic', - catalogBadge: 'API', - signupUrl: 'https://platform.deepseek.com/api_keys', - }, - moonshot: { - label: 'Moonshot', - description: 'Moonshot Kimi API key access.', - baseUrl: 'https://api.moonshot.cn/v1', - authKind: 'api_key', - backendKind: 'ai-sdk', - fallbackModels: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'], - status: 'ready', - protocol: 'openai', - category: 'domestic', - catalogBadge: 'API', - signupUrl: 'https://platform.kimi.com/console/api-keys', - }, - 'zai-coding-plan': { - label: 'Z.AI Coding Plan', - description: 'GLM coding plan over OpenAI-compatible protocol.', - baseUrl: 'https://api.z.ai/api/coding/paas/v4', - authKind: 'api_key', - backendKind: 'ai-sdk', - fallbackModels: ['glm-4.7', 'glm-4.6', 'glm-4.5-air'], - status: 'ready', - protocol: 'openai', - category: 'domestic', - catalogBadge: 'Coding', - signupUrl: 'https://bigmodel.cn/usercenter/proj-mgmt/apikeys', - }, - MiniMax: { - label: 'MiniMax', - description: 'MiniMax M-series over Anthropic-compatible protocol.', - baseUrl: 'https://api.minimax.io/anthropic/v1', - authKind: 'api_key', - backendKind: 'ai-sdk', - fallbackModels: ['MiniMax-M3'], - status: 'ready', - protocol: 'anthropic', - category: 'overseas', - catalogBadge: 'API', - signupUrl: 'https://platform.minimax.io/user-center/basic-information/interface-key', - }, - 'MiniMax-cn': { - label: 'MiniMax 中国站', - description: 'MiniMax M-series (China) over Anthropic-compatible protocol.', - baseUrl: 'https://api.minimaxi.com/anthropic/v1', - authKind: 'api_key', - backendKind: 'ai-sdk', - fallbackModels: ['MiniMax-M3'], - status: 'ready', - protocol: 'anthropic', - category: 'domestic', - catalogBadge: 'API', - signupUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', - }, - siliconflow: SILICONFLOW_PROVIDER_DEFAULTS, - litellm: { - label: 'LiteLLM', - description: 'AI gateway proxy for 100+ LLM providers.', - baseUrl: 'http://localhost:4000/v1', - authKind: 'api_key', - backendKind: 'ai-sdk', - fallbackModels: [], - status: 'ready', - protocol: 'openai', - category: 'custom', - catalogBadge: 'Gateway', - signupUrl: 'https://docs.litellm.ai/', - }, - ollama: { - label: 'Ollama', - description: 'Local models from Ollama on localhost.', - baseUrl: 'http://localhost:11434/v1', - authKind: 'none', - backendKind: 'ai-sdk', - fallbackModels: ['llama3.2', 'qwen2.5-coder', 'gemma3'], - status: 'ready', - protocol: 'openai', - category: 'local', - catalogBadge: 'Local', - }, - 'openai-compatible': { - label: 'OpenAI-compatible (custom)', - description: 'Custom OpenAI-compatible endpoint or gateway.', - baseUrl: '', - authKind: 'api_key', - backendKind: 'ai-sdk', - fallbackModels: [], - status: 'ready', - protocol: 'openai', - category: 'custom', - catalogBadge: 'Custom', - }, - 'claude-subscription': { - label: 'Claude Subscription (Pro / Max OAuth)', - description: 'Claude app subscription auth path, hidden behind the internal experimental gate.', - baseUrl: 'https://api.anthropic.com', - authKind: 'oauth_token', - backendKind: 'ai-sdk', - fallbackModels: [ - 'claude-sonnet-4-5-20250929', - 'claude-opus-4-1-20250805', - 'claude-haiku-4-5-20251001', - ], - status: 'phase3-experimental', - protocol: 'anthropic', - category: 'oauth', - catalogBadge: 'Experimental', - }, - 'codex-subscription': { - label: 'OpenAI OAuth (ChatGPT / Codex)', - description: 'ChatGPT/Codex account OAuth path for OpenAI Responses models.', - baseUrl: 'https://chatgpt.com/backend-api/codex', - authKind: 'oauth_token', - backendKind: 'ai-sdk', - fallbackModels: ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex-spark'], - status: 'phase3-experimental', - protocol: 'openai', - category: 'oauth', - catalogBadge: 'Account', - }, - 'gemini-cli': { - label: 'Gemini CLI OAuth', - description: 'Google account path is tracked separately from ready API-key providers.', - baseUrl: '', - authKind: 'oauth_token', - backendKind: 'ai-sdk', - fallbackModels: ['gemini-2.5-pro', 'gemini-2.5-flash'], - status: 'phase3-experimental', - protocol: 'google', - category: 'oauth', - catalogBadge: 'Account', - }, -}; - -export const READY_PROVIDER_TYPES: ProviderType[] = [ - 'anthropic', - 'openai', - 'google', - 'deepseek', - 'moonshot', - 'zai-coding-plan', - 'MiniMax', - 'MiniMax-cn', - 'siliconflow', - 'ollama', - 'kimi-coding-plan', - 'openai-compatible', -]; - -export const CATALOG_PROVIDER_TYPES: ProviderType[] = [ - 'kimi-coding-plan', - 'deepseek', - 'moonshot', - 'zai-coding-plan', - 'MiniMax', - 'MiniMax-cn', - 'siliconflow', - 'anthropic', - 'openai', - 'google', - 'ollama', - 'litellm', - 'openai-compatible', -]; +export const PROVIDER_DEFAULTS = PROVIDER_REGISTRY; export function backendKindOf(c: Pick): BackendKind { return PROVIDER_DEFAULTS[c.providerType].backendKind; diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index 4408542401..3190ee458b 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -1,8 +1,52 @@ +import type { BackendKind } from './session.js'; import { GENERATED_MODELS_DEV_METADATA, GENERATED_MODELS_DEV_PROVIDER_FACTS, } from './model-metadata.generated.js'; -import type { ProviderDefaults } from './llm-connections.js'; + +export type ProviderCategory = 'oauth' | 'domestic' | 'overseas' | 'local' | 'custom'; +export type ProviderCatalogGroup = 'recommended' | 'plans' | 'api' | 'aggregators' | 'local'; + +export type ProviderRuntimeAdapter = + | { kind: 'anthropic'; auth: 'api-key' | 'bearer'; normalizeBaseUrl: boolean } + | { kind: 'claude-subscription' } + | { kind: 'openai' } + | { kind: 'codex-subscription' } + | { kind: 'google' } + | { + kind: 'openai-compatible'; + name: 'provider' | 'connection'; + apiKeyFallback?: string; + passFetch?: boolean; + requireBaseUrl?: boolean; + } + | { kind: 'unavailable' }; + +export type ProviderModelDiscovery = + | { kind: 'protocol'; auth?: 'claude-subscription'; query?: Readonly> } + | { kind: 'fallback' } + | { kind: 'ollama' }; + +export interface ProviderDefaults { + label: string; + description: string; + baseUrl: string; + authKind: 'api_key' | 'oauth_token' | 'none'; + backendKind: BackendKind; + fallbackModels: string[]; + status: 'ready' | 'phase3-experimental'; + protocol: 'anthropic' | 'openai' | 'google'; + runtimeAdapter: ProviderRuntimeAdapter; + modelDiscovery: ProviderModelDiscovery; + category: ProviderCategory; + catalogGroup?: ProviderCatalogGroup; + catalogBadge?: string; + signupUrl?: string; + modelsDevId?: string; + readyOrder?: number; + catalogOrder?: number; + recommendedOrder?: number; +} const siliconflow = GENERATED_MODELS_DEV_PROVIDER_FACTS.siliconflow; if (!siliconflow.api) throw new Error('models.dev SiliconFlow provider facts are missing api'); @@ -23,19 +67,309 @@ const siliconflowModelIds = orderedSiliconflowModels .filter(([, model]) => model.capabilities?.functionCalling) .map(([id]) => id); -export const SILICONFLOW_PROVIDER_DEFAULTS: ProviderDefaults = { - label: siliconflow.name, - description: 'Hosted multi-model API with exact upstream model ids.', - baseUrl: siliconflow.api, - authKind: 'api_key', - backendKind: 'ai-sdk', - fallbackModels: siliconflowModelIds, - status: 'ready', - protocol: 'openai', - runtimeAdapter: 'openai-compatible', - category: 'domestic', - catalogGroup: 'aggregators', - catalogBadge: 'Aggregator', - signupUrl: siliconflow.doc, - modelsDevId: siliconflow.id, -}; +const providerRegistry = { + anthropic: { + label: 'Anthropic', + description: 'Claude API key access for production agents.', + baseUrl: 'https://api.anthropic.com', + authKind: 'api_key', + backendKind: 'ai-sdk', + fallbackModels: [ + 'claude-sonnet-4-5-20250929', + 'claude-opus-4-1-20250805', + 'claude-haiku-4-5-20251001', + 'claude-3-5-haiku-20241022', + ], + status: 'ready', + protocol: 'anthropic', + runtimeAdapter: { kind: 'anthropic', auth: 'api-key', normalizeBaseUrl: true }, + modelDiscovery: { kind: 'protocol' }, + category: 'overseas', + catalogGroup: 'api', + catalogBadge: 'API', + signupUrl: 'https://console.anthropic.com/settings/keys', + readyOrder: 1, + catalogOrder: 8, + recommendedOrder: 2, + }, + 'kimi-coding-plan': { + label: 'Kimi Coding Plan', + description: 'Kimi for Coding over Anthropic-compatible protocol.', + baseUrl: 'https://api.kimi.com/coding/v1', + authKind: 'api_key', + backendKind: 'ai-sdk', + fallbackModels: ['kimi-for-coding'], + status: 'ready', + protocol: 'anthropic', + runtimeAdapter: { kind: 'anthropic', auth: 'api-key', normalizeBaseUrl: true }, + modelDiscovery: { kind: 'protocol' }, + category: 'domestic', + catalogGroup: 'plans', + catalogBadge: 'Coding', + signupUrl: 'https://www.kimi.com/code/console', + readyOrder: 11, + catalogOrder: 1, + recommendedOrder: 5, + }, + openai: { + label: 'OpenAI', + description: 'GPT API key access, including Responses API models.', + baseUrl: 'https://api.openai.com/v1', + authKind: 'api_key', + backendKind: 'ai-sdk', + fallbackModels: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'gpt-5'], + status: 'ready', + protocol: 'openai', + runtimeAdapter: { kind: 'openai' }, + modelDiscovery: { kind: 'protocol' }, + category: 'overseas', + catalogGroup: 'api', + catalogBadge: 'API', + signupUrl: 'https://platform.openai.com/api-keys', + readyOrder: 2, + catalogOrder: 9, + recommendedOrder: 3, + }, + google: { + label: 'Google Gemini', + description: 'Gemini API key access from Google AI Studio.', + baseUrl: 'https://generativelanguage.googleapis.com/v1beta', + authKind: 'api_key', + backendKind: 'ai-sdk', + fallbackModels: ['gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-1.5-pro'], + status: 'ready', + protocol: 'google', + runtimeAdapter: { kind: 'google' }, + modelDiscovery: { kind: 'protocol' }, + category: 'overseas', + catalogGroup: 'api', + catalogBadge: 'API', + signupUrl: 'https://aistudio.google.com/app/apikey', + readyOrder: 3, + catalogOrder: 10, + recommendedOrder: 4, + }, + deepseek: { + label: 'DeepSeek', + description: 'DeepSeek chat and reasoning models.', + baseUrl: 'https://api.deepseek.com', + authKind: 'api_key', + backendKind: 'ai-sdk', + fallbackModels: ['deepseek-chat', 'deepseek-reasoner'], + status: 'ready', + protocol: 'openai', + runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + modelDiscovery: { kind: 'protocol' }, + category: 'domestic', + catalogGroup: 'api', + catalogBadge: 'API', + signupUrl: 'https://platform.deepseek.com/api_keys', + readyOrder: 4, + catalogOrder: 2, + recommendedOrder: 6, + }, + moonshot: { + label: 'Moonshot', + description: 'Moonshot Kimi API key access.', + baseUrl: 'https://api.moonshot.cn/v1', + authKind: 'api_key', + backendKind: 'ai-sdk', + fallbackModels: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'], + status: 'ready', + protocol: 'openai', + runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + modelDiscovery: { kind: 'protocol' }, + category: 'domestic', + catalogGroup: 'api', + catalogBadge: 'API', + signupUrl: 'https://platform.kimi.com/console/api-keys', + readyOrder: 5, + catalogOrder: 3, + }, + 'zai-coding-plan': { + label: 'Z.AI Coding Plan', + description: 'GLM coding plan over OpenAI-compatible protocol.', + baseUrl: 'https://api.z.ai/api/coding/paas/v4', + authKind: 'api_key', + backendKind: 'ai-sdk', + fallbackModels: ['glm-4.7', 'glm-4.6', 'glm-4.5-air'], + status: 'ready', + protocol: 'openai', + runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + modelDiscovery: { kind: 'protocol' }, + category: 'domestic', + catalogGroup: 'plans', + catalogBadge: 'Coding', + signupUrl: 'https://bigmodel.cn/usercenter/proj-mgmt/apikeys', + readyOrder: 6, + catalogOrder: 4, + }, + MiniMax: { + label: 'MiniMax', + description: 'MiniMax M-series over Anthropic-compatible protocol.', + baseUrl: 'https://api.minimax.io/anthropic/v1', + authKind: 'api_key', + backendKind: 'ai-sdk', + fallbackModels: ['MiniMax-M3'], + status: 'ready', + protocol: 'anthropic', + runtimeAdapter: { kind: 'anthropic', auth: 'bearer', normalizeBaseUrl: false }, + modelDiscovery: { kind: 'protocol' }, + category: 'overseas', + catalogGroup: 'api', + catalogBadge: 'API', + signupUrl: 'https://platform.minimax.io/user-center/basic-information/interface-key', + readyOrder: 7, + catalogOrder: 5, + }, + 'MiniMax-cn': { + label: 'MiniMax 中国站', + description: 'MiniMax M-series (China) over Anthropic-compatible protocol.', + baseUrl: 'https://api.minimaxi.com/anthropic/v1', + authKind: 'api_key', + backendKind: 'ai-sdk', + fallbackModels: ['MiniMax-M3'], + status: 'ready', + protocol: 'anthropic', + runtimeAdapter: { kind: 'anthropic', auth: 'bearer', normalizeBaseUrl: false }, + modelDiscovery: { kind: 'protocol' }, + category: 'domestic', + catalogGroup: 'api', + catalogBadge: 'API', + signupUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', + readyOrder: 8, + catalogOrder: 6, + }, + siliconflow: { + label: siliconflow.name, + description: 'Hosted multi-model API with exact upstream model ids.', + baseUrl: siliconflow.api, + authKind: 'api_key', + backendKind: 'ai-sdk', + fallbackModels: siliconflowModelIds, + status: 'ready', + protocol: 'openai', + runtimeAdapter: { kind: 'openai-compatible', name: 'provider', passFetch: true }, + modelDiscovery: { kind: 'protocol', query: { sub_type: 'chat' } }, + category: 'domestic', + catalogGroup: 'aggregators', + catalogBadge: 'Aggregator', + signupUrl: siliconflow.doc, + modelsDevId: siliconflow.id, + readyOrder: 9, + catalogOrder: 7, + recommendedOrder: 1, + }, + litellm: { + label: 'LiteLLM', + description: 'AI gateway proxy for 100+ LLM providers.', + baseUrl: 'http://localhost:4000/v1', + authKind: 'api_key', + backendKind: 'ai-sdk', + fallbackModels: [], + status: 'ready', + protocol: 'openai', + runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + modelDiscovery: { kind: 'protocol' }, + category: 'custom', + catalogGroup: 'aggregators', + catalogBadge: 'Gateway', + signupUrl: 'https://docs.litellm.ai/', + catalogOrder: 12, + }, + ollama: { + label: 'Ollama', + description: 'Local models from Ollama on localhost.', + baseUrl: 'http://localhost:11434/v1', + authKind: 'none', + backendKind: 'ai-sdk', + fallbackModels: ['llama3.2', 'qwen2.5-coder', 'gemma3'], + status: 'ready', + protocol: 'openai', + runtimeAdapter: { kind: 'openai-compatible', name: 'provider', apiKeyFallback: 'ollama' }, + modelDiscovery: { kind: 'ollama' }, + category: 'local', + catalogGroup: 'local', + catalogBadge: 'Local', + readyOrder: 10, + catalogOrder: 11, + recommendedOrder: 7, + }, + 'openai-compatible': { + label: 'OpenAI-compatible (custom)', + description: 'Custom OpenAI-compatible endpoint or gateway.', + baseUrl: '', + authKind: 'api_key', + backendKind: 'ai-sdk', + fallbackModels: [], + status: 'ready', + protocol: 'openai', + runtimeAdapter: { kind: 'openai-compatible', name: 'connection', requireBaseUrl: true }, + modelDiscovery: { kind: 'protocol' }, + category: 'custom', + catalogGroup: 'aggregators', + catalogBadge: 'Custom', + readyOrder: 12, + catalogOrder: 13, + }, + 'claude-subscription': { + label: 'Claude Subscription (Pro / Max OAuth)', + description: 'Claude app subscription auth path, hidden behind the internal experimental gate.', + baseUrl: 'https://api.anthropic.com', + authKind: 'oauth_token', + backendKind: 'ai-sdk', + fallbackModels: [ + 'claude-sonnet-4-5-20250929', + 'claude-opus-4-1-20250805', + 'claude-haiku-4-5-20251001', + ], + status: 'phase3-experimental', + protocol: 'anthropic', + runtimeAdapter: { kind: 'claude-subscription' }, + modelDiscovery: { kind: 'protocol', auth: 'claude-subscription' }, + category: 'oauth', + catalogBadge: 'Experimental', + }, + 'codex-subscription': { + label: 'OpenAI OAuth (ChatGPT / Codex)', + description: 'ChatGPT/Codex account OAuth path for OpenAI Responses models.', + baseUrl: 'https://chatgpt.com/backend-api/codex', + authKind: 'oauth_token', + backendKind: 'ai-sdk', + fallbackModels: ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex-spark'], + status: 'phase3-experimental', + protocol: 'openai', + runtimeAdapter: { kind: 'codex-subscription' }, + modelDiscovery: { kind: 'fallback' }, + category: 'oauth', + catalogBadge: 'Account', + }, + 'gemini-cli': { + label: 'Gemini CLI OAuth', + description: 'Google account path is tracked separately from ready API-key providers.', + baseUrl: '', + authKind: 'oauth_token', + backendKind: 'ai-sdk', + fallbackModels: ['gemini-2.5-pro', 'gemini-2.5-flash'], + status: 'phase3-experimental', + protocol: 'google', + runtimeAdapter: { kind: 'unavailable' }, + modelDiscovery: { kind: 'protocol' }, + category: 'oauth', + catalogBadge: 'Account', + }, +} satisfies Record; + +export type ProviderType = keyof typeof providerRegistry; +export const PROVIDER_REGISTRY: Readonly> = providerRegistry; + +function providerTypesByOrder(field: 'readyOrder' | 'catalogOrder' | 'recommendedOrder'): ProviderType[] { + return (Object.entries(PROVIDER_REGISTRY) as Array<[ProviderType, ProviderDefaults]>) + .filter(([, provider]) => provider[field] !== undefined) + .sort(([, left], [, right]) => left[field]! - right[field]!) + .map(([providerType]) => providerType); +} + +export const READY_PROVIDER_TYPES = providerTypesByOrder('readyOrder'); +export const CATALOG_PROVIDER_TYPES = providerTypesByOrder('catalogOrder'); +export const RECOMMENDED_PROVIDER_TYPES = providerTypesByOrder('recommendedOrder'); diff --git a/packages/runtime/src/__tests__/claude-subscription-runtime.test.ts b/packages/runtime/src/__tests__/claude-subscription-runtime.test.ts index 0bb39c8c5f..fcec11567f 100644 --- a/packages/runtime/src/__tests__/claude-subscription-runtime.test.ts +++ b/packages/runtime/src/__tests__/claude-subscription-runtime.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { describe, test } from 'node:test'; -import type { LlmConnection } from '@maka/core'; +import { PROVIDER_REGISTRY, type LlmConnection } from '@maka/core'; import { testConnection } from '../test-connection.js'; describe('Claude subscription runtime wiring', () => { @@ -93,38 +93,34 @@ describe('Claude subscription runtime wiring', () => { assert.equal(anthropicV1BaseUrl('https://api.kimi.com/coding/'), 'https://api.kimi.com/coding/v1', 'override omitting /v1 gets it filled in'); }); - test('model factory routes Anthropic and Kimi sends through the /v1 base URL helper', async () => { + test('registry routes Anthropic and Kimi through the normalized API-key adapter', async () => { + assert.deepEqual(PROVIDER_REGISTRY.anthropic.runtimeAdapter, { + kind: 'anthropic', + auth: 'api-key', + normalizeBaseUrl: true, + }); + assert.deepEqual(PROVIDER_REGISTRY['kimi-coding-plan'].runtimeAdapter, { + kind: 'anthropic', + auth: 'api-key', + normalizeBaseUrl: true, + }); const src = await readFile(new URL('../../src/model-factory.ts', import.meta.url), 'utf8'); - // anthropic and kimi-coding-plan share one case body; slice the whole region - // up to the next distinct provider (MiniMax) so a shared case is not a failure. - const region = src.slice(src.indexOf("case 'anthropic'"), src.indexOf("case 'MiniMax'")); - assert.match(region, /baseURL:\s*anthropicV1BaseUrl\(baseURL\)/, 'Anthropic API-key and Kimi sends must use the SDK /v1 base URL'); + const region = src.slice(src.indexOf("case 'anthropic'"), src.indexOf("case 'claude-subscription'")); + assert.match(region, /adapter\.normalizeBaseUrl\s*\?\s*anthropicV1BaseUrl\(baseURL\)/); }); - test('model factory sends MiniMax over Bearer (authToken), not x-api-key', async () => { - const src = await readFile(new URL('../../src/model-factory.ts', import.meta.url), 'utf8'); - const caseIdx = src.indexOf("case 'MiniMax'"); - assert.notEqual(caseIdx, -1, 'MiniMax case must exist'); - const caseRegion = src.slice(caseIdx, src.indexOf("case 'claude-subscription'", caseIdx)); - assert.match( - caseRegion, - /createAnthropic\(\{[\s\S]*authToken:\s*apiKey/, - 'MiniMax must pass the key as authToken so the request uses Authorization: Bearer (MiniMax-recommended auth)', - ); - assert.doesNotMatch( - caseRegion, - /\n\s*apiKey,/, - 'MiniMax must not send the key as x-api-key via the bare apiKey shorthand', - ); - assert.match(caseRegion, /baseURL,/, 'MiniMax must keep its provider-specific Anthropic-compatible base URL'); - assert.doesNotMatch(caseRegion, /anthropicV1BaseUrl/, 'MiniMax base URL must not be rewritten to /v1'); + test('registry sends both MiniMax variants over Bearer without rewriting their base URL', () => { + const expected = { kind: 'anthropic', auth: 'bearer', normalizeBaseUrl: false }; + assert.deepEqual(PROVIDER_REGISTRY.MiniMax.runtimeAdapter, expected); + assert.deepEqual(PROVIDER_REGISTRY['MiniMax-cn'].runtimeAdapter, expected); }); test('model factory wires codex-subscription to OpenAI Responses with account-scoped fetch/header shape', async () => { const src = await readFile(new URL('../../src/model-factory.ts', import.meta.url), 'utf8'); + assert.deepEqual(PROVIDER_REGISTRY['codex-subscription'].runtimeAdapter, { kind: 'codex-subscription' }); const caseIdx = src.indexOf("case 'codex-subscription'"); assert.notEqual(caseIdx, -1, 'codex-subscription case must exist'); - const caseRegion = src.slice(caseIdx, src.indexOf("case 'gemini-cli'", caseIdx)); + const caseRegion = src.slice(caseIdx, src.indexOf("case 'unavailable'", caseIdx)); assert.match(caseRegion, /createOpenAI\(\{[\s\S]*apiKey/, 'Codex OAuth must use OpenAI client with OAuth token'); assert.match(caseRegion, /fetch,/, 'Codex OAuth must accept the desktop ChatGPT backend fetch wrapper'); assert.match(caseRegion, /codexSubscriptionHeaders\(apiKey\)/, 'Codex OAuth must attach account-scoped headers'); diff --git a/packages/runtime/src/__tests__/google-base-url.test.ts b/packages/runtime/src/__tests__/google-base-url.test.ts index 3dcd265554..731b4bba89 100644 --- a/packages/runtime/src/__tests__/google-base-url.test.ts +++ b/packages/runtime/src/__tests__/google-base-url.test.ts @@ -63,7 +63,7 @@ describe('model-factory Google chat wiring', () => { const src = await readFile(new URL('../../src/model-factory.ts', import.meta.url), 'utf8'); const caseIdx = src.indexOf("case 'google'"); assert.notEqual(caseIdx, -1, 'Google case must exist in model-factory'); - const caseRegion = src.slice(caseIdx, src.indexOf("case 'deepseek'", caseIdx)); + const caseRegion = src.slice(caseIdx, src.indexOf("case 'openai-compatible'", caseIdx)); assert.match( caseRegion, /baseURL:\s*googleV1BetaBaseUrl\(baseURL\)/, diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index 1d8f053eb1..6b44d28e22 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -25,37 +25,13 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV3 { const { connection, apiKey, modelId, fetch } = input; const baseURL = effectiveBaseUrl(connection); const definition = PROVIDER_DEFAULTS[connection.providerType]; + const adapter = definition.runtimeAdapter; - if (definition.runtimeAdapter === 'openai-compatible') { - return createOpenAICompatible({ - name: connection.providerType, - apiKey, - baseURL, - fetch, - }).chatModel(modelId); - } - - switch (connection.providerType) { + switch (adapter.kind) { case 'anthropic': - case 'kimi-coding-plan': - // Both send through the Anthropic SDK, normalizing the base URL to /v1 - // (anthropicV1BaseUrl) so a baseUrl override omitting `/v1` sends to - // `/v1/messages` instead of 404ing on `/messages`, matching - // the probe/model-fetch paths. - return createAnthropic({ - apiKey, - baseURL: anthropicV1BaseUrl(baseURL), - headers: { 'anthropic-beta': ANTHROPIC_BETA }, - }).chat(modelId); - - case 'MiniMax': - case 'MiniMax-cn': - // MiniMax's Anthropic-compatible API accepts both x-api-key and Bearer, - // but documents Bearer as recommended (and it takes precedence when both - // are sent), so pass the key as authToken to emit `Authorization: Bearer`. return createAnthropic({ - authToken: apiKey, - baseURL, + ...(adapter.auth === 'bearer' ? { authToken: apiKey } : { apiKey }), + baseURL: adapter.normalizeBaseUrl ? anthropicV1BaseUrl(baseURL) : baseURL, headers: { 'anthropic-beta': ANTHROPIC_BETA }, }).chat(modelId); @@ -75,7 +51,7 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV3 { headers: codexSubscriptionHeaders(apiKey), }).responses(modelId); - case 'gemini-cli': + case 'unavailable': throw new Error(`${connection.providerType} is experimental and not wired yet`); case 'openai': { @@ -85,56 +61,23 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV3 { } case 'google': - // Normalize to /v1beta so a baseUrl override omitting it still hits - // `/v1beta/models/{model}` instead of 404ing. - return createGoogleGenerativeAI({ apiKey, baseURL: googleV1BetaBaseUrl(baseURL) }).chat(modelId); - - case 'deepseek': - return createOpenAICompatible({ - name: 'deepseek', - apiKey, - baseURL: baseURL || 'https://api.deepseek.com', - }).chatModel(modelId); - - case 'moonshot': - return createOpenAICompatible({ - name: 'moonshot', - apiKey, - baseURL: baseURL || 'https://api.moonshot.cn/v1', - }).chatModel(modelId); - - case 'zai-coding-plan': - return createOpenAICompatible({ - name: 'zai-coding-plan', - apiKey, - baseURL: baseURL || 'https://api.z.ai/api/coding/paas/v4', - }).chatModel(modelId); - - case 'litellm': - return createOpenAICompatible({ - name: 'litellm', + return createGoogleGenerativeAI({ apiKey, - baseURL: baseURL || 'http://localhost:4000/v1', - }).chatModel(modelId); - - case 'ollama': - return createOpenAICompatible({ - name: 'ollama', - apiKey: apiKey || 'ollama', - baseURL: baseURL || 'http://localhost:11434/v1', - }).chatModel(modelId); + baseURL: googleV1BetaBaseUrl(baseURL), + }).chat(modelId); - case 'openai-compatible': - if (!baseURL) { - throw new Error(`openai-compatible connection ${connection.slug} requires a base URL`); + case 'openai-compatible': { + if (adapter.requireBaseUrl && !baseURL) { + throw new Error(`${connection.providerType} connection ${connection.slug} requires a base URL`); } + const name = adapter.name === 'connection' ? connection.slug : connection.providerType; return createOpenAICompatible({ - name: connection.slug, - apiKey, + name, + apiKey: adapter.apiKeyFallback ? apiKey || adapter.apiKeyFallback : apiKey, baseURL, + ...(adapter.passFetch ? { fetch } : {}), }).chatModel(modelId); - default: - throw new Error(`No runtime adapter configured for provider ${connection.providerType}`); + } } } diff --git a/packages/runtime/src/model-fetcher.ts b/packages/runtime/src/model-fetcher.ts index ce737b2f03..2c94761536 100644 --- a/packages/runtime/src/model-fetcher.ts +++ b/packages/runtime/src/model-fetcher.ts @@ -34,21 +34,23 @@ async function fetchProviderModelsStrict( apiKey: string, ): Promise { const baseUrl = effectiveBaseUrl(connection); - const auth = PROVIDER_DEFAULTS[connection.providerType].authKind; - if (connection.providerType === 'codex-subscription') { - return PROVIDER_DEFAULTS['codex-subscription'].fallbackModels.map((id) => ({ id })); + const definition = PROVIDER_DEFAULTS[connection.providerType]; + const discovery = definition.modelDiscovery; + + if (discovery.kind === 'fallback') { + return definition.fallbackModels.map((id) => ({ id })); } - if (connection.providerType === 'ollama') { + if (discovery.kind === 'ollama') { const r = await proxiedFetch(`${ollamaRoot(baseUrl)}/api/tags`, { timeoutMs: MODEL_FETCH_TIMEOUT_MS }); if (!r.ok) throw new Error(`HTTP ${r.status}`); const data = await r.json() as { models?: Array<{ name?: string }> }; return (data.models ?? []).flatMap((model) => model.name ? [{ id: model.name }] : []); } - switch (PROVIDER_DEFAULTS[connection.providerType].protocol) { + switch (definition.protocol) { case 'anthropic': { const r = await proxiedFetch(anthropicV1Url(baseUrl, '/models'), { - headers: anthropicModelHeaders(connection, apiKey), + headers: anthropicModelHeaders(discovery.auth, apiKey), timeoutMs: MODEL_FETCH_TIMEOUT_MS, }); if (!r.ok) throw new Error(`HTTP ${r.status}`); @@ -56,10 +58,10 @@ async function fetchProviderModelsStrict( return (data.data ?? []).map(toModelInfo).filter((model): model is ModelInfo => model !== null); } case 'openai': { - const r = await proxiedFetch(openAiModelListUrl(connection, baseUrl), { + const r = await proxiedFetch(modelListUrl(baseUrl, discovery.query), { headers: { 'content-type': 'application/json', - ...(auth === 'none' ? {} : { authorization: `Bearer ${apiKey}` }), + ...(definition.authKind === 'none' ? {} : { authorization: `Bearer ${apiKey}` }), }, timeoutMs: MODEL_FETCH_TIMEOUT_MS, }); @@ -82,9 +84,10 @@ async function fetchProviderModelsStrict( } } -function openAiModelListUrl(connection: LlmConnection, baseUrl: string): string { +function modelListUrl(baseUrl: string, query: Readonly> | undefined): string { const url = `${stripTrailing(baseUrl)}/models`; - return connection.providerType === 'siliconflow' ? `${url}?sub_type=chat` : url; + const search = query ? new URLSearchParams(query).toString() : ''; + return search ? `${url}?${search}` : url; } function toModelInfo(model: RawProviderModel): ModelInfo | null { @@ -99,8 +102,8 @@ function toModelInfo(model: RawProviderModel): ModelInfo | null { }; } -function anthropicModelHeaders(connection: LlmConnection, apiKey: string): Record { - if (connection.providerType === 'claude-subscription') { +function anthropicModelHeaders(auth: 'claude-subscription' | undefined, apiKey: string): Record { + if (auth === 'claude-subscription') { return { ...claudeSubscriptionHeaders(), Authorization: `Bearer ${apiKey}`,