diff --git a/internal/core/channels_endpoint_url_test.go b/internal/core/channels_endpoint_url_test.go new file mode 100644 index 0000000..dbae9d2 --- /dev/null +++ b/internal/core/channels_endpoint_url_test.go @@ -0,0 +1,76 @@ +package core + +import ( + "regexp" + "strings" + "testing" +) + +// ★ 源的 chat URL = `base_url + endpoint`(内核 provider.go ChatURL)。 +// +// 真实缺陷(生产实测):多个渠道平台的 `base_url` 里塞了**完整路径**, +// 而适配器默认 endpoint 又是 "/chat/completions" ⇒ 实际请求变成 +// +// https://copilot.tencent.com/v2/chat/completions/chat/completions +// +// ⇒ 上游回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞,不是模型错误)。 +// +// 受影响平台 8 个:buddy / cline / lobsterai / opencode / qoder / loomy / +// raccoon(zcode 当时已用 endpoint 覆盖,形态正确)。 +// +// ★ 为什么这个缺陷能活很久:**模型名被多个源服务**。 +// +// `deepseek-v4.1-flash` 同时由 4 个源提供(alittokenplan/vela/oin/channels-buddy…) +// ⇒ 网关在 buddy 源失败后会**继续尝试下一个源**并成功 ⇒ 用户看到"能回话", +// 于是"buddy 通道是好的"这个结论**完全没有证据**。 +// 而 `hy3` 只由 buddy 一个源提供 ⇒ 一调就暴露真因。 +// ⇒ 判据必须断言**源的 URL 形态**(结构不变量),不能靠"某个模型能调通"。 +// +// 判据:base_url 不得自带任何 chat 路径段(它只应是 host[+协议公共前缀]), +// 路径必须在 `endpoint` 里。 +func TestChannelSourceBaseURLHasNoChatPath(t *testing.T) { + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + s := string(src) + + // 只在平台定义区里找 base_url(避免误判注释/文档里的示例) + declRe := regexp.MustCompile(`(?m)^\s*base_url = "([^"]+)"\s*,?$`) + // 已知适配器会把这些路径各追加一次;出现即说明 base_url 侵占了 endpoint 的地盘 + banned := []string{"/chat/completions", "/v1/messages", "/generateContent", "/api/chat"} + + found := 0 + for _, m := range declRe.FindAllStringSubmatch(s, -1) { + url := m[1] + found++ + for _, b := range banned { + if strings.Contains(url, b) { + t.Errorf("base_url 侵占了 endpoint 的路径:%s\n"+ + " 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),"+ + "适配器默认 endpoint=\"/chat/completions\" ⇒ 实际会请求 %s%s\n"+ + " 修法:base_url 只留 host,把 `%s` 移到该平台的 `endpoint = \"...\"`", + url, url, b, b) + } + } + } + if found == 0 { + t.Fatal("没扫到任何 base_url 声明(判据失效)") + } + t.Logf("✓ %d 个 base_url 均不含 chat 路径段", found) +} + +// ★ 反过来也要咬:说明该缺陷**确实已被修好**、而不是判据太松。 +// 用一段**故意的坏数据**验证判据的判别力(变异测试的静态等价物)。 +func TestChannelBaseURLJudgeBitesOnBadInput(t *testing.T) { + bad := ` base_url = "https://copilot.tencent.com/v2/chat/completions",` + declRe := regexp.MustCompile(`(?m)^\s*base_url = "([^"]+)"\s*,?$`) + m := declRe.FindStringSubmatch(bad) + if m == nil { + t.Fatal("判据的正则匹配不到坏数据 ⇒ 判据无判别力(形同虚设)") + } + if !strings.Contains(m[1], "/chat/completions") { + t.Fatal("坏数据未被识别 ⇒ 判据无判别力") + } + t.Log("✓ 判据对坏数据有判别力") +} diff --git a/internal/core/channels_upstream_gaps_test.go b/internal/core/channels_upstream_gaps_test.go index 8f6df0d..eb869da 100644 --- a/internal/core/channels_upstream_gaps_test.go +++ b/internal/core/channels_upstream_gaps_test.go @@ -27,8 +27,12 @@ func TestUpstreamGapsAreDocumented(t *testing.T) { s := string(src) t.Run("qoder 走明文端点且不经过信封", func(t *testing.T) { - // 明文端点(OpenAI 兼容):参考 QODER_CHAT_PATH - if !strings.Contains(s, "https://api2-v2.qoder.sh/model/v1/chat/completions") { + // 明文端点(OpenAI 兼容):参考 QODER_CHAT_PATH。 + // ⚠ 断言 **URL 的两半**而不是那一整串字面量:本仓已把"路径从 base_url + // 移到 endpoint"(内核 URL = base_url + endpoint)—— 继续钉死整串会让 + // 一个纯粹的形态重构把这条**语义**判据弄红,那是判据在咬自己。 + if !strings.Contains(s, `base_url = "https://api2-v2.qoder.sh"`) || + !strings.Contains(s, `endpoint = "/model/v1/chat/completions"`) { t.Fatal("qoder 的 chat 端点变了 —— 若改成加密端点 agent_chat_generation," + "则**必须**同时实现信封解包(qoder-envelope)与 WASM 签名," + "否则响应会被当成畸形数据") diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index c50d88c..d990bd8 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -344,7 +344,13 @@ local PLATFORMS = { }, hosts = { "api.workos.com", "api.cline.bot", "app.cline.bot" }, -- cline 的 chat 路径是 /api/v1/chat/completions(参考 CLINE_CHAT_PATH)。 - base_url = "https://api.cline.bot/api/v1/chat/completions", + base_url = "https://api.cline.bot", + -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url: + -- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL), + -- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含 + -- 完整路径,实际请求就变成 .../api/v1/chat/completions/chat/completions ⇒ 上游 + -- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。 + endpoint = "/api/v1/chat/completions", -- 远端模型目录(照参考 cline-models.ts:**必须同时打两个端点**)。 -- 关键:/api/v1/models 的 460 个 id 里**没有** cline-free/*,免费模型只由 -- recommended-models 下发;只打前者会看不到任何免费模型。 @@ -414,7 +420,13 @@ local PLATFORMS = { }, hosts = { "copilot.tencent.com", "www.codebuddy.cn" }, -- buddy 的 chat 端点含 /v2 前缀(参考 CHAT_API_BASE = .../v2 + /chat/completions)。 - base_url = "https://copilot.tencent.com/v2/chat/completions", + base_url = "https://copilot.tencent.com", + -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url: + -- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL), + -- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含 + -- 完整路径,实际请求就变成 .../v2/chat/completions/chat/completions ⇒ 上游 + -- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。 + endpoint = "/v2/chat/completions", -- 远端模型目录(照参考 buddy.ts CONFIG_PATH + buddy-oauth.ts fetchModels)。 -- 关键:必须带 product 身份头,否则 WorkBuddy 会以 CodeBuddy 身份请求。 models_remote = { @@ -688,7 +700,13 @@ local PLATFORMS = { fallback = "2026.9.4", }, -- lobsterai 的 chat 路径是 /api/proxy/v1/chat/completions(参考 LOBSTERAI_CHAT_PATH)。 - base_url = "https://lobsterai-server.youdao.com/api/proxy/v1/chat/completions", + base_url = "https://lobsterai-server.youdao.com", + -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url: + -- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL), + -- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含 + -- 完整路径,实际请求就变成 .../api/proxy/v1/chat/completions/chat/completions ⇒ 上游 + -- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。 + endpoint = "/api/proxy/v1/chat/completions", -- 远端模型目录(照参考 lobsterai.ts LOBSTERAI_MODELS_PATH) models_remote = { -- 参考真值 LOBSTERAI_API_BASE(lobsterai-server.youdao.com)。 @@ -727,7 +745,17 @@ local PLATFORMS = { vendor = "opencode", flow = "paste", -- opencode 的 chat 路径是 /v1/chat/completions(参考 chatPath)。 - base_url = "https://opencode.ai/zen/v1/chat/completions", + -- ★ base_url 必须保留 `/zen` 段:参考 opencode-product.ts:56 的 + -- `baseUrl: 'https://opencode.ai/zen'` + chatPath `/v1/chat/completions`。 + -- 实测(无凭据): + -- https://opencode.ai/zen/v1/chat/completions → 500(路由存在,鉴权前先炸) + -- https://opencode.ai/v1/chat/completions → 404 + HTML(网页) + base_url = "https://opencode.ai/zen", + -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:内核的 URL 是 + -- `base_url + endpoint`(provider.go ChatURL),而适配器默认 endpoint 是 + -- "/chat/completions" ⇒ 若 base_url 已含完整路径,实际请求就变成 + -- .../v1/chat/completions/chat/completions ⇒ 404 Route Not Found。 + endpoint = "/v1/chat/completions", -- 远端模型目录(照参考 opencode-product.ts modelsPath) models_remote = { endpoints = { { url = "https://opencode.ai/v1/models", method = "GET" } }, @@ -853,7 +881,13 @@ local PLATFORMS = { interval = 2, }, -- qoder 的 chat 路径是 /model/v1/chat/completions(参考 QODER_CHAT_PATH)。 - base_url = "https://api2-v2.qoder.sh/model/v1/chat/completions", + base_url = "https://api2-v2.qoder.sh", + -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url: + -- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL), + -- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含 + -- 完整路径,实际请求就变成 .../model/v1/chat/completions/chat/completions ⇒ 上游 + -- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。 + endpoint = "/model/v1/chat/completions", -- Qoder 的错误信封是**双层编码**的,且 10605(排队) 与 110(额度耗尽) -- 语义相反 —— 通用 openai 适配器只能看到外层,产出用户看不懂的原因串。 -- 故用本插件自装的 channels-qoder(厂商码留在插件域,内核零厂商名)。 @@ -1007,9 +1041,24 @@ local PLATFORMS = { access_key_secret = "zsak6eadrbawz683wf5r3m2snrwj868r", app_id = "GM3LOOMY", }, - -- loomy 的 chat 路径是 {apiBase}/chat/completions(参考 loomy-adapter.ts:494 - -- 实测标准 OpenAI 兼容)。 - base_url = "https://loomy.xunfei.cn/chat/completions", + -- loomy 的 chat 端是 {apiBase}/chat/completions(参考 loomy-adapter.ts:494)。 + -- + -- ★ host 是 **loomyad.xunfei.cn/api/v1**(参考 LOOMY_API_BASE),不是 + -- `loomy.xunfei.cn` —— 后者是 **Web 前端/OAuth 回调**域(本块的 + -- wechat.redirect_uri 正用它)。实测: + -- POST https://loomy.xunfei.cn/chat/completions + -- → 200 + ``(网页!根本不是 API) + -- POST https://loomyad.xunfei.cn/api/v1/chat/completions + -- → 200 + `{"code":"100002","desc":"缺少 token"}`(正常 JSON) + -- 写成前端域时,无论路径怎么拼都不可能是 API。 + -- + -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:内核的 URL 是 + -- `base_url + endpoint`(provider.go ChatURL),而适配器默认 endpoint 是 + -- "/chat/completions" ⇒ 若 base_url 已含完整路径,实际请求就变成 + -- .../chat/completions/chat/completions ⇒ 上游回 + -- `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。 + base_url = "https://loomyad.xunfei.cn/api/v1", + endpoint = "/chat/completions", adapter = "openai", -- 兜底目录(照参考 LOOMY_FALLBACK_MODELS)。此前 "loomy-default" 是我编的。 models = { @@ -1044,7 +1093,13 @@ local PLATFORMS = { }, -- 推理是标准 OpenAI 兼容(实测 POST /api/web/llm/v2/chat/completions) -- raccoon 的 chat 端点是 /api/web/llm/v2/chat/completions。 - base_url = "https://xiaohuanxiong.com/api/web/llm/v2/chat/completions", + base_url = "https://xiaohuanxiong.com", + -- ★ 路径必须在 endpoint 里,**不能**塞进 base_url: + -- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL), + -- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含 + -- 完整路径,实际请求就变成 .../api/web/llm/v2/chat/completions/chat/completions ⇒ 上游 + -- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。 + endpoint = "/api/web/llm/v2/chat/completions", adapter = "openai", -- 兜底目录(照参考 RACCOON_FALLBACK_MODELS)。此前 "raccoon-default" 是我编的。 -- 注意 sn- 前缀是本平台的真实命名约定(sensenova 系列)。 @@ -11117,7 +11172,11 @@ function plugin.__inject_refresh_platform_probe(args) label = id, flow = "manual", adapter = "openai", - base_url = "http://127.0.0.1:1/v1/chat/completions", + base_url = "http://127.0.0.1:1/v1", + -- 照正式平台同样的分工:base_url 只到路径前缀,路径放 endpoint + -- (内核 URL = base_url + endpoint)。测试夹具也照此写, + -- 免得将来有人把这行当范例复制进正式平台。 + endpoint = "/chat/completions", refresh = { style = tostring(args.style or "google"), url = tostring(args.refresh_url or "") }, models = { "m1" }, hosts = { "127.0.0.1" },