diff --git a/CHANGELOG.md b/CHANGELOG.md index ea9657e..9ca794c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable user-facing changes should be documented in this file. This changelog is maintained from source. Published release artifacts and tags remain the source of truth for shipped binaries and npm packages. +## Unreleased + +### Added + +- **Custom API-key providers without code changes**: define OpenAI-compatible vendors in `~/.cligate/provider-presets.json` (same schema as the built-in presets — `id`, `label`, `baseUrl`, `apiFormat`, optional `models`/`tiers`/`nativeModelPrefixes`/`pricing`/`validateModel`) and they become first-class everywhere: the add-key form, model mapping with their own tier maps, model discovery, protocol compatibility, pricing. Invalid entries are skipped with a warning; ids colliding with built-ins are rejected. + +### Changed + +- Zhipu (GLM), Moonshot (Kimi), and MiniMax are now declarative presets served by the generic OpenAI-compatible provider instead of bespoke classes (behavior preserved: same base URLs, ids, and pricing; Zhipu key validation probes a 1-token chat request via the new preset `validateModel` field, since Zhipu serves no `/models` endpoint). +- Zhipu/Moonshot/MiniMax keys now also serve `/v1/chat/completions` and the Codex Responses fallback (they previously only served `/v1/messages`). +- Zhipu/Moonshot/MiniMax now have their own tier maps and model lists, so non-native models are remapped instead of passing through verbatim, and the model-mapping page gives each its own editor instead of folding them into the OpenAI one (which the runtime never consulted for these keys). + ## 1.3.1 - 2026-06-14 ### Changed diff --git a/docs/CUSTOM_PROVIDERS.md b/docs/CUSTOM_PROVIDERS.md new file mode 100644 index 0000000..9f1d8a9 --- /dev/null +++ b/docs/CUSTOM_PROVIDERS.md @@ -0,0 +1,73 @@ +# Custom API-Key Providers + +Add an OpenAI-compatible vendor **without touching the code**: describe it in +`~/.cligate/provider-presets.json` and it becomes a first-class provider — +selectable in the add-key form, visible on the model-mapping page with its own +tier map, included in model discovery, protocol compatibility, and pricing. + +## Quick start + +`~/.cligate/provider-presets.json` (full file, not a merge — include every +custom provider you want): + +```json +[ + { + "id": "clinepass", + "label": "ClinePass", + "baseUrl": "https://api.clinepass.example/v1", + "models": ["vendor-x", "vendor-y"], + "tiers": { + "flagship": "vendor-x", + "standard": "vendor-y", + "fast": "vendor-y", + "reasoning": "vendor-x" + }, + "nativeModelPrefixes": ["vendor-"] + } +] +``` + +Restart the server. The new type appears in the dashboard's **Add API Key** +form; keys you add with it route through the generic OpenAI-compatible +provider. `nativeModelPrefixes` keeps ids the caller explicitly chose +(`vendor-x`) from being tier-remapped; everything else (e.g. an incoming +`claude-sonnet-4-6` from Claude Code) resolves through the tier map. + +## Fields + +Same schema as the built-in presets in `src/providers/provider-presets.js`: + +| Field | Required | Meaning | +|----------------------|----------|---------| +| `id` | yes | Provider type key. Lowercase letters/digits/`.`/`_`/`-`; must not collide with a built-in id (`qwen`, `openrouter`, `zhipu`, `moonshot`, `minimax`, `clinepass`, `ollama-cloud`, `openai`, `anthropic`, `gemini`, `azure-openai`, `vertex-ai`, `deepseek`). | +| `baseUrl` | yes | Upstream API base URL (chat-completions compatible). | +| `label`, `badge` | no | Display name (defaults to `id`) and short UI tag (defaults to first two chars uppercased). `labelKey` adds i18n. | +| `apiFormat` | no | `openai_chat` (default) \| `openai_responses` \| `anthropic` \| `gemini_native`. `openai_chat` vendors serve all three gateway surfaces (chat, Codex Responses fallback, Claude Code bridge). | +| `website`, `keyHint` | no | Console link and key placeholder shown in the add-key form. | +| `models` | no | Static model ids for UI dropdowns and tier auto-fill (supplemented by discovery when the vendor serves `/models`). | +| `tiers` | no | `{ flagship, standard, fast, reasoning }` → model id. Unknown tier keys are rejected. | +| `nativeModelPrefixes`| no | Model ids starting with any prefix pass through without tier remap. | +| `passthroughModelMatch` | no | Model ids containing this substring pass through verbatim (e.g. `"/"` for `vendor/model` slugs). | +| `pricing` | no | `{ "": { input, output, cacheRead?, cacheWrite? } }` in USD / 1M tokens. | +| `validateModel` | no | For vendors without `GET /models` (e.g. Zhipu): "test key" probes a 1-token chat request with this model instead. | + +## Validation and error handling + +The loader runs at server start. A bad file never blocks startup — each +invalid entry is skipped with a console warning explaining why: + +- collides with a built-in id → skipped (built-ins always win), +- invalid id shape / missing `baseUrl` / unknown `apiFormat` / unknown tier key → skipped, +- duplicate ids → first entry wins. + +The file must be a JSON array (or `{"presets": [...]}`). + +## Limitations + +- Only `openai_chat` user presets are wired into the protocol-compatibility + sets today (`openai_responses` / `anthropic` / `gemini_native` formats are + accepted but only serve the surfaces the generic provider implements). +- Model discovery probes one provider per type; with a custom `id` your vendor + is always probed — the limitation only affects two keys of the *same* type + with different base URLs. diff --git a/public/js/i18n.js b/public/js/i18n.js index 18ecb60..baf99d6 100644 --- a/public/js/i18n.js +++ b/public/js/i18n.js @@ -1062,6 +1062,11 @@ export const i18n = { provider: 'Provider', providerQwen: 'Qwen', providerOpenrouter: 'OpenRouter', + providerZhipu: 'Zhipu (GLM)', + providerMoonshot: 'Moonshot (Kimi)', + providerMinimax: 'MiniMax', + providerClinepass: 'ClinePass', + providerOllamaCloud: 'Ollama Cloud', apiKeyLabel: 'API Key', keyName: 'Name', totalKeysLabel: 'Total Keys', @@ -2330,6 +2335,11 @@ export const i18n = { provider: '供应商', providerQwen: '通义千问', providerOpenrouter: 'OpenRouter', + providerZhipu: '智谱 (GLM)', + providerMoonshot: '月之暗面 (Kimi)', + providerMinimax: 'MiniMax', + providerClinepass: 'ClinePass', + providerOllamaCloud: 'Ollama Cloud', apiKeyLabel: 'API 密钥', keyName: '名称', totalKeysLabel: '总密钥数', diff --git a/public/js/modules/settings-page.js b/public/js/modules/settings-page.js index af70dbd..1949ddd 100644 --- a/public/js/modules/settings-page.js +++ b/public/js/modules/settings-page.js @@ -1033,11 +1033,11 @@ export function createSettingsPageModule() { }, normalizeModelMappingProvider(type) { + // Provider identity must match runtime resolveModel(providerType, ...) + // 1:1 — collapsing zhipu/moonshot/minimax into 'openai' made the mapping + // page edit a tier map the runtime never consulted for those keys. switch (type) { case 'openai': - case 'moonshot': - case 'minimax': - case 'zhipu': return 'openai'; case 'anthropic': return 'anthropic'; diff --git a/src/api-key-manager.js b/src/api-key-manager.js index 81da676..06e4425 100644 --- a/src/api-key-manager.js +++ b/src/api-key-manager.js @@ -12,24 +12,21 @@ import { AnthropicProvider } from './providers/anthropic.js'; import { GeminiProvider } from './providers/gemini.js'; import { AzureOpenAIProvider } from './providers/azure-openai.js'; import { VertexAIProvider } from './providers/vertex-ai.js'; -import { MiniMaxProvider } from './providers/minimax.js'; -import { MoonshotProvider } from './providers/moonshot.js'; -import { ZhipuProvider } from './providers/zhipu.js'; import { DeepSeekProvider } from './providers/deepseek.js'; import { PROVIDER_PRESETS } from './providers/provider-presets.js'; import { makeOpenAICompatibleProvider } from './providers/openai-compatible.js'; const API_KEYS_FILE = join(CONFIG_DIR, 'api-keys.json'); +// zhipu/moonshot/minimax used to be bespoke classes here; they are now +// declarative presets (provider-presets.js) served by the generic +// OpenAI-compatible class, like qwen and openrouter before them. const PROVIDER_CLASSES = { openai: OpenAIProvider, anthropic: AnthropicProvider, gemini: GeminiProvider, 'azure-openai': AzureOpenAIProvider, 'vertex-ai': VertexAIProvider, - minimax: MiniMaxProvider, - moonshot: MoonshotProvider, - zhipu: ZhipuProvider, deepseek: DeepSeekProvider }; diff --git a/src/pricing-registry.js b/src/pricing-registry.js index 7c06bc9..95df518 100644 --- a/src/pricing-registry.js +++ b/src/pricing-registry.js @@ -68,8 +68,9 @@ const DEFAULT_PRICING = { 'claude-haiku-4-5': { input: 1.00, output: 5.00, cacheRead: 0, cacheWrite: 0 } }, minimax: { - 'MiniMax-M2.7': { input: 0.30, output: 1.20, cacheRead: 0, cacheWrite: 0 }, - 'MiniMax-M2.7-highspeed': { input: 0.30, output: 2.40, cacheRead: 0, cacheWrite: 0 }, + 'MiniMax-M3': { input: 0.30, output: 1.20, cacheRead: 0.06, cacheWrite: 0 }, + 'MiniMax-M2.7': { input: 0.30, output: 1.20, cacheRead: 0.06, cacheWrite: 0 }, + 'MiniMax-M2.7-highspeed': { input: 0.60, output: 2.40, cacheRead: 0, cacheWrite: 0 }, 'MiniMax-M2.5': { input: 0.30, output: 1.20, cacheRead: 0, cacheWrite: 0 }, 'MiniMax-M2.5-highspeed': { input: 0.30, output: 2.40, cacheRead: 0, cacheWrite: 0 }, 'MiniMax-M2.1': { input: 0.20, output: 0.80, cacheRead: 0, cacheWrite: 0 }, @@ -77,18 +78,19 @@ const DEFAULT_PRICING = { 'MiniMax-M2': { input: 0.15, output: 0.60, cacheRead: 0, cacheWrite: 0 } }, moonshot: { - 'kimi-k2.5': { input: 0.60, output: 2.50, cacheRead: 0, cacheWrite: 0 }, - 'kimi-k2-thinking': { input: 0.60, output: 2.50, cacheRead: 0, cacheWrite: 0 }, - 'kimi-k2-thinking-turbo': { input: 0.30, output: 1.20, cacheRead: 0, cacheWrite: 0 } + 'kimi-k3': { input: 3.00, output: 15.00, cacheRead: 0.30, cacheWrite: 0 }, + 'kimi-k2.7-code': { input: 0.95, output: 4.00, cacheRead: 0.19, cacheWrite: 0 }, + 'kimi-k2.7-code-highspeed': { input: 1.90, output: 8.00, cacheRead: 0.38, cacheWrite: 0 }, + 'kimi-k2.6': { input: 0.95, output: 4.00, cacheRead: 0.16, cacheWrite: 0 } }, zhipu: { - 'glm-5': { input: 0.72, output: 2.30, cacheRead: 0, cacheWrite: 0 }, - 'glm-5-turbo': { input: 0.36, output: 1.15, cacheRead: 0, cacheWrite: 0 }, + 'glm-5.3': { input: 1.40, output: 4.40, cacheRead: 0.26, cacheWrite: 0 }, + 'glm-5.3-flash': { input: 0.15, output: 0.50, cacheRead: 0.03, cacheWrite: 0 }, + 'glm-5.2': { input: 1.40, output: 4.40, cacheRead: 0.26, cacheWrite: 0 }, + 'glm-5': { input: 1.00, output: 3.20, cacheRead: 0.20, cacheWrite: 0 }, 'glm-4.7': { input: 0.40, output: 1.20, cacheRead: 0, cacheWrite: 0 }, - 'glm-4-plus': { input: 0.30, output: 0.90, cacheRead: 0, cacheWrite: 0 }, - 'glm-4-air': { input: 0.07, output: 0.07, cacheRead: 0, cacheWrite: 0 }, - 'glm-4-airx': { input: 0.14, output: 0.14, cacheRead: 0, cacheWrite: 0 }, - 'glm-4-flash': { input: 0.01, output: 0.01, cacheRead: 0, cacheWrite: 0 } + 'glm-4.7-flash': { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + 'glm-4-flash': { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }, deepseek: { 'deepseek-v4-flash': { input: 0.27, output: 1.10, cacheRead: 0.07, cacheWrite: 0 }, diff --git a/src/providers/minimax.js b/src/providers/minimax.js deleted file mode 100644 index 7cfcbbb..0000000 --- a/src/providers/minimax.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * MiniMax Provider - * Forwards requests to MiniMax API (OpenAI-compatible). - * https://platform.minimax.io/docs/api-reference/text-openai-api - */ - -import { OpenAIProvider } from './openai.js'; -import { estimateCostWithRegistry, getDefaultPricing } from '../pricing-registry.js'; - -const DEFAULT_BASE_URL = 'https://api.minimax.io/v1'; - -export class MiniMaxProvider extends OpenAIProvider { - constructor(config) { - super({ - ...config, - baseUrl: config.baseUrl || DEFAULT_BASE_URL, - }); - this.type = 'minimax'; - } - - estimateCost(model, inputTokens, outputTokens) { - return estimateCostWithRegistry(this.type, model, inputTokens, outputTokens); - } - - static get pricing() { - return getDefaultPricing('minimax'); - } -} - -export default MiniMaxProvider; diff --git a/src/providers/moonshot.js b/src/providers/moonshot.js deleted file mode 100644 index cea5a0a..0000000 --- a/src/providers/moonshot.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Moonshot Provider (Kimi) - * Forwards requests to Moonshot AI API (OpenAI-compatible). - * https://platform.moonshot.ai/docs/api/chat - */ - -import { OpenAIProvider } from './openai.js'; -import { estimateCostWithRegistry, getDefaultPricing } from '../pricing-registry.js'; - -const DEFAULT_BASE_URL = 'https://api.moonshot.ai/v1'; - -export class MoonshotProvider extends OpenAIProvider { - constructor(config) { - super({ - ...config, - baseUrl: config.baseUrl || DEFAULT_BASE_URL, - }); - this.type = 'moonshot'; - } - - estimateCost(model, inputTokens, outputTokens) { - return estimateCostWithRegistry(this.type, model, inputTokens, outputTokens); - } - - static get pricing() { - return getDefaultPricing('moonshot'); - } -} - -export default MoonshotProvider; diff --git a/src/providers/openai-compatible.js b/src/providers/openai-compatible.js index ce555f0..5ae7810 100644 --- a/src/providers/openai-compatible.js +++ b/src/providers/openai-compatible.js @@ -68,6 +68,24 @@ export function makeOpenAICompatibleProvider(preset) { return estimateCostWithRegistry(this.type, model, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens); } + // Vendors without a GET /models endpoint declare a `validateModel` in + // their preset; validation then probes a 1-token chat request instead. + async validateKey() { + if (!preset.validateModel) { + return super.validateKey(); + } + try { + const response = await this.sendRequest({ + model: preset.validateModel, + messages: [{ role: 'user', content: 'hi' }], + max_tokens: 1, + }); + return response.status !== 401 && response.status !== 403; + } catch { + return false; + } + } + static get pricing() { return getDefaultPricing(preset.id); } diff --git a/src/providers/provider-presets.js b/src/providers/provider-presets.js index f351052..e617431 100644 --- a/src/providers/provider-presets.js +++ b/src/providers/provider-presets.js @@ -6,6 +6,11 @@ * `apiFormat`, not a bespoke class. Adding an OpenAI-compatible vendor is a * single object here — no new class, no scattered edits. * + * User-defined presets: entries in `~/.cligate/provider-presets.json` are + * merged after the built-ins at module load, so adding a vendor at runtime is + * a JSON file — no code changes, no restart beyond server reload. Built-in ids + * are protected: a user preset that collides with one is skipped with a warning. + * * SCOPE (this batch): Qwen (DashScope compatible-mode) and OpenRouter, both * `openai_chat`. They are intentionally exposed ONLY on the OpenAI chat path * (`/v1/chat/completions` + gateway). The generic provider class disables the @@ -28,8 +33,14 @@ * pricing object? { : { input, output, cacheRead?, cacheWrite? } } USD / 1M tokens * nativeModelPrefixes array? model ids starting with any prefix are passed through (no tier remap) * passthroughModelMatch string? if a model id includes this substring, pass it through (e.g. '/') + * validateModel string? vendors without GET /models: validateKey sends a 1-token + * chat request with this model instead (e.g. Zhipu) */ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { CONFIG_DIR } from '../account-manager.js'; + export const PROVIDER_PRESETS = [ { id: 'qwen', @@ -83,8 +94,286 @@ export const PROVIDER_PRESETS = [ reasoning: 'deepseek/deepseek-r1', }, }, + { + // Migrated from the bespoke ZhipuProvider class. ZhipuAI serves no GET + // /models, so validation probes a 1-token chat request and discovery + // relies on the static model list below. Model ids verified against the + // OpenRouter z-ai listing (2026-10). + id: 'zhipu', + label: 'Zhipu (GLM)', + labelKey: 'providerZhipu', + badge: 'GLM', + apiFormat: 'openai_chat', + baseUrl: 'https://open.bigmodel.cn/api/paas/v4', + website: 'https://open.bigmodel.cn', + keyHint: '...', + nativeModelPrefixes: ['glm'], + validateModel: 'glm-4-flash', + models: ['glm-5.3', 'glm-5.3-flash', 'glm-5.2', 'glm-5', 'glm-4.7', 'glm-4.7-flash', 'glm-4-flash'], + tiers: { + flagship: 'glm-5.3', + standard: 'glm-5.2', + fast: 'glm-5.3-flash', + reasoning: 'glm-5.3', + }, + }, + { + // Migrated from the bespoke MoonshotProvider class. Model ids verified + // against the OpenRouter moonshotai listing (2026-10). + id: 'moonshot', + label: 'Moonshot (Kimi)', + labelKey: 'providerMoonshot', + badge: 'KIMI', + apiFormat: 'openai_chat', + baseUrl: 'https://api.moonshot.ai/v1', + website: 'https://platform.moonshot.ai', + keyHint: 'sk-...', + nativeModelPrefixes: ['kimi', 'moonshot'], + models: ['kimi-k3', 'kimi-k2.7-code', 'kimi-k2.7-code-highspeed', 'kimi-k2.6'], + tiers: { + flagship: 'kimi-k3', + standard: 'kimi-k2.6', + fast: 'kimi-k2.7-code-highspeed', + reasoning: 'kimi-k3', + }, + }, + { + // Migrated from the bespoke MiniMaxProvider class. Model ids verified + // against the OpenRouter minimax listing (2026-10): m3 is the current + // flagship, m2.7 the current workhorse. + id: 'minimax', + label: 'MiniMax', + labelKey: 'providerMinimax', + badge: 'MM', + apiFormat: 'openai_chat', + baseUrl: 'https://api.minimax.io/v1', + website: 'https://platform.minimax.io', + keyHint: '...', + nativeModelPrefixes: ['minimax'], + models: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2'], + tiers: { + flagship: 'MiniMax-M3', + standard: 'MiniMax-M2.7', + fast: 'MiniMax-M2.7-highspeed', + reasoning: 'MiniMax-M3', + }, + }, + { + // ClinePass — Cline's flat subscription ($9.99/mo) for open-weight models + // (GLM, Kimi, DeepSeek, MiMo, MiniMax, Qwen) via api.cline.bot. OpenAI + // chat-compatible only upstream — no native Anthropic endpoint — but the + // generic provider's Anthropic bridge serves /v1/messages anyway, so + // Claude Code clients work through translation. Every model id carries the + // mandatory `cline-pass/` slug prefix upstream, so native passthrough is a + // single prefix. Prices in `pricing` are reference rates used for quota + // measurement, not per-token billing (the subscription is flat). + // https://docs.cline.bot/getting-started/clinepass + id: 'clinepass', + label: 'ClinePass', + labelKey: 'providerClinepass', + badge: 'CPASS', + apiFormat: 'openai_chat', + baseUrl: 'https://api.cline.bot/api/v1', + website: 'https://app.cline.bot', + keyHint: 'API key (app.cline.bot → Settings → API Keys)', + nativeModelPrefixes: ['cline-pass/'], + models: [ + 'cline-pass/glm-5.3', + 'cline-pass/glm-5.3-flash', + 'cline-pass/kimi-k3', + 'cline-pass/deepseek-v4-pro', + 'cline-pass/deepseek-v4.1-flash', + 'cline-pass/mimo-v2.5', + 'cline-pass/mimo-v2.5-pro', + 'cline-pass/minimax-m3', + 'cline-pass/qwen3.7-max', + 'cline-pass/qwen3.7-plus', + 'cline-pass/qwen3.8-max', + 'cline-pass/muse-spark-1.3-contributor', + ], + tiers: { + flagship: 'cline-pass/kimi-k3', + standard: 'cline-pass/glm-5.3', + fast: 'cline-pass/glm-5.3-flash', + reasoning: 'cline-pass/mimo-v2.5-pro', + }, + pricing: { + 'cline-pass/glm-5.3': { input: 1.40, output: 4.40, cacheRead: 0.26 }, + 'cline-pass/glm-5.3-flash': { input: 0.15, output: 0.50, cacheRead: 0.03 }, + 'cline-pass/kimi-k3': { input: 3.00, output: 15.00, cacheRead: 0.30 }, + 'cline-pass/deepseek-v4-pro': { input: 1.32, output: 3.96, cacheRead: 0.044 }, + 'cline-pass/deepseek-v4.1-flash': { input: 0.30, output: 1.20, cacheRead: 0.006 }, + 'cline-pass/mimo-v2.5': { input: 0.14, output: 0.28, cacheRead: 0.0028 }, + 'cline-pass/mimo-v2.5-pro': { input: 1.74, output: 3.48, cacheRead: 0.0145 }, + 'cline-pass/minimax-m3': { input: 0.30, output: 1.20, cacheRead: 0.06 }, + 'cline-pass/qwen3.7-max': { input: 2.50, output: 7.50, cacheRead: 0.50, cacheWrite: 3.125 }, + 'cline-pass/qwen3.7-plus': { input: 0.40, output: 1.60, cacheRead: 0.04, cacheWrite: 0.50 }, + 'cline-pass/qwen3.8-max': { input: 2.00, output: 6.00, cacheRead: 0.25, cacheWrite: 2.50 }, + 'cline-pass/muse-spark-1.3-contributor': { input: 0.10, output: 0.20, cacheRead: 0.002 }, + }, + }, + { + // Ollama Cloud — hosted models served from ollama.com (no local install + // needed, unlike the local Ollama runtime which is a separate mechanism + // and owns the `ollama` provider id — hence `ollama-cloud` here). + // OpenAI-compatible /v1 (chat/completions + /models). Cloud model ids mix + // bare vendor names (glm-5.3, kimi-k3) and tagged ones (gpt-oss:120b), so + // passthrough combines a family-prefix list (kept exact — `gpt-oss`, not + // `gpt`, or incoming gpt-5.x from Codex would stop remapping) with a `:` + // match for tag-style ids. Prices from ollama.com (USD / 1M tokens). + // https://docs.ollama.com/api/openai-compatibility + id: 'ollama-cloud', + label: 'Ollama Cloud', + labelKey: 'providerOllamaCloud', + badge: 'OLLAMA', + apiFormat: 'openai_chat', + baseUrl: 'https://ollama.com/v1', + website: 'https://ollama.com', + keyHint: 'API key (ollama.com/settings/keys)', + nativeModelPrefixes: ['gpt-oss', 'glm', 'kimi', 'deepseek', 'minimax', 'gemma', 'mistral', 'nemotron', 'qwen', 'llama', 'phi', 'granite'], + passthroughModelMatch: ':', + models: [ + 'glm-5.3', + 'glm-5.3-flash', + 'kimi-k3', + 'deepseek-v4-pro', + 'glm-5.2', + 'kimi-k2.7-code', + 'kimi-k2.6', + 'deepseek-v4.1-flash', + 'minimax-m3', + 'minimax-m2.7', + 'gpt-oss:120b', + 'gpt-oss:20b', + 'gemma4', + 'mistral-large-4', + 'nemotron-3-ultra', + 'nemotron-3-super', + 'nemotron-3-nano', + ], + tiers: { + flagship: 'kimi-k3', + standard: 'glm-5.3', + fast: 'glm-5.3-flash', + reasoning: 'deepseek-v4-pro', + }, + pricing: { + 'glm-5.3': { input: 1.40, output: 4.40, cacheRead: 0.26 }, + 'glm-5.3-flash': { input: 0.15, output: 0.50, cacheRead: 0.03 }, + 'glm-5.2': { input: 1.40, output: 4.40, cacheRead: 0.26 }, + 'kimi-k3': { input: 3.00, output: 15.00, cacheRead: 0.30 }, + 'kimi-k2.7-code': { input: 0.95, output: 4.00, cacheRead: 0.19 }, + 'kimi-k2.6': { input: 0.95, output: 4.00, cacheRead: 0.16 }, + 'deepseek-v4-pro': { input: 1.32, output: 3.96, cacheRead: 0.044 }, + 'deepseek-v4.1-flash': { input: 0.30, output: 1.20, cacheRead: 0.006 }, + 'minimax-m3': { input: 0.60, output: 2.40, cacheRead: 0.12 }, + 'minimax-m2.7': { input: 0.30, output: 1.20, cacheRead: 0.06 }, + 'gpt-oss:120b': { input: 0.15, output: 0.60, cacheRead: 0.014 }, + 'gpt-oss:20b': { input: 0.07, output: 0.30, cacheRead: 0.035 }, + 'gemma4': { input: 0.14, output: 0.40, cacheRead: 0.05 }, + 'mistral-large-4': { input: 0.68, output: 2.09, cacheRead: 0.07 }, + 'nemotron-3-ultra': { input: 0.10, output: 3.00, cacheRead: 0.10 }, + 'nemotron-3-super': { input: 0.015, output: 0.60, cacheRead: 0.015 }, + 'nemotron-3-nano': { input: 0.06, output: 0.24, cacheRead: 0 }, + }, + }, ]; +// ─── User-defined presets (~/.cligate/provider-presets.json) ──────────────── + +const USER_PRESETS_FILE = join(CONFIG_DIR, 'provider-presets.json'); +const PRESET_API_FORMATS = new Set(['openai_chat', 'openai_responses', 'anthropic', 'gemini_native']); +const PRESET_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; +const PRESET_TIER_KEYS = ['flagship', 'standard', 'fast', 'reasoning']; + +function userPresetProblem(entry) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + return 'entry must be an object'; + } + if (typeof entry.id !== 'string' || !PRESET_ID_PATTERN.test(entry.id)) { + return `invalid id "${entry.id}" (expected lowercase letters/digits/dots/dashes)`; + } + if (BUILTIN_PRESET_IDS.has(entry.id)) { + return `id "${entry.id}" collides with a built-in preset and is ignored`; + } + if (typeof entry.baseUrl !== 'string' || !entry.baseUrl.trim()) { + return `preset "${entry.id}": baseUrl is required`; + } + if (entry.apiFormat !== undefined && !PRESET_API_FORMATS.has(entry.apiFormat)) { + return `preset "${entry.id}": apiFormat must be one of ${[...PRESET_API_FORMATS].join(', ')}`; + } + if (entry.models !== undefined && (!Array.isArray(entry.models) || entry.models.some((m) => typeof m !== 'string'))) { + return `preset "${entry.id}": models must be an array of strings`; + } + if (entry.tiers !== undefined) { + if (!entry.tiers || typeof entry.tiers !== 'object' || Array.isArray(entry.tiers)) { + return `preset "${entry.id}": tiers must be an object`; + } + for (const [tier, model] of Object.entries(entry.tiers)) { + if (!PRESET_TIER_KEYS.includes(tier)) return `preset "${entry.id}": unknown tier "${tier}"`; + if (typeof model !== 'string' || !model.trim()) return `preset "${entry.id}": tier "${tier}" must map to a model id`; + } + } + if (entry.nativeModelPrefixes !== undefined + && (!Array.isArray(entry.nativeModelPrefixes) || entry.nativeModelPrefixes.some((p) => typeof p !== 'string'))) { + return `preset "${entry.id}": nativeModelPrefixes must be an array of strings`; + } + if (entry.passthroughModelMatch !== undefined && typeof entry.passthroughModelMatch !== 'string') { + return `preset "${entry.id}": passthroughModelMatch must be a string`; + } + if (entry.validateModel !== undefined && (typeof entry.validateModel !== 'string' || !entry.validateModel.trim())) { + return `preset "${entry.id}": validateModel must be a non-empty string`; + } + return null; +} + +function normalizeUserPreset(entry) { + return { + label: entry.label || entry.id, + badge: entry.badge || entry.id.slice(0, 2).toUpperCase(), + apiFormat: entry.apiFormat || 'openai_chat', + ...entry, + }; +} + +function loadUserPresets() { + if (!existsSync(USER_PRESETS_FILE)) return []; + + try { + const parsed = JSON.parse(readFileSync(USER_PRESETS_FILE, 'utf8')); + const entries = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.presets) ? parsed.presets : null); + if (!entries) { + console.warn(`[ProviderPresets] ${USER_PRESETS_FILE} must contain an array of presets (or { presets: [...] }) — ignored`); + return []; + } + + const seen = new Set(); + const result = []; + for (const entry of entries) { + const problem = userPresetProblem(entry); + if (problem) { + console.warn(`[ProviderPresets] Skipping user preset: ${problem}`); + continue; + } + if (seen.has(entry.id)) { + console.warn(`[ProviderPresets] Skipping duplicate user preset id "${entry.id}"`); + continue; + } + seen.add(entry.id); + result.push(normalizeUserPreset(entry)); + } + return result; + } catch (err) { + console.warn(`[ProviderPresets] Failed to load ${USER_PRESETS_FILE}: ${err.message} — user presets ignored`); + return []; + } +} + +// Built-ins first, user entries after. Ids are unique across the merged list +// (enforced above), so PRESET_BY_ID keeps first-wins semantics for built-ins. +const BUILTIN_PRESET_IDS = new Set(PROVIDER_PRESETS.map((p) => p.id)); +PROVIDER_PRESETS.push(...loadUserPresets()); + export const PRESET_BY_ID = Object.fromEntries(PROVIDER_PRESETS.map((p) => [p.id, p])); /** All preset provider ids. */ diff --git a/src/providers/zhipu.js b/src/providers/zhipu.js deleted file mode 100644 index 63a356e..0000000 --- a/src/providers/zhipu.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * ZhipuAI Provider (GLM) - * Forwards requests to ZhipuAI API (OpenAI-compatible). - * https://open.bigmodel.cn/dev/api - * - * Note: ZhipuAI base URL is non-standard (/api/paas/v4 instead of /v1), - * but the chat completions endpoint follows the same OpenAI format. - */ - -import { OpenAIProvider } from './openai.js'; -import { estimateCostWithRegistry, getDefaultPricing } from '../pricing-registry.js'; - -const DEFAULT_BASE_URL = 'https://open.bigmodel.cn/api/paas/v4'; - -export class ZhipuProvider extends OpenAIProvider { - constructor(config) { - super({ - ...config, - baseUrl: config.baseUrl || DEFAULT_BASE_URL, - }); - this.type = 'zhipu'; - } - - /** - * Override validateKey — ZhipuAI doesn't have a standard /models endpoint. - * Instead, send a minimal chat request to verify the key. - */ - async validateKey() { - try { - const response = await fetch(`${this.baseUrl}/chat/completions`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${this.apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - model: 'glm-4-flash', - messages: [{ role: 'user', content: 'hi' }], - max_tokens: 1, - }), - }); - return response.status !== 401 && response.status !== 403; - } catch { - return false; - } - } - - estimateCost(model, inputTokens, outputTokens) { - return estimateCostWithRegistry(this.type, model, inputTokens, outputTokens); - } - - static get pricing() { - return getDefaultPricing('zhipu'); - } -} - -export default ZhipuProvider; diff --git a/tests/unit/model-mapping-provider-normalization.test.js b/tests/unit/model-mapping-provider-normalization.test.js index c92d546..3151cd6 100644 --- a/tests/unit/model-mapping-provider-normalization.test.js +++ b/tests/unit/model-mapping-provider-normalization.test.js @@ -1,37 +1,49 @@ import '../test-env.js'; import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; -function normalizeModelMappingProvider(type) { - switch (type) { - case 'openai': - case 'moonshot': - case 'minimax': - case 'zhipu': - return 'openai'; - case 'anthropic': - return 'anthropic'; - case 'gemini': - return 'gemini'; - case 'vertex-ai': - return 'vertex-ai'; - case 'deepseek': - return 'deepseek'; - default: - return type || null; - } +// The mapping page's provider normalization lives inline in the settings-page +// module (browser code). Extract the switch from source so these tests pin the +// real implementation, not a copy. +const settingsPageSource = readFileSync( + join(fileURLToPath(import.meta.url), '..', '..', '..', 'public', 'js', 'modules', 'settings-page.js'), + 'utf8' +); +const match = settingsPageSource.match(/normalizeModelMappingProvider\(type\) \{[\s\S]*?\n \},/); +if (!match) throw new Error('normalizeModelMappingProvider not found in settings-page.js'); + +function normalize(type) { + // Evaluate the extracted switch body as a plain function of `type`. + const body = match[0] + .replace(/^normalizeModelMappingProvider\(type\) \{/, '') + .replace(/\n \},$/, ''); + return new Function('type', body)(type); } -test('normalizeModelMappingProvider folds OpenAI-compatible providers into the openai mapping family', () => { - assert.equal(normalizeModelMappingProvider('openai'), 'openai'); - assert.equal(normalizeModelMappingProvider('moonshot'), 'openai'); - assert.equal(normalizeModelMappingProvider('minimax'), 'openai'); - assert.equal(normalizeModelMappingProvider('zhipu'), 'openai'); +test('normalizeModelMappingProvider keeps provider identity 1:1 with runtime resolveModel', () => { + // zhipu/moonshot/minimax now have their own tier maps (provider-presets.js); + // folding them into 'openai' made the mapping page edit a map the runtime + // never consulted for those keys. + assert.equal(normalize('openai'), 'openai'); + assert.equal(normalize('zhipu'), 'zhipu'); + assert.equal(normalize('moonshot'), 'moonshot'); + assert.equal(normalize('minimax'), 'minimax'); }); test('normalizeModelMappingProvider preserves native mapping families', () => { - assert.equal(normalizeModelMappingProvider('anthropic'), 'anthropic'); - assert.equal(normalizeModelMappingProvider('gemini'), 'gemini'); - assert.equal(normalizeModelMappingProvider('vertex-ai'), 'vertex-ai'); - assert.equal(normalizeModelMappingProvider('deepseek'), 'deepseek'); + assert.equal(normalize('anthropic'), 'anthropic'); + assert.equal(normalize('gemini'), 'gemini'); + assert.equal(normalize('vertex-ai'), 'vertex-ai'); + assert.equal(normalize('deepseek'), 'deepseek'); +}); + +test('normalizeModelMappingProvider falls back to raw type, then null', () => { + assert.equal(normalize('qwen'), 'qwen'); + assert.equal(normalize('openrouter'), 'openrouter'); + assert.equal(normalize('clinepass'), 'clinepass'); + assert.equal(normalize(''), null); + assert.equal(normalize(undefined), null); }); diff --git a/tests/unit/provider-presets.test.js b/tests/unit/provider-presets.test.js index 9dd36a7..60de7b2 100644 --- a/tests/unit/provider-presets.test.js +++ b/tests/unit/provider-presets.test.js @@ -31,8 +31,9 @@ test('every preset declares the required fields and a unique id', () => { } }); -test('this batch ships qwen + openrouter as openai_chat presets', () => { - assert.deepEqual(presetIdsByFormat('openai_chat').sort(), ['openrouter', 'qwen']); +test('this batch ships openai_chat presets (qwen, openrouter, zhipu/moonshot/minimax, clinepass, ollama-cloud)', () => { + assert.deepEqual(presetIdsByFormat('openai_chat').sort(), + ['clinepass', 'minimax', 'moonshot', 'ollama-cloud', 'openrouter', 'qwen', 'zhipu']); // Nothing in this batch should claim the Responses or Anthropic formats. assert.deepEqual(presetIdsByFormat('openai_responses'), []); assert.deepEqual(presetIdsByFormat('anthropic'), []); @@ -159,3 +160,72 @@ test('preset pricing is merged into the registry; openrouter stays unpriced (0)' assert.ok(qwen && qwen.input > 0, 'qwen-max should be priced'); assert.equal(getEffectivePricing('openrouter', 'anthropic/claude-3.7-sonnet'), null, 'openrouter has no built-in pricing'); }); + +// ─── Migrated bespoke providers (zhipu / moonshot / minimax) ──────────────── + +test('migrated zhipu/moonshot/minimax presets keep their registry ids addable', () => { + for (const id of ['zhipu', 'moonshot', 'minimax']) { + const { success, id: keyId } = addApiKey({ type: id, name: `migrated-${id}`, apiKey: 'sk-test' }); + assert.equal(success, true, `${id} must remain a registered, addable provider type`); + try { + const provider = getProviderById(keyId); + assert.equal(provider.type, id, `${id} keys keep their provider identity`); + assert.equal(typeof provider.sendAnthropicRequest, 'function', `${id} keeps the Anthropic bridge`); + } finally { + removeApiKey(keyId); + } + } +}); + +test('migrated providers have tier maps and remap non-native models', () => { + // zhipu: previously had NO tier map — claude models passed through verbatim. + assert.notEqual(resolveModel('zhipu', 'claude-sonnet-4-6'), 'claude-sonnet-4-6'); + // Native model families pass through unchanged. + assert.equal(resolveModel('zhipu', 'glm-5'), 'glm-5'); + assert.equal(resolveModel('moonshot', 'kimi-k2.5'), 'kimi-k2.5'); + assert.equal(resolveModel('minimax', 'MiniMax-M2.5'), 'MiniMax-M2.5'); +}); + +test('zhipu preset declares a validateModel for vendors without GET /models', () => { + assert.equal(PRESET_BY_ID.zhipu.validateModel, 'glm-4-flash'); + const Cls = makeOpenAICompatibleProvider(PRESET_BY_ID.zhipu); + const instance = new Cls({ id: 'tmp_zhipu', apiKey: 'sk-test' }); + assert.equal(typeof instance.validateKey, 'function'); +}); + +// ─── Built-in subscription presets (clinepass / ollama-cloud) ─────────────── + +test('clinepass passes cline-pass/ slugs through and tier-maps the rest', () => { + // Upstream requires the full `cline-pass/` slug — never remap those. + assert.equal(resolveModel('clinepass', 'cline-pass/glm-5.3'), 'cline-pass/glm-5.3'); + // An incoming Claude Code model remaps through the tier map. + assert.equal(resolveModel('clinepass', 'claude-sonnet-4-6'), 'cline-pass/glm-5.3'); + assert.equal(resolveModel('clinepass', 'claude-opus-4-6'), 'cline-pass/kimi-k3'); +}); + +test('ollama-cloud passes catalog ids through and tier-maps non-native ids', () => { + // Bare vendor ids (no common prefix) pass via family prefixes... + assert.equal(resolveModel('ollama-cloud', 'kimi-k2.6'), 'kimi-k2.6'); + assert.equal(resolveModel('ollama-cloud', 'glm-5.3'), 'glm-5.3'); + // ...tag-style ids via the `:` passthrough match... + assert.equal(resolveModel('ollama-cloud', 'gpt-oss:120b'), 'gpt-oss:120b'); + // ...and non-native ids remap (gpt-5.x must NOT be swallowed by a bare + // `gpt` prefix — the family list pins `gpt-oss` exactly). + assert.equal(resolveModel('ollama-cloud', 'gpt-5.4'), 'glm-5.3'); + assert.equal(resolveModel('ollama-cloud', 'claude-sonnet-4-6'), 'glm-5.3'); +}); + +test('clinepass and ollama-cloud are registered providers with the Anthropic bridge', () => { + for (const id of ['clinepass', 'ollama-cloud']) { + const { success, id: keyId } = addApiKey({ type: id, name: `builtin-${id}`, apiKey: 'sk-test' }); + assert.equal(success, true, `${id} must be a registered, addable provider type`); + try { + const provider = getProviderById(keyId); + assert.equal(provider.type, id); + assert.equal(typeof provider.sendAnthropicRequest, 'function', `${id} must serve /v1/messages via the bridge`); + assert.equal(provider.sendResponsesRequest, undefined, `${id} must NOT claim native /responses`); + } finally { + removeApiKey(keyId); + } + } +}); diff --git a/tests/unit/provider-user-presets.test.js b/tests/unit/provider-user-presets.test.js new file mode 100644 index 0000000..c342fa3 --- /dev/null +++ b/tests/unit/provider-user-presets.test.js @@ -0,0 +1,79 @@ +// User-defined provider presets (~/.cligate/provider-presets.json). +// The loader runs at module init, so these tests must run in their own +// process with a fresh CLIGATE_CONFIG_DIR — spawning a child node process +// per test keeps the cache-per-process semantics honest. +import '../test-env.js'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = join(fileURLToPath(import.meta.url), '..', '..', '..'); + +function runWithPresets(presets) { + const configDir = join(globalThis.__cligateTestEnvRoot, `presets-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(configDir, { recursive: true }); + if (presets !== null) { + writeFileSync(join(configDir, 'provider-presets.json'), JSON.stringify(presets)); + } + const result = spawnSync(process.execPath, [ + '--input-type=module', '-e', + "import { PROVIDER_PRESETS } from './src/providers/provider-presets.js';" + + 'console.log(JSON.stringify(PROVIDER_PRESETS.map(p => p.id)));', + ], { + cwd: repoRoot, + encoding: 'utf8', + env: { ...process.env, CLIGATE_CONFIG_DIR: configDir }, + }); + if (result.status !== 0) { + throw new Error(`child failed: ${result.stderr}`); + } + return JSON.parse(result.stdout.trim().split('\n').pop()); +} + +test('user presets file merges built-in and user-defined providers', () => { + const ids = runWithPresets([ + { id: 'clinepass', label: 'ClinePass', baseUrl: 'https://api.clinepass.example/v1' }, + ]); + assert.ok(ids.includes('qwen'), 'built-ins survive'); + assert.ok(ids.includes('clinepass'), 'user preset merged'); +}); + +test('absent user presets file changes nothing', () => { + const ids = runWithPresets(null); + assert.deepEqual(ids.filter((id) => !['qwen', 'openrouter', 'zhipu', 'moonshot', 'minimax', 'clinepass', 'ollama-cloud'].includes(id)), []); +}); + +test('user preset colliding with a built-in id is skipped, not merged', () => { + const ids = runWithPresets([ + { id: 'zhipu', baseUrl: 'https://evil.example/v1' }, + { id: 'valid', baseUrl: 'https://ok.example/v1' }, + ]); + assert.equal(ids.filter((id) => id === 'zhipu').length, 1, 'built-in zhipu wins exactly once'); + assert.ok(ids.includes('valid')); +}); + +test('invalid user presets are skipped individually with no collateral damage', () => { + const ids = runWithPresets([ + { id: 'bad id', baseUrl: 'https://x/v1' }, + { id: 'nobaseurl' }, + { id: 'badformat', baseUrl: 'https://x/v1', apiFormat: 'graphql' }, + { id: 'badtier', baseUrl: 'https://x/v1', tiers: { flagship: 'm1', bogus: 'm2' } }, + { id: 'good', baseUrl: 'https://x/v1', tiers: { flagship: 'm1' } }, + ]); + assert.ok(ids.includes('good'), 'valid entry still merged despite siblings failing'); + assert.ok(!ids.includes('bad id')); + assert.ok(!ids.includes('nobaseurl')); + assert.ok(!ids.includes('badformat')); + assert.ok(!ids.includes('badtier'), 'unknown tier key rejects the preset'); +}); + +test('duplicate user preset ids collapse to the first entry', () => { + const ids = runWithPresets([ + { id: 'dupe', baseUrl: 'https://a/v1' }, + { id: 'dupe', baseUrl: 'https://b/v1' }, + ]); + assert.equal(ids.filter((id) => id === 'dupe').length, 1); +});