Files
ModelRouter/internal/core/channels_upstream_gaps_test.go
JianFeeeee 3ac4b35eb1 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` **两半**,语义不变但对形态重构免疫。
2026-10-16 13:52:02 +08:00

68 lines
3.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"strings"
"testing"
)
// ★ 本文件把「上游客观缺口」写成**可执行的判据**,而不是只留在文档里。
//
// 为什么需要:这些能力缺口多次被误判成"我们漏实现了"(我自己就误判了两轮),
// 于是反复派人去"补"。判据在此把事实钉住:
//
// - qoder 加密端点(agent_chat_generation)需要 `qoder-auth-wasm.wasm`
// —— 该二进制**在参考仓库里也不存在**(参考自己也是 readFileSync 一个
// 它不提供的文件)。故我们走明文端点 `/model/v1/chat/completions`,
// **不经过信封** ⇒ qoder-envelope 的解包能力在明文路径上无从生效。
// - TRAE 的 PKCE 分支参考**明确不支持**(只有"探测并拒绝",无 code→token
// 交换端点)⇒ 我们报"暂不支持"是与参考对齐的正确行为。
//
// 判据的作用是**防止误判**:任何人(包括未来的我)想"补"这些能力时,
// 必须先让这个测试失败 —— 也就是先推翻事实,再动手。
func TestUpstreamGapsAreDocumented(t *testing.T) {
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
s := string(src)
t.Run("qoder 走明文端点且不经过信封", func(t *testing.T) {
// 明文端点(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 签名," +
"否则响应会被当成畸形数据")
}
// 明文端点没有信封层,故解包**不该**存在(存在反而是死代码)
if strings.Contains(s, "unwrapQoderEnvelope") {
t.Fatal("出现了 qoder 信封解包 —— 明文端点没有信封层。" +
"要么端点已换成加密端点(那需要 wasm),要么这是死代码")
}
})
t.Run("TRAE PKCE 明确报不支持(与参考一致)", func(t *testing.T) {
if !strings.Contains(s, "本实现暂不支持该流程") {
t.Fatal("TRAE 的 PKCE 分支必须明确报出「暂不支持」—— " +
"参考 trae-oauth.ts 也只做探测+拒绝,没有 code→token 交换端点。" +
"若改为尝试交换,会拿 code 去打要 RefreshToken 的 ExchangeToken(必然 400)")
}
})
t.Run("codearts 目录打两个签名端点(本轮补上)", func(t *testing.T) {
for _, want := range []string{
"opengw.developer.huaweicloud.com/api/v1/gateway/config",
"snap-access.cn-north-4.myhuaweicloud.com/v1/model/builtin",
`sign = "codearts"`,
} {
if !strings.Contains(s, want) {
t.Fatalf("codearts 目录缺 %q —— 参考 fetchCodeArtsRemoteModels 打两个"+
"**必须 AK/SK 签名**的端点,只打一个会少一半模型", want)
}
}
})
}