package upstream import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/linguo2625469/workbuddy2api-panel/internal/auth" ) func TestClassify(t *testing.T) { cases := []struct { status int body string want ErrKind }{ {402, ``, ErrHardCredit}, {400, `{"code":1,"msg":"余额不足"}`, ErrHardCredit}, {403, `insufficient credits`, ErrHardCredit}, {200, `{"code":10001,"msg":"积分不足,请充值"}`, ErrHardCredit}, {400, `{"code":1,"msg":"额度用尽"}`, ErrHardCredit}, {429, ``, ErrSoftRate}, // 限流文案(issue #28):状态码不是 429 时也必须识别为软限流, // 否则账号不会被冷却,下次请求仍会被选中。 {200, `{"code":11140,"msg":"The model provider is rate-limiting requests. Please wait a moment and try again."}`, ErrSoftRate}, {400, `rate limit`, ErrSoftRate}, {403, `usage limit reached`, ErrSoftRate}, // "model usage limit exceeded" 不是余额语义(无 credit/quota/积分/额度 等计费词), // 属于模型侧用量节流 → 短冷却(误判为硬冷却会把有余量的号停到次日 04:00)。 {200, `{"code":1,"msg":"model usage limit exceeded"}`, ErrSoftRate}, {200, `{"code":1,"msg":"too many requests"}`, ErrSoftRate}, {500, `rate-limited upstream`, ErrSoftRate}, // 限流文案优先于 5xx 分类 // 内容策略拦截(HTTP 400 + 审核文案):误报信号,不罚账号,走降级重试。 {400, `Illegal API invocation from an unapproved channel`, ErrContentBlocked}, {400, `{"code":11128,"msg":"blocked by security policy"}`, ErrContentBlocked}, {400, `unapproved channel`, ErrContentBlocked}, // 通用 4xx(非审核文案):仍判 ErrClient,只换号不罚。 {400, `bad request`, ErrClient}, // ErrBadParams:请求体解析失败(HTTP 400 + Unmarshal chat params failed / code 11101)。 // 这是"发给上游的 body 有问题"(网关截断已由 413 消灭,剩余为客户端畸形 JSON), // 换了账号也一样 400,不罚号。具体词优先于通用 4xx。 {400, `{"code":11101,"msg":"Unmarshal chat params failed with error: unexpected EOF"}`, ErrBadParams}, {400, `Unmarshal chat params failed`, ErrBadParams}, {400, `{"code":11101,"msg":"x"}`, ErrBadParams}, // 图片格式/数据错误是确定性请求错误,分类后不轮转、不罚号。 {400, `{"code":11101,"msg":"Parse message failed: invalid image_url content at index 2: json: cannot unmarshal string into Go value of type v2.ImageContent"}`, ErrImageInvalid}, {400, `{"code":11135,"msg":"invalid_image_data"}`, ErrImageInvalid}, {400, `invalid_image_data`, ErrImageInvalid}, // 11135 业务码须容忍 JSON 空白(5d5223d:字面量 marker 只覆盖紧凑形态)。 {400, `{"code": 11135, "msg":"image data invalid"}`, ErrImageInvalid}, {400, `{"error":{"code": "11135", "message":"image invalid"}}`, ErrImageInvalid}, // 防过宽:11133(模型不支持图片)不进 image_invalid。 {400, `{"code": 11133, "msg":"model does not support image"}`, ErrClient}, {200, `quota exceeded`, ErrHardCredit}, // session 死亡优先于限流文案(401+12153 需人工重登,短冷却无意义)。 {401, `{"code":12153,"msg":"Offline user session not found, rate limit"}`, ErrSessionDead}, {401, `Offline user session not found`, ErrSessionDead}, {401, `{"code":12153,"msg":"Offline user session not found"}`, ErrSessionDead}, {401, `{"code":9999,"msg":"bad token"}`, ErrClient}, {500, `boom`, ErrServer}, {503, `unavailable`, ErrServer}, {200, ``, ErrNone}, // 11102「该后端无此模型」:确定性答复,归 ErrModelBlocked((账号,模型) 负缓存避让)。 {404, `{"code":11102,"msg":"model [deepseek-v3-2-volc] service info not found"}`, ErrModelBlocked}, {400, `{"error":{"code":"11102","message":"model service info not found"}}`, ErrModelBlocked}, {400, `{"msg":"service info not found"}`, ErrModelBlocked}, // 11102 撞在 requestId 上不算(不得误避让可用模型)。 {404, `{"requestId":"11102","msg":"ok"}`, ErrNotFound}, // 429 + 11102 → 限流语义(ErrSoftRate),不是模型不存在。 {429, `{"code":11102,"msg":"service info not found"}`, ErrSoftRate}, // 429 + 余额措辞 → 限流语义(fork-scan-absorb T-3,本次修复点):限流响应 // body 高频携带 "quota exceeded"/"额度不足" 等跨计费/限流两界的措辞, // hardRule 在 429 之前会误判 ErrHardCredit 硬冷却到次日 04:00,白扔号约 12h。 // 状态码是比关键词更权威的信号:真余额耗尽走 402,非 429 的 quota 措辞 // 仍归 hardRule(上方 {200,"quota exceeded"} 语义不变)。 {429, `quota exceeded`, ErrSoftRate}, {429, `{"code":1,"msg":"quota exceeded, please wait"}`, ErrSoftRate}, {429, `insufficient credits`, ErrSoftRate}, {429, `{"code":1,"msg":"额度不足"}`, ErrSoftRate}, {429, `积分不足,请充值`, ErrSoftRate}, // 429 + 账号级故障码防回归(accountFault 仍先于 429 判定):429+14017 若 // 落到 status==429 兜底会误归 soft_rate,账号级故障等不来自愈。 {429, `{"code":14017,"msg":"trial not activated"}`, ErrAccountFault}, {429, `{"error":{"data":{"code":11140,"msg":"request illegal"}}}`, ErrAccountFault}, // Issue #175:14018 明确表示账号积分耗尽,即使 HTTP 状态是 429 也必须 // 走硬积分冷却;仅有相同文案而无该业务码的普通 429 仍保持软限流。 {429, `{"code":14018,"msg":"Credits exhausted"}`, ErrHardCredit}, {429, `{"error":{"data":{"code":"14018","msg":"Credits exhausted"}}}`, ErrHardCredit}, {429, `{"requestId":"14018","msg":"Credits exhausted"}`, ErrSoftRate}, {429, `{"code":1,"msg":"Credits exhausted"}`, ErrSoftRate}, // WAF 403(P0-1):403 + 无业务信封(无 "code":/"msg": 字段)→ ErrWafBlock。 // 空体 / HTML 拦截页 / 纯文本 / 非信封 JSON 均命中。 {403, ``, ErrWafBlock}, {403, `
403 Forbidden`, ErrWafBlock}, {403, `Forbidden`, ErrWafBlock}, {403, `{"message":"blocked by waf"}`, ErrWafBlock}, {403, `blocked`, ErrWafBlock}, // 403 带业务信封的仍走既有分类(P0-1 约束:不劫持业务 403)。 {403, `{"code":11128,"msg":"blocked by security policy"}`, ErrContentBlocked}, {403, `{"code":60001,"msg":"quota exceeded"}`, ErrHardCredit}, {403, `{"code":1,"msg":"unknown business error"}`, ErrClient}, // 非 403 的无信封错误体不进 WAF 分类(WAF 判定绑定 403 形态)。 {400, `bad request`, ErrClient}, {429, ``, ErrSoftRate}, } for _, c := range cases { if got := Classify(c.status, c.body); got != c.want { t.Errorf("Classify(%d,%q)=%v want %v", c.status, c.body, got, c.want) } } } // TestIsModelRateLimit 判断 429 body 是否明确指向模型级限流(code 6004)。 func TestIsModelRateLimit(t *testing.T) { cases := []struct { body string want bool }{ // 6004:模型级限流(issue #31 的核心场景)。 {`{"code":6004,"msg":"将在 2026-09-11 18:33:27 UTC+8 重置"}`, true}, {`{"code": 6004,"msg":"x"}`, true}, // 其他 code(非模型级限流)→ 不算。 {`{"code":11140,"msg":"The model provider is rate-limiting requests."}`, false}, {`{"code":1,"msg":"429 rate limit"}`, false}, } for _, c := range cases { if got := IsModelRateLimit(c.body); got != c.want { t.Errorf("IsModelRateLimit(%q)=%v want %v", c.body, got, c.want) } } } // TestParseSoftRateReset 解析上游 429 6004 msg 里的「将在 … 重置」时间(## UTC+8)。 func TestParseSoftRateReset(t *testing.T) { future := time.Now().Add(35 * time.Minute) ts := future.In(softRateResetLoc).Format("2006-01-02 15:04:05") cases := []struct { name string body string ok bool }{ {"6004 带时间+UTC+8 后缀", `{"code":6004,"msg":"将在 ` + ts + ` UTC+8 重置"}`, true}, {"6004 带时间无后缀", `{"code":6004,"msg":"将在 ` + ts + ` 重置"}`, true}, {"6004 无时间文案", `{"code":6004,"msg":"model usage limit exceeded"}`, false}, {"非 6004 但带时间(ParseRateReset 统一解析;模型级豁免由调用侧按 6004 判定)", `{"code":11140,"msg":"将在 ` + ts + ` UTC+8 重置"}`, true}, {"非法时间格式", `{"code":6004,"msg":"将在 明天 重置"}`, false}, {"空 body", ``, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got, ok := ParseRateReset(c.body) if ok != c.ok { t.Fatalf("ok=%v want %v (body=%s)", ok, c.ok, c.body) } if ok { // 解析结果 = ts 在 UTC+8 解释下的墙钟(截断到分钟),应与 future 相差 ±2 分钟。 if d := got.Sub(future); d < -2*time.Minute || d > 2*time.Minute { t.Errorf("parsed=%v want ~%v (diff %v)", got, future, d) } if got.Location() != time.UTC { // 不同指针的 FixedZone 实例相等性按 offset 判,这里只断言 offset。 if _, off := got.Zone(); off != 8*60*60 { t.Errorf("zone offset=%d want +08:00", off) } } } }) } } type rtFunc func(*http.Request) (*http.Response, error) func (f rtFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } func jsonResp(status int, body string) *http.Response { return &http.Response{ StatusCode: status, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body)), } } func testClient(fn rtFunc) *Client { return &Client{ HTTP: &http.Client{Transport: fn}, ChatBaseCN: "https://chat.example", BillingBaseCN: "https://billing.example", } } func TestRefreshSuccess(t *testing.T) { c := testClient(func(r *http.Request) (*http.Response, error) { if !strings.HasSuffix(r.URL.Path, "/v2/plugin/auth/token/refresh") { return nil, errors.New("wrong path: " + r.URL.Path) } if r.Header.Get("X-Refresh-Token") != "oldrt" { return nil, errors.New("missing X-Refresh-Token") } return jsonResp(200, `{"code":0,"msg":"ok","data":{"accessToken":"newat","refreshToken":"newrt","expiresIn":3600}}`), nil }) a := &auth.Auth{AccessToken: "at", RefreshToken: "oldrt", ExpiresAt: 1} if err := c.RefreshToken(a); err != nil { t.Fatalf("refresh: %v", err) } if a.AccessToken != "newat" || a.RefreshToken != "newrt" { t.Errorf("tokens not updated: %+v", a) } if a.ExpiresAt <= 1 { t.Errorf("expiresAt not advanced: %d", a.ExpiresAt) } } func TestRefreshPreservesExpiryWhenOmitted(t *testing.T) { c := testClient(func(r *http.Request) (*http.Response, error) { return jsonResp(200, `{"code":0,"data":{"accessToken":"newat"}}`), nil }) a := &auth.Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1753600000} if err := c.RefreshToken(a); err != nil { t.Fatalf("refresh: %v", err) } if a.ExpiresAt != 1753600000 { t.Errorf("expiresAt should be preserved, got %d", a.ExpiresAt) } if a.RefreshToken != "rt" { t.Errorf("refreshToken should be preserved, got %s", a.RefreshToken) } } func TestRefreshSessionDead(t *testing.T) { c := testClient(func(r *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 401, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"code":12153,"msg":"Offline user session not found"}`)), }, nil }) a := &auth.Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1} err := c.RefreshToken(a) if err == nil { t.Fatal("want error") } var ue *Error if !errors.As(err, &ue) { t.Fatalf("want *Error, got %T %v", err, err) } if ue.Kind != ErrSessionDead { t.Errorf("kind=%v want ErrSessionDead", ue.Kind) } } func TestChatStreamSendsHeadersAndStreamTrue(t *testing.T) { var gotAuth, gotUID, gotProduct string var gotBody []byte c := testClient(func(r *http.Request) (*http.Response, error) { gotAuth = r.Header.Get("Authorization") gotUID = r.Header.Get("X-User-Id") gotProduct = r.Header.Get("X-Product") gotBody, _ = io.ReadAll(r.Body) return &http.Response{ StatusCode: 200, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), }, nil }) a := &auth.Auth{AccessToken: "at", UID: "u1", EnterpriseID: "e1"} rc, status, respBody, err := c.ChatStream(a, []byte(`{"model":"glm-5.2","messages":[]}`), "", ChatMeta{}) if err != nil || status != 200 { t.Fatalf("chat: status=%d err=%v", status, err) } if respBody != nil { t.Errorf("200 response should carry nil body, got %q", respBody) } rc.Close() if gotAuth != "Bearer at" || gotUID != "u1" || gotProduct != "WorkBuddy" { t.Errorf("headers: auth=%q uid=%q product=%q", gotAuth, gotUID, gotProduct) } if !bytes.Contains(gotBody, []byte(`"stream":true`)) { t.Errorf("stream not forced: %s", gotBody) } } func TestFetchModelsEffortsDriveBodyDowngrade(t *testing.T) { var outbound []byte c := testClient(func(r *http.Request) (*http.Response, error) { switch { case strings.HasSuffix(r.URL.Path, "/console/enterprises/personal/models"): return jsonResp(200, `{"code":0,"data":{"models":[ {"id":"glm-5.2","name":"GLM-5.2","maxInputTokens":131072,"maxOutputTokens":8192,"reasoning":{"effort":"high","supportedEfforts":["low","high"]}} ],"agents":[{"name":"cli","models":["glm-5.2"]}]}}`), nil case strings.HasSuffix(r.URL.Path, "/v3/config"): return jsonResp(200, `{"code":0,"data":{"models":[]}}`), nil default: outbound, _ = io.ReadAll(r.Body) return &http.Response{ StatusCode: 200, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), }, nil } }) a := &auth.Auth{AccessToken: "at", UID: "u1"} infos, err := c.FetchModels(a) if err != nil { t.Fatalf("fetch models: %v", err) } if len(infos) != 1 { t.Fatalf("infos=%+v", infos) } // ModelInfo.Efforts 应携带 supportedEfforts,DefaultEffort 应携带 reasoning.effort if len(infos[0].Efforts) != 2 || infos[0].Efforts[0] != "low" { t.Errorf("infos[0].Efforts=%v", infos[0].Efforts) } if infos[0].DefaultEffort != "high" { t.Errorf("infos[0].DefaultEffort=%q want high", infos[0].DefaultEffort) } // glm-5.2 只支持 low/high,请求 max → 降级为 high rc, status, _, err := c.ChatStream(a, []byte(`{"model":"glm-5.2","reasoning_effort":"max","messages":[]}`), "", ChatMeta{}) if err != nil || status != 200 { t.Fatalf("chat: status=%d err=%v", status, err) } rc.Close() var m map[string]any if err := json.Unmarshal(outbound, &m); err != nil { t.Fatalf("outbound unmarshal: %v (%s)", err, outbound) } if got, _ := m["reasoning_effort"].(string); got != "high" { t.Errorf("reasoning_effort=%v want high (outbound=%s)", m["reasoning_effort"], outbound) } } func TestChatStreamHardCreditError(t *testing.T) { c := testClient(func(r *http.Request) (*http.Response, error) { return jsonResp(402, `{"code":1,"msg":"余额不足"}`), nil }) a := &auth.Auth{AccessToken: "at", UID: "u1"} _, status, respBody, err := c.ChatStream(a, []byte(`{}`), "", ChatMeta{}) if status != 402 { t.Errorf("status=%d", status) } // 错误信封一次成型:≥400 返回已分类的 *Error(Kind + body 全量仍经 respBody 透出) var ue *Error if !errors.As(err, &ue) || ue.Kind != ErrHardCredit { t.Fatalf("hard credit should return classified *Error envelope, got %v", err) } if len(respBody) == 0 { t.Errorf("body should still be returned for passthrough") } } // TestChatStreamReadsMultipleChunksOverRealTransport 走真实 net/http 传输层, // 回归 defer cancel() 导致第二块起 body Read 返回 context canceled 的断流 bug。 func TestChatStreamReadsMultipleChunksOverRealTransport(t *testing.T) { const frames = 6 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") flusher, ok := w.(http.Flusher) if !ok { t.Error("http.ResponseWriter does not implement http.Flusher") return } for i := 1; i <= frames; i++ { if _, err := fmt.Fprintf(w, "data: chunk-%d\n\n", i); err != nil { return } flusher.Flush() time.Sleep(20 * time.Millisecond) } })) defer srv.Close() c := New() c.ChatBaseCN = srv.URL c.IdleTimeout = 5 * time.Second a := &auth.Auth{AccessToken: "at", UID: "u1"} rc, status, _, err := c.ChatStream(a, []byte(`{"model":"glm-5.2","messages":[]}`), "", ChatMeta{}) if err != nil || status != 200 { t.Fatalf("chat: status=%d err=%v", status, err) } defer rc.Close() buf := make([]byte, 1) var got string for i := 0; i < frames; i++ { if _, err := io.ReadFull(rc, buf); err != nil { t.Fatalf("read %d: %v (real transport body must not be cut)", i, err) } got += string(buf) } if strings.Contains(got, "context canceled") { t.Fatalf("body read hit context canceled, got %q", got) } } func TestUserResourceAggregation(t *testing.T) { c := testClient(func(r *http.Request) (*http.Response, error) { if !strings.HasSuffix(r.URL.Path, "/v2/billing/meter/get-user-resource") { return nil, errors.New("wrong path: " + r.URL.Path) } if r.Method != http.MethodPost { return nil, errors.New("want POST") } body, _ := io.ReadAll(r.Body) if !bytes.Contains(body, []byte(`"ProductCode":"p_tcaca"`)) { return nil, errors.New("missing ProductCode: " + string(body)) } return jsonResp(200, `{"code":0,"data":{"Response":{"Data":{"TotalCount":2,"TotalDosage":3000,"Accounts":[ {"PackageName":"签到包","CapacitySize":2000,"CapacityRemain":1200,"CapacityUsed":800,"CycleCapacitySize":2000,"CycleCapacityRemain":1200,"CycleCapacityUsed":800}, {"PackageName":"体验包","CapacitySize":1000,"CapacityRemain":300,"CapacityUsed":700,"CycleCapacitySize":1000,"CycleCapacityRemain":300,"CycleCapacityUsed":700} ]}}}}`), nil }) a := &auth.Auth{AccessToken: "at", UID: "u1"} remain, total, err := c.UserResource(a) if err != nil { t.Fatalf("resource: %v", err) } if remain != 1500 { t.Errorf("remain=%d want 1500", remain) } if total != 3000 { t.Errorf("total=%d want 3000", total) } } func TestUserResourceNegativeClamped(t *testing.T) { c := testClient(func(r *http.Request) (*http.Response, error) { return jsonResp(200, `{"code":0,"data":{"Response":{"Data":{"Accounts":[ {"PackageName":"p","CycleCapacitySize":100,"CycleCapacityRemain":-50,"CycleCapacityUsed":150} ]}}}}`), nil }) remain, total, err := c.UserResource(&auth.Auth{AccessToken: "at"}) if err != nil || remain != 0 { t.Errorf("remain=%d err=%v, want 0 (clamped)", remain, err) } if total != 100 { t.Errorf("total=%d want 100", total) } } func TestDailyCheckinAlready(t *testing.T) { c := testClient(func(r *http.Request) (*http.Response, error) { if !strings.HasSuffix(r.URL.Path, "/v2/billing/meter/daily-checkin") { return nil, errors.New("wrong path") } return jsonResp(200, `{"code":14001,"msg":"今日已签到"}`), nil }) err := c.DailyCheckin(&auth.Auth{AccessToken: "at"}) if err == nil || !strings.Contains(err.Error(), "已签到") { t.Errorf("err=%v", err) } } func TestBasesAlwaysCN(t *testing.T) { c := testClient(nil) cn := &auth.Auth{Domain: ""} other := &auth.Auth{Domain: "example.com"} if c.chatBase(cn) != "https://chat.example" || c.billingBase(cn) != "https://billing.example" { t.Error("cn bases wrong") } // 恒 CN:domain 不同不改变上游 host。 if c.chatBase(other) != c.chatBase(cn) || c.billingBase(other) != c.billingBase(cn) { t.Error("bases must be CN regardless of domain") } } func TestNewChatClientNoTotalTimeoutAndSharedTransport(t *testing.T) { c := New() if c.ChatHTTP == nil { t.Fatal("ChatHTTP should be initialized") } if c.ChatHTTP.Timeout != 0 { t.Errorf("ChatHTTP.Timeout=%v want 0 (no total cap)", c.ChatHTTP.Timeout) } // 共享同一个 Transport 实例,连接池不重复。 if c.ChatHTTP.Transport != c.HTTP.Transport { t.Errorf("ChatHTTP and HTTP must share the same *http.Transport") } htr, ok := c.ChatHTTP.Transport.(*http.Transport) if !ok { t.Fatalf("Transport type=%T", c.ChatHTTP.Transport) } if htr.ResponseHeaderTimeout != 60*time.Second { // 连接层加固:响应头上限从 120s 收到 60s(慢冷启动留 3.75× 余量) t.Errorf("ResponseHeaderTimeout=%v want 60s", htr.ResponseHeaderTimeout) } } func TestChatStreamRoutesToChatHTTP(t *testing.T) { // 显式注入 ChatHTTP(可辨识标记),验证 ChatStream 走它而非 HTTP。 chatHit, httpHit := false, false c := testClient(func(*http.Request) (*http.Response, error) { httpHit = true return jsonResp(200, `{}`), nil }) c.ChatHTTP = &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) { chatHit = true return &http.Response{ StatusCode: 200, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), }, nil })} a := &auth.Auth{AccessToken: "at", UID: "u1"} rc, status, _, err := c.ChatStream(a, []byte(`{}`), "", ChatMeta{}) if err != nil || status != 200 { t.Fatalf("chat: status=%d err=%v", status, err) } rc.Close() if !chatHit { t.Error("ChatStream should use ChatHTTP") } if httpHit { t.Error("ChatStream must not use HTTP") } } func TestChatHTTPNilFallsBackToHTTP(t *testing.T) { c := testClient(func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 200, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), }, nil }) if c.chatHTTP() != c.HTTP { t.Error("chatHTTP() should fall back to HTTP when ChatHTTP is nil") } } func TestFetchModelsDefaultEffortDualKeyAndSizes(t *testing.T) { // 上游双键:老模型 reasoning.effort(auto),新模型(glm-5.3 系)只有 reasoning.defaultEffort; // credits/maxAllowedSize/canDisableThinking/supportsReasoning 等尺寸与能力字段应一并透出。 c := testClient(func(r *http.Request) (*http.Response, error) { return jsonResp(200, `{"code":0,"data":{"models":[ {"id":"glm-5.3","maxInputTokens":1000000,"maxOutputTokens":48000,"maxAllowedSize":1000000,"credits":"x0.79","supportsReasoning":true,"reasoning":{"defaultEffort":"high","canDisableThinking":true,"supportedEfforts":["low","high","max"]}}, {"id":"auto","maxInputTokens":168000,"maxOutputTokens":32000,"supportsReasoning":true,"reasoning":{"effort":"high"}} ],"agents":[{"name":"cli","models":["glm-5.3","auto"]}]}}`), nil }) a := &auth.Auth{AccessToken: "at", UID: "u1"} infos, err := c.FetchModels(a) if err != nil { t.Fatalf("fetch models: %v", err) } byID := map[string]ModelInfo{} for _, mi := range infos { byID[mi.ID] = mi } g := byID["glm-5.3"] if g.DefaultEffort != "high" { t.Errorf("glm-5.3 DefaultEffort=%q want high (from defaultEffort key)", g.DefaultEffort) } if !g.CanDisableThinking || !g.SupportsReasoning { t.Errorf("glm-5.3 capability flags: canDisable=%v supportsReasoning=%v want true/true", g.CanDisableThinking, g.SupportsReasoning) } if g.MaxAllowedSize != 1000000 || g.MaxTokens != 48000 || g.Credits != "x0.79" { t.Errorf("glm-5.3 sizes: maxAllowed=%d maxOut=%d credits=%q", g.MaxAllowedSize, g.MaxTokens, g.Credits) } if got := c.ModelRate("cn", "glm-5.3"); got != "0.79" { t.Errorf("glm-5.3 ModelRate=%q want 0.79", got) } if au := byID["auto"]; au.DefaultEffort != "high" { t.Errorf("auto DefaultEffort=%q want high (from legacy effort key)", au.DefaultEffort) } } func TestModelRateCacheEffectiveAndNormalized(t *testing.T) { c := New() factor := 0.5 c.storeModelRates("cn", []ModelInfo{ {ID: "base", Credits: "x0.50 credits"}, {ID: "promo", Credits: "x0.80", PromoFactor: &factor, PromoCredits: "0.50x"}, }) if got := c.ModelRate("cn", "base"); got != "0.5" { t.Fatalf("base rate=%q want 0.5", got) } if got := c.ModelRate("cn", "promo"); got != "0.5" { t.Fatalf("promo rate=%q want 0.5", got) } if got := normalizeModelRate("x0.05 credits"); got != "0.05" { t.Fatalf("normalizeModelRate=%q want 0.05", got) } c.storeModelRates("cn", []ModelInfo{{ID: "base", Credits: "x0.79"}}) if got := c.ModelRate("cn", "base"); got != "0.79" { t.Fatalf("refreshed base rate=%q want 0.79", got) } if got := c.ModelRate("cn", "promo"); got != "" { t.Fatalf("stale promo rate=%q want empty after full refresh", got) } } func TestFetchModelsOverlaysV3ConfigCapabilities(t *testing.T) { // CLI 目录给 flash 精简字段(128K / 固定 high);IDE /v3/config 给完整能力。 var sawIDE bool c := testClient(func(r *http.Request) (*http.Response, error) { switch { case strings.HasSuffix(r.URL.Path, "/console/enterprises/personal/models"): return jsonResp(200, `{"code":0,"data":{"models":[ {"id":"deepseek-v4.1-flash","name":"Deepseek-V4.1-Flash","maxInputTokens":1000000,"maxOutputTokens":128000,"credits":"x0.03 credits","supportsReasoning":true,"onlyReasoning":true,"reasoning":{"effort":"high","summary":"auto"}} ],"agents":[{"name":"cli","models":["deepseek-v4.1-flash"]}]}}`), nil case strings.HasSuffix(r.URL.Path, "/v3/config"): sawIDE = true if r.Header.Get("User-Agent") != codeBuddyIDEUA { t.Errorf("v3/config UA=%q want %s", r.Header.Get("User-Agent"), codeBuddyIDEUA) } if r.Header.Get("X-Product") != "SaaS" { t.Errorf("X-Product=%q want SaaS", r.Header.Get("X-Product")) } if r.Header.Get("X-User-Id") != "u1" { t.Errorf("X-User-Id=%q want u1", r.Header.Get("X-User-Id")) } return jsonResp(200, `{"code":0,"data":{"models":[ {"id":"deepseek-v4.1-flash","name":"Deepseek-V4.1-Flash","maxInputTokens":1000000,"maxOutputTokens":393216,"credits":"x0.03","supportsReasoning":true,"onlyReasoning":true,"reasoning":{"canDisableThinking":true,"defaultEffort":"high","summary":"auto","supportedEfforts":["low","high","max"]}} ]}}`), nil default: t.Errorf("unexpected path %s", r.URL.Path) return jsonResp(404, `{}`), nil } }) a := &auth.Auth{AccessToken: "at", UID: "u1", Domain: "copilot.tencent.com"} infos, err := c.FetchModels(a) if err != nil { t.Fatalf("fetch models: %v", err) } if !sawIDE { t.Fatal("expected /v3/config request") } if len(infos) != 1 { t.Fatalf("infos=%+v", infos) } mi := infos[0] if mi.MaxTokens != 393216 { t.Errorf("MaxTokens=%d want 393216", mi.MaxTokens) } if mi.ContextWindow != 1000000 { t.Errorf("ContextWindow=%d want 1000000", mi.ContextWindow) } if !mi.CanDisableThinking || !mi.SupportsReasoning { t.Errorf("flags canDisable=%v supportsReasoning=%v", mi.CanDisableThinking, mi.SupportsReasoning) } if mi.DefaultEffort != "high" { t.Errorf("DefaultEffort=%q want high", mi.DefaultEffort) } if got := strings.Join(mi.Efforts, ","); got != "low,high,max" { t.Errorf("Efforts=%v want low,high,max", mi.Efforts) } }