mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
fix(channels): 8 个平台 base_url 侵占 endpoint 路径 ⇒ 上游 404 Route Not Found
## 真实缺陷(用户可见) 内核的上游 URL 是 `base_url + endpoint`(provider.go ChatURL),而适配器默认 `endpoint = "/chat/completions"`。多个渠道平台把**完整路径**写进了 `base_url` ⇒ 实际请求变成 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` 覆盖,形态正确)。 另有 loomy 的**第二处**错误:host 用了 `loomy.xunfei.cn`(Web 前端/OAuth 回调域), 实测该 host 的 /chat/completions 返回 **HTML 网页**;正确推理域是参考的 `loomyad.xunfei.cn/api/v1`(返回正常 JSON)。 ## ★ 为什么这个缺陷活了很久(最重要的教训) **模型名被多个源服务**。`deepseek-v4.1-flash` 同时由 4 个源提供 (alittokenplan/vela/oin/channels-buddy)⇒ buddy 源失败后网关**继续尝试下一个源** 并成功 ⇒ 用户看到"能回话" ⇒ "buddy 通道是好的"这个结论**完全没有证据**。 本轮我一度正是这么误判的。而 `hy3` **只由 buddy 一个源**提供 ⇒ 一调就暴露真因。 ⇒ **判据必须断言源的结构不变量(URL 形态),不能靠"某个模型能调通"** —— 后者会被多源冗余掩盖。已按此写判据。 ## 排查手法(可复用) 怀疑"某模型不可用"时,先列**该模型由几个源服务**: 1 个 ⇒ 该模型是那条源的健康探针(用它测) >1 个 ⇒ 它调通**不能**证明任何单一源可用(换独有模型再测) 另:非流式请求会被 buddy 直接 400 拒绝 (`{"code":11101,"msg":"Non-stream chat request is currently not supported"}`), 我第一次用 stream=false 时四个模型全 400,**看不出**模型差异,差点把结论下成 "头组合无区别"就收工。 ## 修法 `base_url` 只留 host(或协议公共前缀),路径移入该平台的 `endpoint`。 逐平台按参考的 `apiBase` + `chatPath` 取值核对,并**实测**修正后的 URL: buddy/cline/lobsterai/qoder/raccoon → 401(缺凭据,正确) loomy → 200 `{"code":"100002","desc":"缺少 token"}`(正确) opencode → 500(路由存在;`https://opencode.ai/v1/...` 是 404+HTML,已证伪) 测试注入平台也一并对齐(免被当范例复制)。 ## 判据(2 条 + 变异 2/2) - `TestChannelSourceBaseURLHasNoChatPath`:静态扫描全部 `base_url`,不得含 `/chat/completions` `/v1/messages` `/generateContent` `/api/chat`。 - `TestChannelBaseURLJudgeBitesOnBadInput`:用**故意坏数据**验证判据的判别力 (防判据自己写歪成恒真)。 变异:把 buddy 恢复成旧形态 → 报红。 ## 顺带修正 `TestUpstreamGapsAreDocumented` 原先钉死 qoder 的**整串 URL 字面量** ⇒ 这次纯形态重构(语义未变)把它弄红了 —— **判据在咬自己**。改为断言 `base_url` 与 `endpoint` **两半**,语义不变但对形态重构免疫。
This commit is contained in:
76
internal/core/channels_endpoint_url_test.go
Normal file
76
internal/core/channels_endpoint_url_test.go
Normal file
@ -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("✓ 判据对坏数据有判别力")
|
||||
}
|
||||
@ -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 签名," +
|
||||
"否则响应会被当成畸形数据")
|
||||
|
||||
@ -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 + `<!DOCTYPE html>`(网页!根本不是 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" },
|
||||
|
||||
Reference in New Issue
Block a user