gcli2api / internal /upstream /client_test.go
a3216's picture
chore: 同步到上游 1.12.0-panel + 凭证同步/独立启动器/保活
6d60378 verified
Raw History Blame Contribute Delete
27.7 kB
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, `<html><body>403 Forbidden</body></html>`, ErrWafBlock},
{403, `Forbidden`, ErrWafBlock},
{403, `{"message":"blocked by waf"}`, ErrWafBlock},
{403, `<head><script>...</script></head><body>blocked</body>`, 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)
}
}