mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
生产实测逐渠道排查(2026-10-19):
* qoder:PKCE 设备码流无 register 第二跳,落库凭据是裸 token 字符串;
qoderClaim 硬要求结构化 table 导致「缺少凭据对象」(而 account_test
走 token 路径正常 —— 自相矛盾状态)。改为非空裸 tok 就地包
{access_token=tok};补 TestQoderClaimAcceptsBareTokenCredential。
* trae(SOLO 自装适配器):上游对非流式请求也回 SSE(event: 前缀帧),
transform_response 不识别 → 整个 body 原样透传 → Go unmarshal 报
invalid character 'e'。补 aggregate_solo_sse 聚合。
* buddy(CodeBuddy):上游 400 11101 只支持流式。transform_request 强制
stream=true;transform_response 补 SSE 聚合(tool_calls 按 index 拼分片)。
* provider 流式:真实 tool-call 片段之后出现 finish_reason=stop 时归一为
tool_calls(trae 实测如此发);buddy/trae 不再序列化空 tool_calls 表。
* opencode:远端目录端点改为 /zen/v1/models(裸 /v1/models 回 HTML);
兜底目录同步 2026-10-19 实测(space-bunny-free 410 下架等 5 项变化)。
opencodezen 适配器 v1.1.0:会话亲和(client_session / 首 user 消息指纹
分桶),保留免费层门禁四要素。
* 内核:适配器可声明 adapter.hidden = true 退出用户可见列表;被源引用的
隐藏适配器保持可见(源编辑器要回显当前值)。opencodezen 默认隐藏。
* channels UI:切渠道立即清空账号/模型区 + 响应代际校验,修复 qoder 账号
闪现在 opencode 卡片下的渲染竞态。
* channels request_end hook:挂接丢失的 isModelGone/recordDeadModel ——
真实 404(如 codearts GLM-5.3 "The model is not registered")现在会进
deadModels,不再每轮 404 仍可选中。
296 lines
11 KiB
Go
296 lines
11 KiB
Go
package core
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"llmsproxy/internal/lua"
|
||
)
|
||
|
||
// Qoder 活动领取有三条参考用**抓包 + 用户报障**定位过的「会误报」陷阱,
|
||
// 全部必须在判据里钉死:
|
||
//
|
||
// ① 「无可领活动」必须是 inactive,**不能**报 already-claimed
|
||
// (用户报障原文:「没领过却显示今日已领取,去 IDE 看还能领」);
|
||
// ② 刷新前(每日 10:00 UTC+8 之前)看到 CLAIMED 属于**昨天** → 必须报 inactive
|
||
// 而不是"今天已领取"(谎报且不可逆);
|
||
// ③ 未开通要给出 action_required 的可操作提示。
|
||
//
|
||
// 另:`replayed:true` 是幂等判据(HTTP 仍是 200),只看状态码会把"今天已领"
|
||
// 误报成"成功 +100"。
|
||
func TestQoderClaimDecisionTable(t *testing.T) {
|
||
type c struct {
|
||
name string
|
||
campaigns string
|
||
usage string
|
||
wantKind string
|
||
wantAction bool
|
||
wantClaimed int
|
||
}
|
||
cases := []c{
|
||
{
|
||
"无可领且无 CLAIM_BENEFIT,addOnQuota 缺失→inactive+可操作提示",
|
||
`{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"VIEW_DETAILS","claimStatus":"CLAIMABLE"}]}`,
|
||
`{"data":{"userQuota":{"remaining":0}}}`,
|
||
"inactive", true, 0,
|
||
},
|
||
{
|
||
"无可领但确实领过(CLAIMED)→ 视刷新时刻",
|
||
`{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMED"}]}`,
|
||
`{"data":{"addOnQuota":{"remaining":0}}}`,
|
||
"", false, 0, // 见下方:按当前 UTC+8 时刻断言二者之一
|
||
},
|
||
{
|
||
"可领→success(多个活动逐个领)",
|
||
`{"showCampaign":true,"claimable":true,"campaigns":[
|
||
{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":100}},
|
||
{"campaignId":"C2","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":50}}]}`,
|
||
"",
|
||
"success", false, 2,
|
||
},
|
||
{
|
||
"可领但服务端回放 replayed→already-claimed(不是 success)",
|
||
`{"showCampaign":true,"claimable":true,"campaigns":[
|
||
{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":100}}]}`,
|
||
"",
|
||
"already-claimed", false, 1,
|
||
},
|
||
{
|
||
"无任何活动且已开通(addOnQuota 存在)→inactive(不带可操作提示)",
|
||
`{"showCampaign":false,"claimable":false,"campaigns":[]}`,
|
||
`{"data":{"addOnQuota":{"total":100,"remaining":0}}}`,
|
||
"inactive", false, 0,
|
||
},
|
||
{
|
||
"有 CLAIM_BENEFIT 但已过期→inactive",
|
||
`{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"EXPIRED"}]}`,
|
||
`{"data":{"addOnQuota":{"remaining":0}}}`,
|
||
"inactive", false, 0,
|
||
},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
claimed := 0
|
||
var claimBody string
|
||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
switch {
|
||
case r.URL.Path == "/sash/api/v1/me/campaigns":
|
||
fmt.Fprint(w, tc.campaigns)
|
||
case r.URL.Path == "/sash/api/v2/me/usage":
|
||
if tc.usage == "" {
|
||
w.WriteHeader(500)
|
||
return
|
||
}
|
||
fmt.Fprint(w, tc.usage)
|
||
case len(r.URL.Path) > len("/sash/api/v1/me/campaigns/") &&
|
||
r.URL.Path[len(r.URL.Path)-len("/claim"):] == "/claim":
|
||
claimed++
|
||
b := make([]byte, 0)
|
||
if r.ContentLength > 0 {
|
||
b = make([]byte, r.ContentLength)
|
||
_, _ = r.Body.Read(b)
|
||
}
|
||
claimBody = string(b)
|
||
if tc.name == "可领但服务端回放 replayed→already-claimed(不是 success)" {
|
||
fmt.Fprint(w, `{"replayed":true,"status":"CLAIMED"}`)
|
||
} else {
|
||
fmt.Fprint(w, `{"status":"CLAIMED","amount":100}`)
|
||
}
|
||
default:
|
||
w.WriteHeader(404)
|
||
}
|
||
}))
|
||
defer ts.Close()
|
||
|
||
c := kernelTestCore(t)
|
||
ps := c.Plugins()
|
||
src, err := readBundledChannels(t)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.LoadSource("channels", src); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||
"platform": "qoder",
|
||
"qoder_sash_base": map[string]interface{}{"url": ts.URL},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
cred, _ := json.Marshal(map[string]interface{}{"access_token": "AT-1", "uid": "u1"})
|
||
body, _ := json.Marshal(map[string]string{
|
||
"platform": "qoder", "credential": string(cred), "account": "t",
|
||
})
|
||
if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
|
||
Method: "POST", Role: "admin", Body: string(body),
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
|
||
Method: "POST", Role: "admin", Body: `{"platform":"qoder"}`,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("claim_daily: %v", err)
|
||
}
|
||
j, _ := out.JSON.(map[string]interface{})
|
||
oc, _ := j["outcome"].(map[string]interface{})
|
||
if oc == nil {
|
||
t.Fatalf("应返回 outcome,实得 %v", j)
|
||
}
|
||
kind, _ := oc["kind"].(string)
|
||
|
||
if tc.wantKind == "" {
|
||
// 刷新时刻决定 already-claimed 还是 inactive —— 两者都合法,
|
||
// 但**绝不能**是 success(今天是否领过不能靠"看到 CLAIMED"推断成可以再领)。
|
||
if kind != "already-claimed" && kind != "inactive" {
|
||
t.Fatalf("刷新前/后都应报 already-claimed 或 inactive,实得 %q(%v)", kind, oc["message"])
|
||
}
|
||
} else if kind != tc.wantKind {
|
||
t.Fatalf("kind 应为 %s,实得 %q(%v)", tc.wantKind, kind, oc["message"])
|
||
}
|
||
if tc.wantAction && oc["action_required"] != true {
|
||
t.Errorf("未开通场景应带 action_required=true(提示去官方客户端),实得 %v", oc["action_required"])
|
||
}
|
||
if claimed != tc.wantClaimed {
|
||
t.Errorf("claim 请求次数 = %d,期望 %d", claimed, tc.wantClaimed)
|
||
}
|
||
if tc.wantClaimed > 0 && claimBody != "" {
|
||
t.Errorf("claim 请求体必须是空串(抓包实测 content-length: 0),实得 %q", claimBody)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 「今天的活动列表是否已刷新」必须用 **UTC+8 算术平移**判定。
|
||
// ⚠️ 本机时区恰是 HKT(+0800),所以「用本机时区判」的实现在真实时刻下**结果相同**
|
||
// —— 那是判据不可达(这台机器上永远抓不到)。必须注入 UTC 时刻来覆盖两个方向:
|
||
// 判据的输入是 UTC 毫秒,与运行环境时区无关。
|
||
func TestQoderRefreshUsesUTC8NotLocalTime(t *testing.T) {
|
||
c := kernelTestCore(t)
|
||
ps := c.Plugins()
|
||
src, err := readBundledChannels(t)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.LoadSource("channels", src); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// UTC 01:00 = UTC+8 09:00 → **未刷新**(阈值 10:00)
|
||
// UTC 02:00 = UTC+8 10:00 → **已刷新**
|
||
// 若实现误用本机时区(HKT=UTC+8),对"UTC 毫秒"这个输入会得到:
|
||
// 把 07:00Z 当本地 15:00 → 判已刷新(错)
|
||
cases := []struct {
|
||
name string
|
||
utcHour int
|
||
want bool
|
||
}{
|
||
{"UTC 01:00(UTC+8 09:00,未到 10:00)", 1, false},
|
||
{"UTC 02:00(UTC+8 10:00,刚好刷新)", 2, true},
|
||
{"UTC 00:30(UTC+8 08:30,未刷新)", 0, false},
|
||
{"UTC 15:00(UTC+8 23:00,已刷新)", 15, true},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
// 构造该 UTC 时刻的毫秒值(固定日期,避免跨日干扰)
|
||
// 用 time.Date 明确构造「00:00 UTC + N 小时」——手算 epoch 会引入
|
||
// 时区/跨日误差(第一版就是这么错的:基准不是 UTC 零点)。
|
||
ms := time.Date(2025, 10, 9, tc.utcHour, 0, 0, 0, time.UTC).UnixMilli()
|
||
var rec map[string]interface{}
|
||
got, err := ps.Call("channels", "__qoder_refresh_probe", map[string]interface{}{
|
||
"now_ms": float64(ms),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("probe: %v", err)
|
||
}
|
||
w, _ := got.(map[string]interface{})
|
||
rec, _ = w["json"].(map[string]interface{})
|
||
if rec == nil {
|
||
t.Fatalf("无 json: %#v", got)
|
||
}
|
||
if rec["refreshed"] != tc.want {
|
||
t.Fatalf("refreshed 应为 %v,实得 %v(UTC+8 阈值 10:00)", tc.want, rec["refreshed"])
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 裸 token 凭据必须能领:qoder 是 PKCE 设备码流且无 register 第二跳,
|
||
// complete_login 存的就是裸 access_token 字符串(真实生产事故:账号池凭据是
|
||
// dt-… 裸串,claim 报「缺少凭据对象」,而 account_test 用 token 路径照常通过
|
||
// —— 自相矛盾的状态)。活动接口只要 Bearer token,就地包一层即可。
|
||
func TestQoderClaimAcceptsBareTokenCredential(t *testing.T) {
|
||
var auth string
|
||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
auth = r.Header.Get("Authorization")
|
||
switch {
|
||
case r.URL.Path == "/sash/api/v1/me/campaigns":
|
||
fmt.Fprint(w, `{"showCampaign":true,"claimable":true,"campaigns":[
|
||
{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":100}}]}`)
|
||
case len(r.URL.Path) > len("/sash/api/v1/me/campaigns/") &&
|
||
r.URL.Path[len(r.URL.Path)-len("/claim"):] == "/claim":
|
||
fmt.Fprint(w, `{"status":"CLAIMED","amount":100}`)
|
||
default:
|
||
w.WriteHeader(404)
|
||
}
|
||
}))
|
||
defer ts.Close()
|
||
|
||
c := kernelTestCore(t)
|
||
ps := c.Plugins()
|
||
src, err := readBundledChannels(t)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.LoadSource("channels", src); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||
"platform": "qoder",
|
||
"qoder_sash_base": map[string]interface{}{"url": ts.URL},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 与生产一致:凭据是**裸 token 字符串**(不是 JSON 对象)。
|
||
body, _ := json.Marshal(map[string]string{
|
||
"platform": "qoder", "credential": "dt-D6t0cFLOY3fpSmu2Mck2WWeO", "account": "t",
|
||
})
|
||
if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
|
||
Method: "POST", Role: "admin", Body: string(body),
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
|
||
Method: "POST", Role: "admin", Body: `{"platform":"qoder"}`,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("claim_daily: %v", err)
|
||
}
|
||
j, _ := out.JSON.(map[string]interface{})
|
||
oc, _ := j["outcome"].(map[string]interface{})
|
||
if oc == nil {
|
||
t.Fatalf("应返回 outcome,实得 %v", j)
|
||
}
|
||
// claim_daily 的通用路径把 claim_fn 的 kind=claimed 归一成 success + message;
|
||
// 判据是**不再是 failed** 且消息带领取金额。
|
||
kind, _ := oc["kind"].(string)
|
||
if kind == "failed" {
|
||
t.Fatalf("裸 token 凭据应能领取,实得 kind=failed(%v)", oc["message"])
|
||
}
|
||
if msg, _ := oc["message"].(string); !strings.Contains(msg, "100") {
|
||
t.Errorf("领取成功消息应带金额,实得 %q", msg)
|
||
}
|
||
if auth != "Bearer dt-D6t0cFLOY3fpSmu2Mck2WWeO" {
|
||
t.Errorf("活动接口必须带 Bearer 裸 token,实得 %q", auth)
|
||
}
|
||
}
|