diff --git a/apps/desktop/src/assets/provider-icons/deepseek.svg b/apps/desktop/src/assets/provider-icons/deepseek.svg new file mode 100644 index 0000000..3fc2302 --- /dev/null +++ b/apps/desktop/src/assets/provider-icons/deepseek.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/apps/desktop/src/assets/provider-icons/huoshan.png b/apps/desktop/src/assets/provider-icons/huoshan.png new file mode 100644 index 0000000..dabe3b7 Binary files /dev/null and b/apps/desktop/src/assets/provider-icons/huoshan.png differ diff --git a/apps/desktop/src/assets/provider-icons/kimi.svg b/apps/desktop/src/assets/provider-icons/kimi.svg new file mode 100644 index 0000000..ec5db53 --- /dev/null +++ b/apps/desktop/src/assets/provider-icons/kimi.svg @@ -0,0 +1 @@ +Kimi \ No newline at end of file diff --git a/apps/desktop/src/assets/provider-icons/minimax.svg b/apps/desktop/src/assets/provider-icons/minimax.svg new file mode 100644 index 0000000..2a60bd4 --- /dev/null +++ b/apps/desktop/src/assets/provider-icons/minimax.svg @@ -0,0 +1 @@ +Minimax \ No newline at end of file diff --git a/apps/desktop/src/assets/provider-icons/zhipu.svg b/apps/desktop/src/assets/provider-icons/zhipu.svg new file mode 100644 index 0000000..0c6e61c --- /dev/null +++ b/apps/desktop/src/assets/provider-icons/zhipu.svg @@ -0,0 +1 @@ +Zhipu \ No newline at end of file diff --git a/apps/desktop/src/components/cursor/CursorModelEditor.tsx b/apps/desktop/src/components/cursor/CursorModelEditor.tsx index ecd34df..2618faa 100644 --- a/apps/desktop/src/components/cursor/CursorModelEditor.tsx +++ b/apps/desktop/src/components/cursor/CursorModelEditor.tsx @@ -1,5 +1,6 @@ import type { ModelInput, ModelType } from "../../api"; import { defaultCustomHeadersText } from "../../utils/modelDefaults"; +import { modelPresets, presetEndpoint, trimTrailingSlash, type ModelPreset } from "../../utils/modelPresets"; import { Button } from "../ui/Button"; import { Checkbox } from "../ui/Checkbox"; import { FormField, SecretTextInput, TextInput } from "../ui/FormControls"; @@ -7,6 +8,7 @@ import { JsonEditor } from "../ui/JsonEditor"; import { Combobox, Select } from "../ui/Select"; import { Switch } from "../ui/Switch"; import { claudeIcon, openAiIcon } from "../ui/icons"; +import { CursorPresetChips } from "./CursorPresetChips"; import styles from "./CursorSettings.module.scss"; export type CursorModelDraft = { @@ -53,13 +55,64 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange, onDiscover: () => void; }) { const setModel = (patch: Partial) => onChange({ ...draft, model: { ...draft.model, ...patch } }); - const setType = (type: ModelType) => setModel({ - type, - openai_endpoint: type === "openai" ? draft.model.openai_endpoint || "/v1/responses" : "", - anthropic_thinking_effort: type === "anthropic" ? draft.model.anthropic_thinking_effort || "xhigh" : null, - }); + const setType = (type: ModelType) => { + // 切换协议类型时,若当前地址命中某预设的另一协议端点,自动换到该预设对应协议的端点, + // 避免出现「类型是 Anthropic、URL 却是 OpenAI chat/completions」的错配 + const other: ModelType = type === "anthropic" ? "openai" : "anthropic"; + const preset = modelPresets.find((candidate) => trimTrailingSlash(presetEndpoint(candidate, other).baseUrl) === trimTrailingSlash(draft.model.base_url.trim())); + const endpoint = preset ? presetEndpoint(preset, type) : null; + onChange({ + ...draft, + model: { + ...draft.model, + type, + ...(endpoint ? { + base_url: endpoint.baseUrl, + use_full_url: endpoint.useFullUrl, + custom_headers_enabled: endpoint.customHeaders !== null, + custom_headers: endpoint.customHeaders ? { ...endpoint.customHeaders } : {}, + } : {}), + openai_endpoint: type === "openai" ? (endpoint?.openaiEndpoint || draft.model.openai_endpoint || "/v1/responses") : "", + anthropic_thinking_effort: type === "anthropic" ? draft.model.anthropic_thinking_effort || "xhigh" : null, + }, + customHeadersText: endpoint?.customHeaders ? JSON.stringify(endpoint.customHeaders, null, 2) : draft.customHeadersText, + }); + }; const numberValue = (value: string) => value === "" ? null : Math.trunc(Number(value)); const canDiscover = Boolean(draft.model.base_url.trim() && draft.model.api_key.trim()); + // 选中预设后,把该服务商已知的模型 id 并入下拉,方便直接选(仍可用「获取模型」发现) + const presetModelOptions = modelPresets + .filter((preset) => trimTrailingSlash(presetEndpoint(preset, draft.model.type).baseUrl) === trimTrailingSlash(draft.model.base_url.trim())) + .flatMap((preset) => preset.models.map((item) => item.model_id)); + const combinedOptions = [...new Set([...modelOptions, ...presetModelOptions])]; + const applyPreset = (preset: ModelPreset) => { + const endpoint = presetEndpoint(preset, draft.model.type); + const first = preset.models[0]; + // 切到别家服务商时清空 API Key(不同家的 Key 不能串用);同一家内切换协议则保留 + const currentBase = trimTrailingSlash(draft.model.base_url.trim()); + const sameProvider = [preset.endpoints.anthropic, preset.endpoints.openai] + .some((candidate) => trimTrailingSlash(candidate.baseUrl) === currentBase); + onChange({ + ...draft, + model: { + ...draft.model, + base_url: endpoint.baseUrl, + use_full_url: endpoint.useFullUrl, + openai_endpoint: draft.model.type === "openai" ? endpoint.openaiEndpoint : draft.model.openai_endpoint, + custom_headers_enabled: endpoint.customHeaders !== null, + custom_headers: endpoint.customHeaders ? { ...endpoint.customHeaders } : {}, + api_key: sameProvider ? draft.model.api_key : "", + model_id: first?.model_id ?? draft.model.model_id, + display_name: first?.display_name ?? draft.model.display_name, + tooltip_data: !draft.model.tooltip_data.trim() || draft.model.tooltip_data === t("备注") ? preset.name : draft.model.tooltip_data, + context_window_tokens: first?.context_window_tokens ?? draft.model.context_window_tokens, + ...(draft.model.type === "openai" + ? { max_completion_tokens: first?.max_output_tokens ?? draft.model.max_completion_tokens } + : { anthropic_max_tokens: first?.max_output_tokens ?? draft.model.anthropic_max_tokens }), + }, + customHeadersText: endpoint.customHeaders ? JSON.stringify(endpoint.customHeaders, null, 2) : draft.customHeadersText, + }); + }; const requestUrlPlaceholder = draft.model.use_full_url ? draft.model.type === "anthropic" ? "https://api.anthropic.com/v1/messages" @@ -71,6 +124,7 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange, : "https://api.openai.com"; return + setModel({ api_key: event.target.value })} /> - {discovering ? t("获取中…") : t("获取模型")}} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /> + {discovering ? t("获取中…") : t("获取模型")}} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /> setModel({ display_name: event.target.value })} /> setModel({ tooltip_data: event.target.value })} /> diff --git a/apps/desktop/src/components/cursor/CursorPresetChips.module.scss b/apps/desktop/src/components/cursor/CursorPresetChips.module.scss new file mode 100644 index 0000000..2a5d54f --- /dev/null +++ b/apps/desktop/src/components/cursor/CursorPresetChips.module.scss @@ -0,0 +1,47 @@ +.wrap { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.label { + font-size: 12px; + color: var(--vscode-descriptionForeground, rgba(255, 255, 255, 0.7)); +} + +.chips { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 10px; + border: 1px solid var(--vscode-editorWidget-border, rgba(255, 255, 255, 0.16)); + border-radius: 999px; + background: transparent; + color: inherit; + font-size: 12px; + cursor: pointer; + + &:hover { + border-color: var(--vscode-focusBorder, #49b0ff); + } + + &.active { + border-color: var(--vscode-focusBorder, #49b0ff); + color: var(--vscode-focusBorder, #49b0ff); + } +} + +.icon { + width: 14px; + height: 14px; + border-radius: 3px; + object-fit: contain; +} diff --git a/apps/desktop/src/components/cursor/CursorPresetChips.tsx b/apps/desktop/src/components/cursor/CursorPresetChips.tsx new file mode 100644 index 0000000..5874a6a --- /dev/null +++ b/apps/desktop/src/components/cursor/CursorPresetChips.tsx @@ -0,0 +1,25 @@ +import type { ModelType } from "../../api"; +import { modelPresets, presetEndpoint, trimTrailingSlash, type ModelPreset } from "../../utils/modelPresets"; +import styles from "./CursorPresetChips.module.scss"; + +/** 常用服务商预设:点击按当前协议类型自动填充对应端点与默认模型 */ +export function CursorPresetChips({ type, baseUrl, onPick }: { type: ModelType; baseUrl: string; onPick: (preset: ModelPreset) => void }) { + return + {t("常用预设")} + + {modelPresets.map((preset) => { + const active = trimTrailingSlash(baseUrl) === trimTrailingSlash(presetEndpoint(preset, type).baseUrl); + return onPick(preset)} + > + + {preset.name} + ; + })} + + ; +} diff --git a/apps/desktop/src/i18n/generated/catalog.json b/apps/desktop/src/i18n/generated/catalog.json index 1eafce9..6c6d5ea 100644 --- a/apps/desktop/src/i18n/generated/catalog.json +++ b/apps/desktop/src/i18n/generated/catalog.json @@ -33,7 +33,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 88, + "line": 142, "column": 40 } ] @@ -159,7 +159,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 118, + "line": 172, "column": 16 }, { @@ -369,7 +369,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 105, + "line": 159, "column": 16 }, { @@ -434,8 +434,8 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 90, - "column": 283 + "line": 144, + "column": 286 } ] }, @@ -446,12 +446,12 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 79, + "line": 133, "column": 59 }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 79, + "line": 133, "column": 124 } ] @@ -661,17 +661,17 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 94, + "line": 148, "column": 49 }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 96, + "line": 150, "column": 50 }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 99, + "line": 153, "column": 50 } ] @@ -759,7 +759,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 90, + "line": 144, "column": 42 } ] @@ -809,7 +809,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 101, + "line": 155, "column": 27 }, { @@ -919,7 +919,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 91, + "line": 145, "column": 42 } ] @@ -1478,7 +1478,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 94, + "line": 148, "column": 25 }, { @@ -1524,7 +1524,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 85, + "line": 139, "column": 145 } ] @@ -1553,7 +1553,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 143, + "line": 197, "column": 41 } ] @@ -1707,12 +1707,12 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 96, + "line": 150, "column": 27 }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 99, + "line": 153, "column": 27 }, { @@ -1741,7 +1741,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 92, + "line": 146, "column": 69 } ] @@ -2316,7 +2316,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 112, + "line": 166, "column": 16 }, { @@ -2434,8 +2434,8 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 90, - "column": 295 + "line": 144, + "column": 298 } ] }, @@ -2538,7 +2538,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 85, + "line": 139, "column": 115 } ] @@ -2620,7 +2620,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 79, + "line": 133, "column": 76 } ] @@ -2793,12 +2793,12 @@ }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 100, + "line": 154, "column": 27 }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 100, + "line": 154, "column": 58 } ] @@ -3079,7 +3079,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 86, + "line": 140, "column": 61 } ] @@ -3118,7 +3118,7 @@ }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 91, + "line": 145, "column": 25 } ] @@ -3154,7 +3154,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 92, + "line": 146, "column": 118 } ] @@ -3411,7 +3411,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 91, + "line": 145, "column": 99 } ] @@ -3548,12 +3548,12 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 97, + "line": 151, "column": 27 }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 97, + "line": 151, "column": 58 } ] @@ -3625,7 +3625,7 @@ }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 90, + "line": 144, "column": 25 } ] @@ -3695,7 +3695,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 101, + "line": 155, "column": 50 } ] @@ -3736,7 +3736,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 85, + "line": 139, "column": 70 } ] @@ -3758,6 +3758,18 @@ } ] }, + "d58c88688e1a949d": { + "source": "常用预设", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/cursor/CursorPresetChips.tsx", + "line": 8, + "column": 37 + } + ] + }, "d60669bb26a22f5d": { "source": "留空使用默认值", "kind": "text", @@ -3765,17 +3777,17 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 94, + "line": 148, "column": 121 }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 96, + "line": 150, "column": 122 }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 99, + "line": 153, "column": 122 } ] @@ -3787,7 +3799,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 101, + "line": 155, "column": 137 } ] @@ -3852,7 +3864,12 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 92, + "line": 107, + "column": 88 + }, + { + "file": "components/cursor/CursorModelEditor.tsx", + "line": 146, "column": 54 } ] @@ -4089,7 +4106,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 85, + "line": 139, "column": 54 } ] @@ -4159,12 +4176,12 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 75, + "line": 129, "column": 25 }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 75, + "line": 129, "column": 55 } ] diff --git a/apps/desktop/src/i18n/locales/en-US.json b/apps/desktop/src/i18n/locales/en-US.json index 07e6d37..317ebfa 100644 --- a/apps/desktop/src/i18n/locales/en-US.json +++ b/apps/desktop/src/i18n/locales/en-US.json @@ -151,7 +151,7 @@ "6d1876364ac6457d": "Proxy mode", "6e86570183c3cdd0": "You're up to date", "7005693f4f050bce": "Cache I/O {cost}", - "72644ec4389da2f7": "Flat", + "72644ec4389da2f7": "Default layout", "736c9dc2a04c65fd": "The model configuration changed. Refresh and try again.", "7392e20d61abaa07": "Also store complete requests and streamed responses; by default only timing, status, and usage are stored.", "788db1cfec2a3db5": "Theme", @@ -167,7 +167,7 @@ "7f3c8312816fe26a": "Refreshing…", "7f68ebad19ba6bcd": "Check for updates", "811a3b22a5a7f2d5": "Unable to connect to the local management service", - "83c4efccd9a6bf69": "Connectivity tests cancelled: {successful} succeeded, {failed} failed", + "83c4efccd9a6bf69": "Connectivity test cancelled: {successful} succeeded, {failed} failed", "83fcfb4c1f2c1641": "Fetch models", "842b9f11cdd96bda": "Launch at login", "843ac7e15a5047a7": "Confirm legacy model configuration import", @@ -260,6 +260,7 @@ "d34335433395cd3a": "Start Cursor BYOK automatically after signing in.", "d3716cc5a2f5a810": "Server address", "d3d21191f32e79a5": "Processing…", + "d58c88688e1a949d": "Presets", "d60669bb26a22f5d": "Leave blank to use the default", "d6b1f203680f5496": "Leave blank to use adaptive thinking", "d86fa42c3848c680": "Use system proxy", diff --git a/apps/desktop/src/i18n/locales/zh-CN.json b/apps/desktop/src/i18n/locales/zh-CN.json index bdf6fab..6682ace 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/i18n/locales/zh-CN.json @@ -260,6 +260,7 @@ "d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。", "d3716cc5a2f5a810": "服务器地址", "d3d21191f32e79a5": "处理中…", + "d58c88688e1a949d": "常用预设", "d60669bb26a22f5d": "留空使用默认值", "d6b1f203680f5496": "留空使用 adaptive thinking", "d86fa42c3848c680": "使用系统代理", diff --git a/apps/desktop/src/utils/modelPresets.ts b/apps/desktop/src/utils/modelPresets.ts new file mode 100644 index 0000000..35d1389 --- /dev/null +++ b/apps/desktop/src/utils/modelPresets.ts @@ -0,0 +1,130 @@ +import type { ModelType } from "../api"; +import deepseekIcon from "../assets/provider-icons/deepseek.svg"; +import huoshanIcon from "../assets/provider-icons/huoshan.png"; +import kimiIcon from "../assets/provider-icons/kimi.svg"; +import minimaxIcon from "../assets/provider-icons/minimax.svg"; +import zhipuIcon from "../assets/provider-icons/zhipu.svg"; +import { defaultCustomHeaders } from "./modelDefaults"; + +export interface ModelPresetEntry { + model_id: string; + display_name: string; + context_window_tokens: number | null; + max_output_tokens: number | null; +} + +/** 一个服务商在某种协议(anthropic / openai)下的接入端点 */ +export interface ModelPresetEndpoint { + baseUrl: string; + /** true 时 baseUrl 即完整请求 URL;false 时由请求协议追加标准端点路径 */ + useFullUrl: boolean; + /** openai 协议的请求端点(useFullUrl 为 true 时忽略) */ + openaiEndpoint: string; + /** 非空时启用自定义 Headers(claude-cli 伪装头) */ + customHeaders: Record | null; +} + +export interface ModelPreset { + key: string; + name: string; + icon: string; + keyHint: string; + /** 五家服务商均同时提供 Anthropic 与 OpenAI 兼容协议 */ + endpoints: { anthropic: ModelPresetEndpoint; openai: ModelPresetEndpoint }; + models: ModelPresetEntry[]; +} + +const entry = ( + modelId: string, + displayName: string, + contextWindowTokens: number | null, + maxOutputTokens: number | null, +): ModelPresetEntry => ({ + model_id: modelId, + display_name: displayName, + context_window_tokens: contextWindowTokens, + max_output_tokens: maxOutputTokens, +}); + +const claudeHeaders = { ...defaultCustomHeaders }; +/** anthropic 协议:填 Base URL,自动追加 /v1/messages */ +const anthropic = (baseUrl: string): ModelPresetEndpoint => ({ baseUrl, useFullUrl: false, openaiEndpoint: "", customHeaders: claudeHeaders }); +/** openai 协议:填 Base URL,自动追加 /v1/chat/completions */ +const openaiChat = (baseUrl: string): ModelPresetEndpoint => ({ baseUrl, useFullUrl: false, openaiEndpoint: "/v1/chat/completions", customHeaders: null }); +/** openai 协议:路径不规则,直接给完整请求 URL */ +const openaiFullUrl = (url: string): ModelPresetEndpoint => ({ baseUrl: url, useFullUrl: true, openaiEndpoint: "/v1/chat/completions", customHeaders: null }); + +export const modelPresets: ModelPreset[] = [ + { + key: "zhipu", + name: "智谱 GLM", + icon: zhipuIcon, + keyHint: "bigmodel.cn → GLM Coding Plan → API Key(套餐 Key 与普通 Key 不通用)", + endpoints: { + anthropic: anthropic("https://open.bigmodel.cn/api/anthropic"), + openai: openaiFullUrl("https://open.bigmodel.cn/api/coding/paas/v4/chat/completions"), + }, + models: [ + entry("glm-5.3", "GLM 5.3", 1000000, 65536), + entry("glm-5.2", "GLM 5.2", 200000, 32768), + entry("glm-4.7", "GLM 4.7", 200000, 32768), + ], + }, + { + key: "kimi", + name: "Kimi (Moonshot)", + icon: kimiIcon, + keyHint: "Kimi Code 编程套餐页获取 API Key(api.kimi.com/coding 端点)", + endpoints: { + anthropic: anthropic("https://api.kimi.com/coding"), + openai: openaiChat("https://api.kimi.com/coding"), + }, + models: [ + entry("k3", "Kimi K3", 1048576, 65536), + entry("kimi-for-coding", "K2.7 Coding", 262144, 32768), + ], + }, + { + key: "deepseek", + name: "DeepSeek", + icon: deepseekIcon, + keyHint: "platform.deepseek.com → API Keys", + endpoints: { + anthropic: anthropic("https://api.deepseek.com/anthropic"), + openai: openaiChat("https://api.deepseek.com"), + }, + models: [ + entry("deepseek-v4-pro", "DeepSeek V4 Pro", 1000000, 65536), + entry("deepseek-v4-flash", "DeepSeek V4 Flash", 1000000, null), + ], + }, + { + key: "volcengine", + name: "火山引擎方舟", + icon: huoshanIcon, + keyHint: "火山方舟 Coding Plan(ark-code-latest 路由多款代码模型)", + endpoints: { + anthropic: anthropic("https://ark.cn-beijing.volces.com/api/coding"), + openai: openaiFullUrl("https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions"), + }, + models: [entry("ark-code-latest", "Ark Code Latest", 256000, 32768)], + }, + { + key: "minimax", + name: "MiniMax", + icon: minimaxIcon, + keyHint: "platform.minimaxi.com → 订阅 Coding Plan → API Key", + endpoints: { + anthropic: anthropic("https://api.minimaxi.com/anthropic"), + openai: { baseUrl: "https://api.minimaxi.com", useFullUrl: false, openaiEndpoint: "/v1/responses", customHeaders: null }, + }, + models: [ + entry("MiniMax-M3", "MiniMax M3", 1000000, 65536), + entry("MiniMax-M2.7", "MiniMax M2.7", 205000, 32768), + ], + }, +]; + +export const trimTrailingSlash = (url: string) => url.replace(/\/+$/, ""); + +export const presetEndpoint = (preset: ModelPreset, type: ModelType): ModelPresetEndpoint => preset.endpoints[type]; diff --git a/server/src/control/service.rs b/server/src/control/service.rs index 282301c..1f6b81f 100644 --- a/server/src/control/service.rs +++ b/server/src/control/service.rs @@ -708,7 +708,32 @@ fn model_discovery_url(base_url: &str) -> Result { "model request URL must contain a host".into(), )); } - url.set_path("/v1/models"); + // 在现有路径上追加,而不是整段替换:多数编程套餐的 API 挂在子路径下 + // (/api/anthropic、/coding、/api/paas/v4 等),直接 set_path("/v1/models") + // 会把这些前缀吃掉,发现请求必然 404 + let path = url.path().trim_end_matches('/'); + let last = path.rsplit('/').next().unwrap_or(""); + let versioned = last.len() > 1 + && last.starts_with('v') + && last[1..].bytes().all(|byte| byte.is_ascii_digit()); + let new_path = if let Some(parent) = path.strip_suffix("/chat/completions") { + // 完整请求 URL:剥掉端点段(chat/completions 是两段),换成 models + format!("{parent}/models") + } else if let Some(parent) = path + .strip_suffix("/responses") + .or_else(|| path.strip_suffix("/messages")) + .or_else(|| path.strip_suffix("/completions")) + { + format!("{parent}/models") + } else if path.is_empty() { + "/v1/models".to_string() + } else if versioned { + // 已带版本段(/v1、/api/v3、/api/paas/v4):只补 models + format!("{path}/models") + } else { + format!("{path}/v1/models") + }; + url.set_path(&new_path); url.set_query(None); url.set_fragment(None); Ok(url) @@ -846,7 +871,25 @@ mod tests { store::Store, }; - use super::ControlService; + use super::{model_discovery_url, ControlService}; + + #[test] + fn model_discovery_url_appends_to_path() { + let cases = [ + ("https://api.deepseek.com", "https://api.deepseek.com/v1/models"), + ("https://open.bigmodel.cn/api/anthropic", "https://open.bigmodel.cn/api/anthropic/v1/models"), + ("https://api.kimi.com/coding", "https://api.kimi.com/coding/v1/models"), + ("https://api.moonshot.cn/v1", "https://api.moonshot.cn/v1/models"), + ("https://ark.cn-beijing.volces.com/api/v3", "https://ark.cn-beijing.volces.com/api/v3/models"), + ( + "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions", + "https://open.bigmodel.cn/api/coding/paas/v4/models", + ), + ]; + for (base, expected) in cases { + assert_eq!(model_discovery_url(base).unwrap().as_str(), expected, "base: {base}"); + } + } struct TestProvider { invocation: Arc>>, @@ -1010,12 +1053,12 @@ mod tests { } #[test] - fn model_discovery_url_uses_only_the_provider_origin() { + fn model_discovery_url_keeps_provider_path_prefix() { assert_eq!( super::model_discovery_url("https://example.com:8443/arbitrary/v1/chat/completions") .unwrap() .as_str(), - "https://example.com:8443/v1/models" + "https://example.com:8443/arbitrary/v1/models" ); } @@ -1044,7 +1087,7 @@ mod tests { let (sender, mut requests) = tokio::sync::mpsc::unbounded_channel(); let app = axum::Router::new() - .route("/v1/models", axum::routing::get(models)) + .route("/custom/models", axum::routing::get(models)) .with_state(sender); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); @@ -1081,7 +1124,8 @@ mod tests { assert_eq!(result.models, vec!["model-a"]); let (method, uri, headers, body) = requests.recv().await.unwrap(); assert_eq!(method, axum::http::Method::GET); - assert_eq!(uri.path(), "/v1/models"); + // /custom/responses 剥掉端点段后是 /custom,发现地址为 /custom/models + assert_eq!(uri.path(), "/custom/models"); assert!(body.is_empty()); assert!(headers.get(axum::http::header::USER_AGENT).is_none()); assert_eq!(headers.get("x-tenant").unwrap(), "tenant-a");