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 e5e45db..b3c14c5 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 8e3c0f0..4e90763 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": 137, "column": 40 } ] @@ -159,7 +159,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 118, + "line": 167, "column": 16 }, { @@ -384,7 +384,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 105, + "line": 154, "column": 16 }, { @@ -449,8 +449,8 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 90, - "column": 283 + "line": 139, + "column": 286 } ] }, @@ -461,12 +461,12 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 79, + "line": 128, "column": 59 }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 79, + "line": 128, "column": 124 } ] @@ -676,17 +676,17 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 94, + "line": 143, "column": 49 }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 96, + "line": 145, "column": 50 }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 99, + "line": 148, "column": 50 } ] @@ -762,7 +762,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 90, + "line": 139, "column": 42 } ] @@ -812,7 +812,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 101, + "line": 150, "column": 27 }, { @@ -922,7 +922,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 91, + "line": 140, "column": 42 } ] @@ -1469,7 +1469,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 94, + "line": 143, "column": 25 }, { @@ -1498,7 +1498,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 85, + "line": 134, "column": 145 } ] @@ -1527,7 +1527,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 143, + "line": 192, "column": 41 } ] @@ -1681,12 +1681,12 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 96, + "line": 145, "column": 27 }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 99, + "line": 148, "column": 27 }, { @@ -1715,7 +1715,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 92, + "line": 141, "column": 69 } ] @@ -2266,7 +2266,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 112, + "line": 161, "column": 16 }, { @@ -2369,8 +2369,8 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 90, - "column": 295 + "line": 139, + "column": 298 } ] }, @@ -2473,7 +2473,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 85, + "line": 134, "column": 115 } ] @@ -2555,7 +2555,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 79, + "line": 128, "column": 76 } ] @@ -2728,12 +2728,12 @@ }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 100, + "line": 149, "column": 27 }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 100, + "line": 149, "column": 58 } ] @@ -3000,7 +3000,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 86, + "line": 135, "column": 61 } ] @@ -3039,7 +3039,7 @@ }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 91, + "line": 140, "column": 25 } ] @@ -3445,12 +3445,12 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 97, + "line": 146, "column": 27 }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 97, + "line": 146, "column": 58 } ] @@ -3522,7 +3522,7 @@ }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 90, + "line": 139, "column": 25 } ] @@ -3592,7 +3592,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 101, + "line": 150, "column": 50 } ] @@ -3633,7 +3633,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 85, + "line": 134, "column": 70 } ] @@ -3645,11 +3645,23 @@ "refs": [ { "file": "components/ui/Modal.tsx", - "line": 95, + "line": 97, "column": 135 } ] }, + "d58c88688e1a949d": { + "source": "常用预设", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/cursor/CursorPresetChips.tsx", + "line": 8, + "column": 37 + } + ] + }, "d86fa42c3848c680": { "source": "使用系统代理", "kind": "text", @@ -3710,12 +3722,17 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 27, + "line": 29, "column": 19 }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 92, + "line": 102, + "column": 88 + }, + { + "file": "components/cursor/CursorModelEditor.tsx", + "line": 141, "column": 54 } ] @@ -3940,7 +3957,7 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 85, + "line": 134, "column": 54 } ] @@ -4010,12 +4027,12 @@ "refs": [ { "file": "components/cursor/CursorModelEditor.tsx", - "line": 75, + "line": 124, "column": 25 }, { "file": "components/cursor/CursorModelEditor.tsx", - "line": 75, + "line": 124, "column": 55 } ] diff --git a/apps/desktop/src/i18n/locales/en-US.json b/apps/desktop/src/i18n/locales/en-US.json index 2e4ed62..6ec6642 100644 --- a/apps/desktop/src/i18n/locales/en-US.json +++ b/apps/desktop/src/i18n/locales/en-US.json @@ -251,6 +251,7 @@ "d34335433395cd3a": "Start Cursor BYOK automatically after signing in.", "d3716cc5a2f5a810": "Server address", "d3d21191f32e79a5": "Processing…", + "d58c88688e1a949d": "Presets", "d86fa42c3848c680": "Use system proxy", "d8c47e9776cf1082": "Main menu", "da521d1c1cbd36af": "Authorization is required to install the certificate", diff --git a/apps/desktop/src/i18n/locales/zh-CN.json b/apps/desktop/src/i18n/locales/zh-CN.json index 855879f..ae33d0f 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/i18n/locales/zh-CN.json @@ -251,6 +251,7 @@ "d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。", "d3716cc5a2f5a810": "服务器地址", "d3d21191f32e79a5": "处理中…", + "d58c88688e1a949d": "常用预设", "d86fa42c3848c680": "使用系统代理", "d8c47e9776cf1082": "主菜单", "da521d1c1cbd36af": "需要授权安装证书", 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 20dd892..932fa14 100644 --- a/server/src/control/service.rs +++ b/server/src/control/service.rs @@ -659,7 +659,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) @@ -797,7 +822,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>>, @@ -899,12 +942,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" ); } @@ -933,7 +976,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(); @@ -970,7 +1013,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");