// Package upstream 封装对 CodeBuddy 上游(chat / billing / auth)的全部 HTTP 调用, // 以及错误分类(驱动 pool 冷却状态机)。 package upstream import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log" "net/http" "net/url" "regexp" "sort" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/linguo2625469/workbuddy2api-panel/internal/auth" "github.com/linguo2625469/workbuddy2api-panel/internal/logfmt" ) // ErrKind 错误分类,pool 据此决定冷却时长。 type ErrKind int const ( ErrNone ErrKind = iota // 成功 ErrHardCredit // 余额不足(402 或 body 关键词)→ 长冷却 ErrSoftRate // 429 软限流 → 短冷却 ErrSessionDead // 401 + 12153 offline session 失效 → 禁用 ErrNotFound // 404 上游偶发 → 短冷却,不累计错误计数(防雪崩) ErrServer // 5xx 上游故障 ErrContentBlocked // 内容策略拦截(400 + 审核文案)→ 不罚账号,走降级重试 ErrBadParams // 请求体解析失败(400 + Unmarshal chat params failed / 11101)→ 请求级错误:不罚号、不轮转,末端 400 透传原文 ErrAccountFault // 账号级授权/配额故障(11140 request illegal / 14017 trial not activated)→ 冷却轮换,不无限重试 ErrModelBlocked // 11102「该后端无此模型」→ (账号,模型) 负缓存避让,切模型/切账号 ErrWafBlock // 403 + 非业务信封体(APISIX WAF 拦截页/空体)→ 账号软冷却 + 抖动退避 ErrPromptTooLong // 11115「prompt is too long」→ 请求级错误(上下文超限是请求的问题非账号的问题):不罚号、不轮转,末端透传原文 ErrImageInvalid // 图片请求格式/数据无效 → 请求级错误:不罚号、不轮转,末端透传原文 ErrClient // 其他 4xx / 业务错误 ) func (k ErrKind) String() string { switch k { case ErrHardCredit: return "hard_credit" case ErrSoftRate: return "soft_rate" case ErrSessionDead: return "session_dead" case ErrNotFound: return "not_found" case ErrServer: return "server" case ErrContentBlocked: return "content_blocked" case ErrBadParams: return "bad_params" case ErrModelBlocked: return "model_blocked" case ErrWafBlock: return "waf_block" case ErrPromptTooLong: return "prompt_too_long" case ErrImageInvalid: return "image_invalid" case ErrAccountFault: return "account_fault" case ErrClient: return "client" default: return "none" } } // Error 带分类的上游错误。 type Error struct { Kind ErrKind Status int Msg string // RetryAfter 上游明示的等待时长(Retry-After 秒 / retry-after-ms / // x-ratelimit-reset 头解析,见 ParseRetryAfter)。零值 = 上游未明示, // 冷却时长回落调用方计算值。挂载点选在 Error 信封:Kind 决定「罚不罚」, // RetryAfter 决定「罚多久」,同为上游响应的一等公民。 RetryAfter time.Duration } func (e *Error) Error() string { return fmt.Sprintf("upstream %s (http %d): %s", e.Kind, e.Status, e.Msg) } // hardMarkers 余额不足关键词(小写比较 + 中文原文比较双通道)。 var hardMarkers = []string{ "insufficient credit", "no credit", "credit exhausted", "credits exhausted", "out of credit", "quota exceeded", "quota exhaust", "payment required", "credit not enough", "not enough credit", "积分不足", "额度不足", "余额不足", "积分用完", "额度用尽", "没有积分", } // softRateMarkers 限流/节流关键词(小写比较 + 中文原文比较双通道)。 // 上游在状态码非 429 时也会返回限流语义(如 200 + code 11140 // "The model provider is rate-limiting requests."、400 + "rate limit"), // 此类响应若不识别,账号既不被冷却也不喂熔断,下次请求仍会被选中(issue #28)。 // // 词表按子串匹配,宁缺毋滥:只收录明确指向「请求速率/模型用量被节流」的措辞。 // 连字符形式(rate-limiting / rate-limited)需单列——Contains 不跨 '-'。 // "too many" 会命中 "too many tokens" 这类客户端参数错误,代价是该号被软冷却 // 一个 SoftCooldown(默认 60s)后自愈,远小于漏判限流导致反复选中同一号的代价。 var softRateMarkers = []string{ "rate limit", // rate limit / rate limits / rate limiting "rate-limiting", "rate-limited", "too many requests", "too many", "usage limit", // usage limit reached / model usage limit exceeded(用量节流,非计费余额) "请求过于频繁", "限流", } var sessionDeadMarkers = []string{"Offline user session not found", "12153"} // accountFaultMarkers 账号级授权/配额故障关键词(大小写不敏感子串匹配)。 // // 定位:这类错误是**账号本身状态**决定的本机故障,不是请求格式、不是临时限流、 // 也不是内容误报——继续重试只会反复刷上游风控/配额检查,必须把该账号冷却轮换。 // - "request illegal"(code 11140)→ 上游 auth/auth_forbidden,账号级授权风控, // 需重新 OAuth 登录才能恢复,短冷却只能阻止继续送死。 // - code 14017("trial not activated" / "The trial version is not yet activated")→ // 上游 quota/quota_not_activated,register 未完成的试用未激活账号,同样账号级。 // // 注意 11140 **不能**按 code 判定:该 code 也承载模型级限流文案("The model provider // is rate-limiting requests."),那种场景必须保持 ErrSoftRate(下方 softRateMarkers // 后判定)。故此处只收 msg 关键词 "request illegal"(auth_forbidden 的真实文案)。 // 14017 文案唯一(无软限流歧义),可安全收录。 var accountFaultMarkers = []string{ "request illegal", "trial not activated", "trial version is not yet activated", } // contentBlockedMarkers 内容策略拦截关键词(大小写不敏感子串匹配)。 // // 定位:上游按逐字精确指纹审核,system 来源的模板句(如 Claude Code/Codex // 注入指令)触发 HTTP 400 + 以下文案。这是「误报」(合法流量被审核误杀), // 非账号问题——该账号余额健康、未限流、session 未死,故 ErrContentBlocked // 在 applyErrorPolicy 中不罚账号(无冷却/熔断/NoteError),改由网关降级重试。 var contentBlockedMarkers = []string{ "blocked by security policy", "unapproved channel", "illegal api invocation", } // badParamsMarkers 请求体解析失败关键词(issue #41 连带):HTTP 400 + 上游 // "Unmarshal chat params failed..."(code 11101)。这是"发给上游的 body 有问题", // 与账号健康无关——不罚号,但仍轮转(commit B)。 var badParamsMarkerMsg = "Unmarshal chat params failed" // invalidImageMarkers 图片请求格式/数据无效(HTTP 400)的**文案**形态。这类错误由 // 请求内容决定,不是账号问题:换账号不会改变同一 body 的解析结果。上游常见形态包括 // `Parse message failed: invalid image_url content`、invalid_image_data、 // `replace the image`。 // // 业务码 11135 不放在这里:code 判定必须容忍 JSON 空白(`"code": 11135`), // 字面量 marker 只能覆盖紧凑形态,故统一走 codeMarker(见 Classify 的 400 分支, // 与 hint.go 的 isInvalidImageData 同口径;上游 5d5223d 的 Copilot review 修复)。 var invalidImageMarkers = []string{ "invalid image_url content", "invalid_image_data", "replace the image", } // 定位:上下文超限是**请求的问题不是账号的问题**——同一个 body 换任何账号发都会 // 超限,与 WAF fail-fast 同哲学(确定与账号无关的错误不罚号不轮转,白白浪费健康号 // 的请求配额)。marker 双通道: // - `"code":11115`:业务信封 code 字段(JSON 空格容差;`"code":"11115"` 字符串 // 形态也命中); // - "prompt is too long":msg 文案(大小写不敏感)。 // // 只在 400/404/413 请求级状态码上判(429+11115 概率极低且属限流语义优先, // 5xx 属服务端故障优先)。误判代价(好 body 被归 prompt_too_long):不罚号 + // 不轮转 + 透传原文,客户端看到上游原文可自行排查,代价可控。 var promptTooLongMarkers = []string{ `"code":11115`, `"code": 11115`, `"code":"11115"`, "prompt is too long", } // isPromptTooLongStatus 11115 只在请求级 4xx 上判(见 promptTooLongMarkers 注释)。 func isPromptTooLongStatus(status int) bool { return status == http.StatusBadRequest || status == http.StatusNotFound || status == http.StatusRequestEntityTooLarge } // alreadyCheckinMarkers "今天已签到"关键词(上游对重复签到返回 code!=0, // 实测 code=10001/14001 "今天已签到"/"今日已签到")。只对 *Error.Msg 做包含匹配, // 网络层/解析层错误不在此识别(见 IsAlreadyCheckin)。 var alreadyCheckinMarkers = []string{"已签到", "already"} var badParamsMarkerCode = `"code":11101` // softRateResetLoc 上游 429 6004 文案中的重置时间固定按 UTC+8 解释(上游文案如此, // 与容器时区无关)。 var softRateResetLoc = time.FixedZone("UTC+8", 8*60*60) // SoftRateResetLoc 暴露重置时间的固定时区(供测试构造/断言同一时区口径)。 func SoftRateResetLoc() *time.Location { return softRateResetLoc } // modelRateLimitCode 明确指向「模型级 429 限流」的业务 code。 // 上游用它表达"该模型的使用量超限"(code 6004,msg 带「将在 … 重置」), // 而不是账号整体被限流——账号健康,只是这个模型此刻被限(issue #31)。 const modelRateLimitCode = "6004" // softRateResetPatternCN/EN 匹配重置文案(CN「将在 … 重置」/ global 域英文 // "reset at <固定格式时间>"),捕获中间的时间串。 const softRateResetPatternCN = `将在 (.+?) 重置` const softRateResetPatternEN = `(?i)reset at (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})` // 限流判定正则预编译为包级 var:IsModelRateLimit / ParseRateReset 在每次错误 // 分类、每个限流 body 上调用,函数体内 MustCompile 是纯浪费;错误风暴(429 // 轰炸)时尤甚。模式串均为纯常量。regexp 并发安全(匹配只读),无需额外锁。 var ( reModelRateLimit = regexp.MustCompile(`"code"\s*:\s*"?` + modelRateLimitCode + `"?`) reSoftRateResetCN = regexp.MustCompile(softRateResetPatternCN) reSoftRateResetEN = regexp.MustCompile(softRateResetPatternEN) ) // softRateTimeLayout 上游重置时间的格式(无时区后缀;时区固定 UTC+8)。 const softRateTimeLayout = "2006-01-02 15:04:05" // IsModelRateLimit 报告 429 body 是否明确指向模型级限流(业务 code 6004)。 // 用于区分"账号级软限流"(按账号冷却)与"模型级用量限流"(切模型即可用)。 func IsModelRateLimit(body string) bool { // `"code":6004` / `"code": 6004` / `"code":"6004"` 均可命中(JSON 空格容差)。 return reModelRateLimit.MatchString(body) } // modelBlockCode 明确指向「该后端无此模型」的业务 code。 const modelBlockCode = "11102" // modelBlockMsgMarker 11102 答复的确定性文案(官方 error message 固定短语)。 // 只收这个窄短语,不收 "model ... not found" 宽正则——后者会误伤其他业务的 // not found 措辞。 const modelBlockMsgMarker = "service info not found" // ModelBlockReason 11102 负缓存条目在 pool.modelCooldowns 里的 reason 前缀。 // handler 写 BlockModelBackoff;pool.BlockModelClear 按 "11102" 前缀识别条目 // (与 6004 条目的 "6004 model rate limit" reason 互不干扰)。 const ModelBlockReason = "11102 model not available" // IsModelBlocked 报告 body 是否是「该后端无此模型」(11102) 的确定性答复。 // // 只比对 code/msg 等独立字段,绝不做整段文本子串匹配:错误体还带 requestId 等字段, // 拿整段文本匹配会把 "11102" 撞在 ID 上、误避让一个本来能用的模型。判定 = // code 字段精确等于 "11102",或 msg/message 字段命中窄短语 "service info not // found"(两者任一命中即真)。只看 400/404:429 带 11102 属限流语义。 // 字段遍历覆盖顶层与 error 子对象两层。 func IsModelBlocked(status int, body string) bool { if (status != http.StatusBadRequest && status != http.StatusNotFound) || body == "" { return false } // 轻量预检:body 既无 "11102" 又无 marker 时直接短路(大多数 4xx 零分配返回)。 if !strings.Contains(body, modelBlockCode) && !strings.Contains(strings.ToLower(body), modelBlockMsgMarker) { return false } var root map[string]any if err := json.Unmarshal([]byte(body), &root); err != nil { return false } nodes := []map[string]any{root} if inner, ok := root["error"].(map[string]any); ok { nodes = append(nodes, inner) } code, msg := "", "" for _, node := range nodes { for _, key := range []string{"code", "errCode", "error_code"} { if v, ok := node[key]; ok && v != nil && code == "" { code = strings.TrimSpace(fmt.Sprint(v)) } } for _, key := range []string{"msg", "message"} { if v, ok := node[key].(string); ok && v != "" && msg == "" { msg = strings.TrimSpace(v) } } } if code == modelBlockCode { return true } return strings.Contains(strings.ToLower(msg), modelBlockMsgMarker) } // hasBusinessCode reports whether a JSON error envelope contains an exact // business code in a field named "code". Upstream envelopes vary between // top-level and nested error/data objects, so walk the decoded structure. func hasBusinessCode(body, want string) bool { var root any if err := json.Unmarshal([]byte(body), &root); err != nil { return false } var walk func(any) bool walk = func(value any) bool { switch node := value.(type) { case map[string]any: if code, ok := node["code"]; ok && strings.TrimSpace(fmt.Sprint(code)) == want { return true } for _, child := range node { if walk(child) { return true } } case []any: for _, child := range node { if walk(child) { return true } } } return false } return walk(root) } // hasBusinessEnvelope 报告错误 body 是否携带上游业务信封形态(JSON 且含 // `"code":` 或 `"msg":` 字段)。WAF 403 判定(IsWafBlocked)用「无业务信封」 // 区分 APISIX WAF 拦截页(HTML/空体/纯文本)与上游业务层 403(带 code/msg // 信封,正常走既有分类)。JSON 解析不做:信封存在性只需字段名命中—— // 畸形 JSON 但含 `"msg":` 字样仍按业务响应保守处理(宁漏判 WAF 也不误罚 // 业务 403,后者有各自的权威分类)。 func hasBusinessEnvelope(body string) bool { return strings.Contains(body, `"code":`) || strings.Contains(body, `"msg":`) } // IsWafBlocked 报告 403 响应是否为 WAF 拦截形态:HTTP 403 且 body 无业务信封 // (无 `"code":`/`"msg":` JSON 字段——HTML 拦截页、空体、纯文本均命中)。 // 带业务信封的 403(11140 request illegal / 11128 等)仍走既有分类链。 // 403 含 accountFault 文案的维持现状(ErrAccountFault),由 Classify 的规则序保证。 func IsWafBlocked(status int, body string) bool { return status == http.StatusForbidden && !hasBusinessEnvelope(body) } // retryAfterHeaderCandidates 冷却时长优先解析的响应头候选序列: // retry-after(秒,RFC 7231)/ retry-after-ms(毫秒)/ x-ratelimit-reset // (epoch 秒或毫秒,取 now+ 剩余量)。大小写不敏感(http.Header.Get 已归一)。 var retryAfterHeaderCandidates = []string{"Retry-After", "Retry-After-Ms", "X-Ratelimit-Reset"} // retryAfterSanity 解析结果的上限(超过视为上游异常值丢弃,回落本地计算), // 与 pool 的 softRateMax 默认 2h 同量级。 const retryAfterSanity = 2 * time.Hour // ParseRetryAfter 从限流/拦截响应头解析上游明示的等待时长: // 依次尝试 Retry-After(整数秒)→ retry-after-ms(整数毫秒)→ // x-ratelimit-reset(纯数字按 epoch 秒/毫秒推断;HTTP-Date 形态不支持—— // 上游族实践发的是数字)。任一头缺失/非法/非正/超上限则尝试下一头; // 全部不可用返回 false(调用方回落既有计算值,绝不臆造等待时长)。 func ParseRetryAfter(h http.Header) (time.Duration, bool) { for _, name := range retryAfterHeaderCandidates { v := strings.TrimSpace(h.Get(name)) if v == "" { continue } if !isAllDigits(v) { continue // 非纯数字(如 HTTP-Date)不解析,宁缺毋滥 } n, ok := parseRetryNumber(v, name) if !ok { continue } if n <= 0 || n > retryAfterSanity { continue // 非正/异常大:丢弃(回落本地计算) } return n, true } return 0, false } // isAllDigits 报告 s 是否为纯数字(前置快筛,免 strconv 之后再判语义)。 func isAllDigits(s string) bool { if s == "" { return false } for _, r := range s { if r < '0' || r > '9' { return false } } return true } // parseRetryNumber 按头名口径把纯数字串折算成时长。x-ratelimit-reset 是 // epoch 时刻而非时长:秒口径(10 位)与毫秒口径(13 位)都按「now+ 该时刻 // 的剩余量」折算,已在过去则不可用。位数不足(8 位以下)无法判定 epoch // 语义的丢弃(宁缺毋滥:短串多半是序号之类的误用头)。 func parseRetryNumber(v, headerName string) (time.Duration, bool) { // 上限 16 位防 int64 溢出(超过 epoch 毫秒的现实量级必非法)。 if len(v) > 16 { return 0, false } var n int64 for _, r := range v { n = n*10 + int64(r-'0') } switch headerName { case "Retry-After": // 先做上限校验再乘 time.Second:16 位数字乘 1e9 会溢出 int64 回绕成 // 小正数,进而通过调用方的 retryAfterSanity 校验被当作合法等待时长。 if n > int64(retryAfterSanity/time.Second) { return 0, false } return time.Duration(n) * time.Second, true case "Retry-After-Ms": if n > int64(retryAfterSanity/time.Millisecond) { return 0, false } return time.Duration(n) * time.Millisecond, true default: // X-Ratelimit-Reset:epoch → 剩余量 sec := n if len(v) >= 12 { // 毫秒口径(13 位);11 位边界按秒(误判代价是多算 1000 倍) sec = n / 1000 } remain := time.Until(time.Unix(sec, 0)) return remain, true } } // ParseRateReset 从任何限流响应 body 里统一解析「将在 … 重置」时间(上游 UTC+8 文案)。 // 成功返回解析出的**墙钟时刻**(按 UTC+8 解释),失败返回零值 + false。 // // 是否走模型级豁免、时日对齐到 until 还是 modelCooldowns,由冷却决策侧(pool)按 // IsModelRateLimit 判定,本函数只负责「把上游明说的恢复时刻抽出来」。没有时间文案 // 的限流也照常由调用方退回有界退避(绝不臆造时间)。 func ParseRateReset(body string) (time.Time, bool) { // CN 文案优先;global 域 429 body 是英文形态("will reset at YYYY-MM-DD HH:MM:SS // UTC+8"),此前只认中文 → global 限流解析不到恢复时刻,退回有界退避基数反复 // 翻倍(修「global 域冷却指数翻倍」)。英文正则锚定固定格式时间,自然语言 // ("reset at the end of the day")不匹配。 m := reSoftRateResetCN.FindStringSubmatch(body) if len(m) < 2 { m = reSoftRateResetEN.FindStringSubmatch(body) } if len(m) < 2 { return time.Time{}, false } ts := strings.TrimSpace(m[1]) ts = strings.TrimSuffix(ts, " UTC+8") // 去掉后缀,固定按 softRateResetLoc 解释 t, err := time.ParseInLocation(softRateTimeLayout, ts, softRateResetLoc) if err != nil { return time.Time{}, false } return t, true } // Classify 按 HTTP 状态码 + body 判定错误类别。 // // 判定顺序自「严」到「宽」,每层的先后都有语义依据: // 0. 11102(IsModelBlocked)——「该后端无此模型」确定性答复,语义最具体,最先判 // (只认 400/404,429+11102 属限流语义走第 4 层)。 // 1. 402 —— 真正的计费余额耗尽状态码,最严、最不可自愈,最先判。 // 2. sessionDeadMarkers —— 需要人工重登的终态。若 401 body 同时含 "12153" 与 // "rate limit"(如网关错误页混排),归 session_dead:短冷却救不活失效 session, // 误判为限流会让该死号留在池中反复被选中;且此层 marker 是精确词(12153 等), // 比限流层的大范围子串更具体,具体优先于宽泛。 // 3. accountFaultMarkers —— 账号级授权/配额故障(11140 request illegal auth 风控、 // 14017 trial not activated register 未完成)。必须先于 status==429 判定: // 14017 常带 429 状态码,若落到 status==429 会误归 soft_rate("限流"语义不符: // 限流可指数退避等自愈,账号级故障等不来)。11140 的 model 级限流变体 // (rate-limiting 文案)因 marker 不含该文案而天然落到 softRateMarkers 层, // 不受影响。 // 4. 429 + code 14018 —— 明确的账号积分耗尽,归 ErrHardCredit(issue #175)。 // 只认结构化业务码,不靠可能跨计费/限流两界的文案猜测。 // 5. status==429 —— 限流状态码兜底(先于 hardMarkers):429 body 高频携带 // "quota exceeded"/"额度不足" 等跨计费/限流两界的措辞,hardMarkers 先判会把 // 限流误归 ErrHardCredit 硬冷却到次日 04:00,白扔号约 12h。状态码是比关键词 // 更权威的信号;真正的余额耗尽由 402(第 1 层)或 14018(第 4 层)捕获, // 非 429 状态码的 quota 措辞仍走下方 hardMarkers(第 6 层)。 // 6. hardMarkers —— 非 429 响应携带计费关键词(200 业务信封 / 403 信封等)。 // 7. softRateMarkers —— 非 429 状态码携带限流文案(issue #28 修复点)。 // 位于此处可覆盖 200/400/403/5xx 各状态码。 // 8. 11115 —— 「prompt is too long」请求级语义:判在 404/5xx 与通用 4xx 兜底 // 之前(404 上打 11115 若落 ErrNotFound 会误冷却账号——上下文超限与账号无关)。 // 9. 404 / 5xx —— 与限流无关的常规分类。 // 10. IsWafBlocked —— 403 且无业务信封(HTML 拦截页/空体/纯文本):APISIX WAF // 拦截形态。判在通用 4xx 兜底**之前**:此前该形态落 ErrClient → 只换号不罚 → // 连环 403。带业务信封的 403 已被上方各层捕获,走不到本层。 // 11. 内容策略/参数错误/其他 4xx —— 通用兜底。 func Classify(status int, body string) ErrKind { // 11102「该后端无此模型」须最先判:它是「模型在后端不存在」的确定性答复,语义比 // 计费/限流都更具体——若不先判,msg 里的 "service info not found" 会被更宽的 // 4xx 兜底归为 ErrClient(只换号不避让),该坏号会留在池内反复被选中。 // 只认 400/404(见 IsModelBlocked),429+11102 落下方 status==429 层走限流语义。 if IsModelBlocked(status, body) { return ErrModelBlocked } // 402:真正的计费余额耗尽状态码,最严、最不可自愈,最先判。 if status == http.StatusPaymentRequired { return ErrHardCredit } lower := strings.ToLower(body) // sessionDead / accountFault 先于 status==429:账号级终态等不来自愈,限流状态码 // 不得掩盖它们(429+14017 必须 accountFault,401+12153 混排 "rate limit" 必须 // sessionDead——此层 marker 是精确词,比限流层的大范围子串更具体,具体优先于宽泛)。 for _, m := range sessionDeadMarkers { if strings.Contains(body, m) { return ErrSessionDead } } for _, m := range accountFaultMarkers { if strings.Contains(lower, strings.ToLower(m)) || strings.Contains(body, m) { return ErrAccountFault } } // 14018 是明确的账号积分耗尽业务码。它必须先于通用 429 兜底,否则会被误判为 // 可自愈的软限流并在全池冷却时反复兜底选中(issue #175)。仅按结构化 code // 判定;无该 code 的 "credits exhausted" 文案仍保持普通 429 的软限流语义。 if status == http.StatusTooManyRequests && hasBusinessCode(body, "14018") { return ErrHardCredit } // status==429 先于 hardMarkers:限流响应 body 高频携带 "quota exceeded"/ // "额度不足" 等跨计费/限流两界的措辞,hardMarkers 先判会把限流误归 // ErrHardCredit 硬冷却到次日 04:00,白扔号约 12h。状态码是比关键词更权威的 // 信号:上游既然给了 429,就按限流语义处理(宁可短冷却自愈,不可长冷却弃号); // 真正的余额耗尽由 402(上层)或 14018(上层)捕获,非 429 状态码的 quota // 措辞仍走下方 hardMarkers(历史语义不变)。 if status == http.StatusTooManyRequests { return ErrSoftRate } for _, m := range hardMarkers { if strings.Contains(lower, strings.ToLower(m)) || strings.Contains(body, m) { return ErrHardCredit } } for _, m := range softRateMarkers { if strings.Contains(lower, strings.ToLower(m)) || strings.Contains(body, m) { return ErrSoftRate } } // 11115「prompt is too long」:判在 404/5xx/WAF/内容策略/参数错误/通用 4xx // 之前——请求级语义最具体(上下文超限),须先于宽泛的状态码兜底(404 兜底会 // 误归 ErrNotFound 只冷却不透传;ErrClient 只换号,浪费健康号配额)。 if isPromptTooLongStatus(status) { for _, m := range promptTooLongMarkers { if strings.Contains(body, m) || (m != strings.ToLower(m) && strings.Contains(lower, strings.ToLower(m))) { return ErrPromptTooLong } } } if status == http.StatusNotFound { return ErrNotFound } if status >= 500 { return ErrServer } // WAF 403(无业务信封的拦截形态):判在内容策略/参数错误/通用 4xx 之前—— // 这些层只认带文案的 body,WAF 空体/HTML 永远不会命中它们的 marker, // 但落 ErrClient 兜底的代价是「只换号不罚」(连环 403 根因),必须在兜底前分流。 // 带信封的 403 在上方各层已有权威分类,不受影响。 if IsWafBlocked(status, body) { return ErrWafBlock } // 图片格式/数据错误是确定性的请求级错误:同 body 换账号结果不变,直接 // fail-fast,避免把健康账号轮转一遍后仍把最终 503 返回给客户端。 // 11135 业务码走 codeMarker(JSON 空白容差),文案走 invalidImageMarkers。 if status == http.StatusBadRequest && codeMarker(lower, "11135") { return ErrImageInvalid } if status == http.StatusBadRequest { for _, m := range invalidImageMarkers { if strings.Contains(lower, m) { return ErrImageInvalid } } } // 内容策略拦截(HTTP 400 + 审核文案):判在通用 ErrClient 之前。 // 这是误报信号,不罚账号,由网关降级重试处理(见 handler.applyErrorPolicy)。 if status >= 400 { for _, m := range contentBlockedMarkers { if strings.Contains(lower, m) { return ErrContentBlocked } } // 请求体解析失败(HTTP 400 + Unmarshal chat params failed / code 11101): // 这是"发给上游的 body 有问题"。网关侧截断已由 413 消灭(issue #41 commit A), // 剩余来源是客户端 JSON 本身畸形——换了账号照样 400,不该罚号(白白冷却好号)。 // 归 ErrBadParams:不冷却/不熔断/不计错,且**不轮转**——11101 发生在上游解析 // 请求体阶段,还没走到模型路由,所以"不同账号可能有不同模型权限"其实是 // 11102(ErrModelBlocked)的理由,那里已有 (账号,模型) 负缓存避让。 if strings.Contains(body, badParamsMarkerMsg) || strings.Contains(body, badParamsMarkerCode) { return ErrBadParams } return ErrClient } // HTTP 200 但业务 code 非 0 且含余额关键词的情况已被上面 hardMarkers 捕获。 return ErrNone } // apiEnvelope 上游统一信封。 type apiEnvelope struct { Code int `json:"code"` Msg string `json:"msg"` Data json.RawMessage `json:"data"` } // Client 上游 HTTP 客户端。Base 字段可覆盖便于测试。 type Client struct { HTTP *http.Client // ChatHTTP 聊天 SSE 专用 client:无总时长上限(Timeout=0),首字节由 // Transport.ResponseHeaderTimeout 约束,流中空闲由 IdleTimeout 约束。 // 与 HTTP 共享同一个 *http.Transport 实例,连接池不重复。 ChatHTTP *http.Client // HeaderTimeout 聊天 SSE 首字节前(响应头)超时;<=0 表示未设置(回落 HTTP.Timeout)。 HeaderTimeout time.Duration // IdleTimeout 聊天 SSE 流中空闲超时;<=0 表示禁用空闲监控。 IdleTimeout time.Duration // effortsMu/efforts 缓存各模型 supportedEfforts(FetchModels 刷新),供请求体 effort 降级。 // 按 realm 分层桶(cn/global):同模型名跨域探测的 effort 集合可能不同, // 混桶会互相污染(C-2)。 effortsMu sync.RWMutex efforts map[string]map[string][]string // defaultEfforts 缓存各模型 reasoning.defaultEffort(FetchModels 刷新),供 // thinking.go 补档:缺显式 effort 时优先用模型声明默认档,空串回退硬编码 high。 // 与 efforts 同 realm 分层桶(同 C-2 隔离原则),共用 effortsMu。 defaultEfforts map[string]map[string]string // modelRates 缓存各模型当前生效积分倍率(规范化数值,如 "0.5")。 // 与 efforts 共用 realm 分层和锁;每次成功刷新模型目录时整体替换对应域。 modelRates map[string]map[string]string // globalModels 缓存 global 模型名目录探测结果(成功 ∩ 静态 overlay; // 1h TTL + 5min 负缓存),见 global_models.go。按实例持有,测试新建 Client 即隔离。 globalModels fetchGlobalModelsCache // SanitizeFingerprints 出站请求体黑名单指纹脱敏开关(默认 true;false 完全还原)。 // 面板保存配置热改 + chat 热路径并发读写,用 atomic.Bool 消除数据竞争。 SanitizeFingerprints atomic.Bool // UserAgent 出站 User-Agent 显式覆盖(非空时全路径生效,优先于默认三段式)。 // 空 = 默认官方形态:chat/refresh/FetchModels 走 // `WorkBuddy/ WorkBuddy/ CLI/`;billing 走 `WorkBuddy/` // (仅当 client_name 非空)。 UserAgent string // ClientVersion WorkBuddy 客户端版本段(出站 UA 的 `WorkBuddy/` + X-IDE-Version)。 // 空 = 内置默认(对齐官方 5.5.4 分发包)。 ClientVersion string // CliVersion 出站 UA 中 `CLI/` 段版本。空 = 内置默认(官方内置 CLI 2.137.1)。 CliVersion string // ClientName 用量归属头取值(X-Product / X-IDE-Name / X-IDE-Type / X-IDE-Version)。 // 空 = 旧行为:X-Product="SaaS",不设 X-IDE-*(向后兼容,不突变归因)。 ClientName string // PassthroughIP 是否透传客户端 IP 给上游(X-Forwarded-For/X-Real-IP 首段)。 // 缺省 false(反代安全边界);handler 在 chat 路径按请求把 clientIP 传入 ChatStream。 PassthroughIP bool // DeviceToken 设备风控 Token(X-Device-Token 头)全局兜底来源:config upstream.device_token。 // 解析优先级:auth.Auth.DeviceToken > DeviceToken(config)> DeviceTokenFile(文件)。 DeviceToken string // DeviceTokenFile 设备 token 文件路径兜底(宿主落盘的桌面端 token,5 分钟读取缓存)。 DeviceTokenFile string ChatBaseCN string BillingBaseCN string // WebBaseCN 官网(workbuddy.cn)域:部分「任务领奖」类接口只在此域提供 // (Web 成长中心用;CLI 域 copilot.tencent.com 的同名路径返回 400)。 WebBaseCN string // ChatBaseGlobal / BillingBaseGlobal 国际版(global realm)上游 base。 // 空 = 缺省默认 https://www.workbuddy.ai(D5)。 ChatBaseGlobal string BillingBaseGlobal string // GlobalEnabled 是否启用 global realm 路由(config global.enabled,缺省 true)。 // false 时即便用户 auth 写了 realm=global 也**不**路由到 global base—— // chatBase/billingBase 返回 CN base,路径也走 CN(双保险,与 auth.Realm() 的开关闸呼应)。 GlobalEnabled bool } // New 生产默认值。Transport 由 newTransport() 集中构造(连接层加固:真正禁 h2 / // TLS 握手超时 / 短 keepalive 探测 / 失败清池,参数见 transport.go——吸收上游 // kongjianguan 4 连击实测经验)。 func New() *Client { tr := newTransport() c := &Client{ HTTP: &http.Client{Timeout: 120 * time.Second, Transport: tr}, ChatHTTP: &http.Client{Timeout: 0, Transport: tr}, // 无总时长;首字节由 ResponseHeaderTimeout 管 ChatBaseCN: "https://copilot.tencent.com", BillingBaseCN: "https://www.codebuddy.cn", WebBaseCN: "https://www.workbuddy.cn", // GlobalEnabled 缺省 true(与 config global.enabled 缺省 true 一致;纯 CN 部署行为不变: // CN 账号恒判 cn,global base 只在 realm=global 的账号上被使用)。 GlobalEnabled: true, } c.SanitizeFingerprints.Store(true) return c } // chatHTTP 返回聊天专用 client;未设置(如测试只注入 HTTP)时回落 HTTP。 func (c *Client) chatHTTP() *http.Client { if c.ChatHTTP != nil { return c.ChatHTTP } return c.HTTP } // defaultGlobalBase 缺省 global base(D5:config 未覆盖时默认 workbuddy.ai)。 const defaultGlobalBase = "https://www.workbuddy.ai" // globalChatBase 生效的 global chat base:Client.ChatBaseGlobal 非空取之,否则默认。 func (c *Client) globalChatBase() string { if c.ChatBaseGlobal != "" { return c.ChatBaseGlobal } return defaultGlobalBase } // globalBillingBase 生效的 global billing base:Client.BillingBaseGlobal 非空取之,否则默认。 func (c *Client) globalBillingBase() string { if c.BillingBaseGlobal != "" { return c.BillingBaseGlobal } return defaultGlobalBase } // globalOn 报告账号是否路由到 global 上游:GlobalEnabled 开且账号 Realm()==global。 // 双保险:config 开关是第一道闸(上游侧),auth.Realm() 的开关闸是第二道(账号侧)。 func (c *Client) globalOn(a *auth.Auth) bool { return c.GlobalEnabled && a != nil && a.Realm() == "global" } // 路径常量:CN 与 global 共用的 chat 出站路径(/v2 单路径)。 const chatCompletionsPath = "/v2/chat/completions" // chatPaths 返回按 realm 的 chat 路径候选序列: // global → [/v2](#119 固定单路径:/console 挂腾讯云 WAF body 内容规则,反引号 // printf/whoami 等命令执行特征确定性 403;/v2 同 base 不挂该规则,实测等价端点。 // 已知取舍:若上游未来关闭 /v2,global chat 整体不可用——届时应重新启用 /console // 路径,此注释即"坏了再说"的锚点);cn → [/v2](单元素,现状)。 func (c *Client) chatPaths(a *auth.Auth) []string { return []string{chatCompletionsPath} } // billing 域端点路径(billingBase + path)。balance/checkin 与 report(report.go)同域, // 统一走 billingJSON 发请求。 const ( billingMeterPath = "/billing/meter/get-user-resource" // global 首选(国际版无 /v2 前缀) dailyCheckinPath = "/billing/meter/daily-checkin" // global 首选 billingMeterPathV2 = "/v2/billing/meter/get-user-resource" // CN 现状 / global fallback dailyCheckinPathV2 = "/v2/billing/meter/daily-checkin" ) // billingMeterPaths 按 realm 返回 billing/meter 域路径候选序列: // global → [无 /v2, 有 /v2](404 时 fallback);cn → [有 /v2](现状逐字,零回归)。 // 仅作用于 get-user-resource / daily-checkin(/billing/meter/* 族);report /v2/report 不参与, // 其他 billing 端点(growth 等)路径不含 /billing/meter 前缀,走原常量不受影响。 func (c *Client) billingMeterPaths(a *auth.Auth) []string { if c.globalOn(a) { return []string{billingMeterPath, billingMeterPathV2} } return []string{billingMeterPathV2} } // checkinMeterPaths 同上,针对 daily-checkin。 func (c *Client) checkinMeterPaths(a *auth.Auth) []string { if c.globalOn(a) { return []string{dailyCheckinPath, dailyCheckinPathV2} } return []string{dailyCheckinPathV2} } func (c *Client) chatBase(a *auth.Auth) string { if c.globalOn(a) { return c.globalChatBase() } return c.ChatBaseCN } // prepareBody 组装出站请求体(脱敏开关由 Client.SanitizeFingerprints 控制)。 // realm 为账号 Realm()(cn/global),供 efforts 缓存分桶(跨域 effort 集合不互相污染)。 func (c *Client) prepareBody(body []byte, realm, uid, conversationID string) []byte { efforts, defs := c.effortsSnapshot(realm), c.defaultEffortsSnapshot(realm) if realmKey(realm) == "global" { // global 域降级源 = 远端探测桶(权威)∪ 产品静态兜底表(全局 21 名内档位如 // deepseek-v4.1-flash ['high'])。当前探测桶为空时也按静态表降级,不全程透传 //(issue #84:往 WorkBuddy 上游发 low/max 非法,须降级到 high)。 efforts, defs = globalEffortMap(efforts, defs) } body = PrepareBodyOptWithEffortsAndDefault(body, c.SanitizeFingerprints.Load(), efforts, defs) // prompt_cache_key 注入(P0 费用优化,费用降 ~17×):按账号隔离的稳定缓存键, // 让同一客户端对同一账号的连续请求命中上游前缀缓存。 body = InjectPromptCacheKey(body, uid, conversationID) return body } // effortsSnapshot 返回 effort 能力缓存副本;nil 表示未知(透传不降级)。 func (c *Client) effortsSnapshot(realm string) map[string][]string { c.effortsMu.RLock() defer c.effortsMu.RUnlock() bucket, ok := c.efforts[realmKey(realm)] if !ok || len(bucket) == 0 { return nil } cp := make(map[string][]string, len(bucket)) for k, v := range bucket { cp[k] = v } return cp } // defaultEffortsSnapshot 返回指定 realm 的模型 defaultEffort 缓存副本; // 该域无探测或无声明默认档 → nil(thinking.go 回退硬编码 high)。 func (c *Client) defaultEffortsSnapshot(realm string) map[string]string { c.effortsMu.RLock() defer c.effortsMu.RUnlock() bucket, ok := c.defaultEfforts[realmKey(realm)] if !ok || len(bucket) == 0 { return nil } cp := make(map[string]string, len(bucket)) for k, v := range bucket { cp[k] = v } return cp } // realmKey 归一化 efforts 缓存键:cn/global。空 realm 视为 cn(老调用/无前缀模型名)。 func realmKey(realm string) string { if realm == "" { return "cn" } return realm } func (c *Client) billingBase(a *auth.Auth) string { if c.globalOn(a) { return c.globalBillingBase() } return c.BillingBaseCN } // webBase 返回官网域(任务领奖类接口;未注入时回落默认)。 // realm 感知:global 账号切国际站 workbuddy.ai,CN 用 workbuddy.cn。 func (c *Client) webBase(a *auth.Auth) string { if c.globalOn(a) { return defaultGlobalBase } if c.WebBaseCN != "" { return c.WebBaseCN } return "https://www.workbuddy.cn" } // doJSON 发请求并解信封;HTTP 非 2xx 或业务 code != 0 时返回带 body 片段的 *Error。 // body 读失败(连接中断/空闲掐流/截断)返回普通错误(非 *Error)——半截 body 不进 // Classify,不参与账号惩罚(传输层故障不该喂熔断误罚号)。 func (c *Client) doJSON(req *http.Request) (json.RawMessage, error) { resp, err := c.HTTP.Do(req) if err != nil { return nil, err } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, fmt.Errorf("read body: %w", err) } if resp.StatusCode >= 400 { kind := Classify(resp.StatusCode, string(raw)) return nil, &Error{Kind: kind, Status: resp.StatusCode, Msg: truncate(string(raw), 200)} } var env apiEnvelope if err := json.Unmarshal(raw, &env); err != nil { return nil, fmt.Errorf("parse failed: %w (body: %s)", err, truncate(string(raw), 120)) } if env.Code != 0 { kind := Classify(resp.StatusCode, env.Msg) if kind == ErrNone { kind = ErrClient } return nil, &Error{Kind: kind, Status: resp.StatusCode, Msg: fmt.Sprintf("code=%d msg=%s", env.Code, truncate(env.Msg, 160))} } return env.Data, nil } // RefreshToken 刷新 access token;成功时更新 a 的字段(缺省值保留旧值), // 调用方负责 SaveAtomic。全程持 a 锁,防止并发 SaveAtomic 读半更新 token。 // refreshIOTimeout 刷新端点网络 I/O 上限(两段式锁外执行,防上游 hang 长占锁)。 const refreshIOTimeout = 30 * time.Second // refreshTokenExpiresInMax refresh 响应 expiresIn 的量级上限(10 年,纯防御值: // 实测 R-D 响应恒 5184000=60d)。超限视为上游脏数据,不写 ExpiresAt(保留旧值), // 防止 NeedsRefresh 永假导致 token 永不刷新反而真过期失效。 const refreshTokenExpiresInMax = 10 * 365 * 24 * time.Hour // RefreshToken 刷新 access token;成功时更新 a 的字段(缺省值保留旧值), // 调用方负责 SaveAtomic。 // // 并发安全模型(两段式,缩小持锁窗口): // - 锁内仅做「读 refreshToken 快照」与「校验未变后写回新 token」两小段内存操作; // - 网络 I/O(doJSON)在**锁外**执行,带 30s ctx 超时——避免上游 hang 时长时间 // 独占 a.mu,阻塞同账号的 SaveAtomic / 其他刷新(issue:持锁 120s I/O)。 // - 写回前重新校验快照一致性:若锁外期间另一 goroutine 已完成刷新(refreshToken // 已变),本次结果直接采用(新 token 已生效),不再重复写回。 func (c *Client) RefreshToken(a *auth.Auth) error { // 第 1 段(锁内):读快照。 a.Lock() rtSnapshot := a.RefreshToken atBefore := a.AccessToken a.Unlock() if strings.TrimSpace(rtSnapshot) == "" { return fmt.Errorf("no refreshToken") } endpoint := c.chatBase(a) + "/v2/plugin/auth/token/refresh" ctx, cancel := context.WithTimeout(context.Background(), refreshIOTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil) if err != nil { return err } // RefreshHeaders 读取 a 的字段(domain/uid 等)注入请求头——需在锁内取快照值, // 用一个显式逐字段拷贝的临时 auth 构造头(不拷贝 sync.Mutex,避免 vet copies-lock)。 a.Lock() hdrSnapshot := auth.Auth{ AccessToken: a.AccessToken, RefreshToken: rtSnapshot, ExpiresAt: a.ExpiresAt, Domain: a.Domain, UID: a.UID, EnterpriseID: a.EnterpriseID, Nickname: a.Nickname, DeviceToken: a.DeviceToken, } a.Unlock() c.RefreshHeaders(req, &hdrSnapshot) // 网络 I/O(锁外,30s 上限)。 data, err := c.doJSON(req) if err != nil { return err } var tok struct { AccessToken string `json:"accessToken"` RefreshToken string `json:"refreshToken"` ExpiresIn int64 `json:"expiresIn"` Domain string `json:"domain"` } if err := json.Unmarshal(data, &tok); err != nil || tok.AccessToken == "" { return fmt.Errorf("refresh_failed: no accessToken in response — re-login required") } // 第 2 段(锁内):校验快照一致后写回。 a.Lock() defer a.Unlock() // 写回守卫是 AND 语义:锁外期间另一刷新已完成 → 两 token 必同时变化(实测 R-D: // refresh 响应 accessToken/refreshToken 总是一起 rotate,写回也同时写两个),AND // 即「并发刷新已完成」判据;AND 与 OR 在真实形态下等价。唯 OR 会额外放弃的 // 「只有单 token 变化」(如手工只改 auth 文件一个字段)不构成放弃条件——本次 // 结果覆盖手工编辑。 if a.AccessToken != atBefore && a.RefreshToken != rtSnapshot { // 锁外期间另一 goroutine 已完成刷新:新 token 已生效,本次结果不必再写 // (实测 R-E:服务端无 rotation 撤销,并发双刷新拿到的两个新 token 都有效, // 后写覆盖先写二者等价可用;提前返回避免无意义覆盖与 ExpiresAt 抖动)。 return nil } a.AccessToken = tok.AccessToken if tok.RefreshToken != "" { a.RefreshToken = tok.RefreshToken } if tok.Domain != "" { a.Domain = tok.Domain } // preserveExpiry:响应缺 expiresIn 时保留旧过期时间,避免刷新风暴。 // 实测 R-D 响应恒带 expiresIn=5184000(60d)——缺省分支仅为防御,保留旧值 // 避免过期判定漂移。同理,超过 10 年的 expiresIn 按脏值处理保留旧值: // 实测 JWT exp-iat 与 expiresIn 严格自洽(R-F),超量级值只会是上游脏数据, // 照写会把 ExpiresAt 推到荒谬未来 → NeedsRefresh 永假 → token 永不刷新 // 反而真过期失效。 if tok.ExpiresIn > 0 && time.Duration(tok.ExpiresIn)*time.Second < refreshTokenExpiresInMax { a.ExpiresAt = time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second).Unix() } return nil } // ChatStream 发 chat 请求并返回原始 SSE body 流(调用方负责 Close)。 // 等价于 ChatStreamContext(context.Background(), ...):不带调用方取消语义。 // 需要客户端断开联动的调用方用 ChatStreamContext 传入请求 ctx。 // // global realm:先打 /console/chat/completions,404/405 时同一 base 二次换 /v2/chat/completions // (上游新旧路径分叉,PLAN R9 fallback 顺序)。cn:/v2/chat/completions 现状不变。 func (c *Client) ChatStream(a *auth.Auth, body []byte, clientIP string, meta ChatMeta) (rc io.ReadCloser, status int, respBody []byte, err error) { return c.ChatStreamContext(context.Background(), a, body, clientIP, meta) } // ChatStreamContext 同 ChatStream,但从 ctx 派生请求 context:调用方(handler)传入 // r.Context() 后,客户端断连/请求取消会立即中断在途上游调用、释放连接与账号在途名额, // 不再空转到 IdleTimeout。ctx 为 nil 时回落 Background。成功流的 cancel 仍由 // monitorBody 的 Close 接管(reqCtx 取消与显式 Close 任一触发即断)。 // // 错误路径(≥400 且非 fallback 状态码)除 (status, respBody) 外还返回**已分类的** // *Error(Kind 信封 + Retry-After 头解析):客户端错误分类在此一次完成,handler // 不再对 body 二次 Classify(消除「上游分类一次、网关再分类一次」的双路径漂移面), // Retry-After 也随信封流动。respBody 仍原样返回(错误透传语义:message 透传上游 // 原文)。判定为 ErrNone 的响应(理论上不存在,防御)err 为 nil,handler 按 // respBody 自行兜底。 // // global chat 自 #119 实测后固定走 /v2(chat 层无 fallback 链;billing 层的 404 // fallback 独立存在,语义不受影响)。ensureConsoleSystem 在 prepareBody 后统一套用 // 全局脚本:首条消息非 system 时前置兜底 system(防 console 域上游 code 11-128; // #119 后 global 出站固定 /v2,该兜底保留——上游对 /v2 是否需要 system 无实测 // 反证,删了无回滚路径)。 func (c *Client) ChatStreamContext(ctx context.Context, a *auth.Auth, body []byte, clientIP string, meta ChatMeta) (rc io.ReadCloser, status int, respBody []byte, err error) { if ctx == nil { ctx = context.Background() } prepared := c.prepareBody(body, a.Realm(), a.UID, meta.ConversationID) if c.globalOn(a) { prepared = ensureConsoleSystem(prepared) } // reqCtx 的 cancel 在每个出口显式调用(Do 失败 / ≥400 / 成功分支移交 monitorBody), // 循环本身各分支必 return——无循环尾兜底代码(此前外层 var cancel 从未赋值 + // 尾部不可达 cancel() 是潜伏 nil-panic,已删;chatPaths 恒非空由构造保证)。 for _, path := range c.chatPaths(a) { endpoint := c.chatBase(a) + path req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(prepared)) if err != nil { return nil, 0, nil, err } c.ChatHeaders(req, a, clientIP, meta) // 从调用方 ctx 派生:保留取消传播(父 ctx 取消 → 本 ctx 取消), // 同时 monitorBody.Close 仍能独立 cancel 本分支(空闲掐流)。 reqCtx, cancel := context.WithCancel(ctx) req = req.WithContext(reqCtx) resp, err := c.chatHTTP().Do(req) if err != nil { cancel() log.Printf("ERR: [upstream] chat_stream acct=%s: transport error: %v", logfmt.Label(a.UID, a.Nickname), err) // 传输层失败 → 清空共享连接池的空闲连接(连接层加固):失败连接可能仍 // 留在空闲池里,下一个请求会继续捡到它——仅靠 IdleConnTimeout 等过期 // 不够,主动清池才断根。 roundTripCloseIdle(c.chatHTTP().Transport) return nil, 0, nil, err } if resp.StatusCode >= 400 { raw, rerr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) resp.Body.Close() cancel() // body 读失败(掐流/截断)→ 传输层错误:半截 raw 不交回调用方进 Classify, // 否则 handler 侧 applyErrorPolicy 会按误判分类罚号。 if rerr != nil { log.Printf("ERR: [upstream] chat_stream acct=%s: read body: %v", logfmt.Label(a.UID, a.Nickname), rerr) return nil, 0, nil, fmt.Errorf("read body: %w", rerr) } kind := Classify(resp.StatusCode, string(raw)) log.Printf("WARN: [upstream] chat_stream acct=%s: upstream %d %s body=%s", logfmt.Label(a.UID, a.Nickname), resp.StatusCode, kind, truncate(string(raw), 200)) // ≥400 直接返回(#119 后 global 单路径 /v2,chat 层无 fallback 链)。 // 分类一次、随 Kind 信封返回(含 Retry-After 头解析): // ErrNone 是防御分支(≥400 不应产生 None),返回原文让 handler 兜底。 if kind == ErrNone { return nil, resp.StatusCode, raw, nil } ue := &Error{Kind: kind, Status: resp.StatusCode, Msg: truncate(string(raw), 200)} if d, ok := ParseRetryAfter(resp.Header); ok { ue.RetryAfter = d } return nil, resp.StatusCode, raw, ue } // 成功分支:cancel 所有权交给 monitorBody(其 Close 会 cancel); // IdleTimeout<=0 时 monitorBody 原样返回底流、无人调 cancel——可接受: // 取消传播由 http.Transport 在 body Close / 父 ctx 取消时处理,连接正常清理。 return monitorBody(resp.Body, c.IdleTimeout, cancel), resp.StatusCode, nil, nil } panic("unreachable: chatPaths is never empty") // for range 空集时编译器仍要求兜底 return;chatPaths 恒非空(构造保证),永不触达 } // ModelInfo 动态模型信息(含 maxInputTokens/maxOutputTokens + 上游模型对象全字段)。 // CN /console 与 global /v2 的模型对象同构,故共用此结构;上游省略的字段保持零值, // /v1/models 侧按「空值省略」透出(不编造)。 type ModelInfo struct { ID string Name string ContextWindow int64 // = maxInputTokens MaxTokens int64 // = maxOutputTokens(思考与最终回答共享此预算,上游无独立思考上限字段) Efforts []string // reasoning.supportedEfforts(空=未知/固定档) DefaultEffort string // reasoning.defaultEffort(新模型键)或 reasoning.effort(老模型键);空=未返回 // 模型目录全字段(models-full-fields): Description string // descriptionZh 中文描述 Credits string // credits 积分倍率原文(如 "x0.05"),仅展示不参与选号 Tags []string // tags 模型标签(含 badge:限时免费 等) Vendor string // vendor 厂商标识 IsDefault bool // isDefault 是否默认模型 SupportsReasoning bool // supportsReasoning 是否支持推理 SupportsToolCall bool // supportsToolCall 是否支持工具调用 OnlyReasoning bool // onlyReasoning 是否纯推理模型 SupportsImages bool // 顶层 supportsImages(多模态能力,透出到 /v1/models) MaxAllowedSize int64 // maxAllowedSize 最大允许上下文(与 maxInputTokens 口径并列,上游各自下发) CanDisableThinking bool // reasoning.canDisableThinking:思考可关(off 档可用) ReasoningEffort string // reasoning.effort 推理模式(与 supportedEfforts 数组不同源) ReasoningSummary string // reasoning.summary 推理摘要模式(如 "auto") // 优惠(modelPromotions,/v3/config data.modelPromotions):Credits 是**牌价** //(转正后基准倍率),Promo* 是当前生效的限时优惠——面板据此显示「生效价 + // 标签 + 牌价」。PromoFactor 为 nil 表示无 machine-readable 折扣(如「错峰 // 使用」只有时段文案无 factor),仅挂标签/提示。 PromoFactor *float64 // 折扣系数(0=限时免费,0.5=五折);nil=无 PromoCredits string // 折扣后倍率原文(如 "0x" / "0.50x"),仅展示 PromoLabel string // 徽章文案(限时免费 / 夜间折扣 / 错峰使用) PromoNote string // hover 说明原文(含时段/日期描述) } // dynModelEntry 上游模型目录(CN /console 与 global /v2 同构)的单条模型解析形态, // FetchModels 与 global_models.go 的探测共用。iconUrl/descriptionEn/生成参数等 // 按「不透出」原则不解析。modelInfo() 是 dynEntry→ModelInfo 映射的单一事实来源, // 杜绝两域映射漂移。 type dynModelEntry struct { ID string `json:"id"` Name string `json:"name"` // ModelID / Model id 的宽松回退键(仅 global 目录的多信封兜底用,CN 目录 // 不下发这两个键;字段加在这里只是让 typed 解析能"看见"它们)。 ModelID string `json:"modelId"` Model string `json:"model"` Description string `json:"descriptionZh"` Credits string `json:"credits"` Tags []string `json:"tags"` Vendor string `json:"vendor"` IsDefault bool `json:"isDefault"` MaxInputTokens int64 `json:"maxInputTokens"` MaxOutputTokens int64 `json:"maxOutputTokens"` MaxAllowedSize int64 `json:"maxAllowedSize"` Disabled bool `json:"disabled"` SupportsImages bool `json:"supportsImages"` SupportsReason bool `json:"supportsReasoning"` SupportsTool bool `json:"supportsToolCall"` OnlyReasoning bool `json:"onlyReasoning"` Reasoning struct { Effort string `json:"effort"` Summary string `json:"summary"` DefaultEffort string `json:"defaultEffort"` CanDisableThinking bool `json:"canDisableThinking"` SupportedEfforts []string `json:"supportedEfforts"` } `json:"reasoning"` } // modelInfo 按解析条目构造 ModelInfo(dynEntry→ModelInfo 映射的单一事实来源)。 // defaultEffort 新老双键兼容:defaultEffort 优先,缺省回落 effort。 func (m dynModelEntry) modelInfo() ModelInfo { def := m.Reasoning.DefaultEffort if def == "" { def = m.Reasoning.Effort } return ModelInfo{ ID: m.ID, Name: m.Name, ContextWindow: m.MaxInputTokens, MaxTokens: m.MaxOutputTokens, Efforts: m.Reasoning.SupportedEfforts, DefaultEffort: def, SupportsImages: m.SupportsImages, Description: m.Description, Credits: m.Credits, Tags: m.Tags, Vendor: m.Vendor, IsDefault: m.IsDefault, SupportsReasoning: m.SupportsReason, SupportsToolCall: m.SupportsTool, OnlyReasoning: m.OnlyReasoning, MaxAllowedSize: m.MaxAllowedSize, CanDisableThinking: m.Reasoning.CanDisableThinking, ReasoningEffort: m.Reasoning.Effort, ReasoningSummary: m.Reasoning.Summary, } } // nonChatModel 判定是否非对话模型(应从模型列表过滤掉)。 // 来源:harness buddy.ts:547-555。三类规则: // - id 前缀 nes-/completion-/codewise-:嵌入/补全/代码专用模型,选了报 code=11102。 // - maxOutputTokens ≤ 256:tiny 输出非对话模型。 // - tags 含生成类标签(图片/视频):生成模型走各自专用端点,作为对话模型 // 选上去只会报 11102,非本网关用途。 // // 生成类标签随上游扩充:早期只有 text-to-image,桌面端目录(2026-10-02 实测) // 另有 text-to-video / image-to-video(seedance 系列)与 image-to-image // (gpt-image 系列)——后者已由 text-to-image 覆盖,此处补齐视频两类。 // 注意本函数 CN 与 global 共用,新增标签对两域同时生效。 func nonChatModel(id string, maxOutputTokens int64, tags []string) bool { id = strings.ToLower(strings.TrimSpace(id)) for _, p := range [...]string{"nes-", "completion-", "codewise-"} { if strings.HasPrefix(id, p) { return true } } if maxOutputTokens > 0 && maxOutputTokens <= 256 { return true } for _, t := range tags { switch t { case "text-to-image", "image-to-image", "text-to-video", "image-to-video": return true } } return false } // codeBuddyIDEUA /v3/config 要求能解析出 CodeBuddy 版本号的 UA。 // CLI 三段式 WorkBuddy UA 会拿到精简目录(flash 输出 128K、无 supportedEfforts); // 官方 IDE 头 `CodeBuddyIDE/4.12.0 CodeBuddy/4.12.0` 才返回完整能力 // (flash:393216 + low/high/max)。 // 版本号需随上游 IDE 发版跟进:UAn 版本过旧时该端点可能同样返回精简目录。 const codeBuddyIDEUA = "CodeBuddyIDE/4.12.0 CodeBuddy/4.12.0" // codeBuddyCLIUA CLI 三段式 UA。**实测(2026-09-22)该端点对不同 UA 下发的模型集合不同**: // - IDE UA → 14 条(10 个 chat:含 o4-mini / enhance-1.0 / auto-chat,**无 deepseek 系列**) // - CLI UA → 22 条(22 个 chat:**含 deepseek-v4.1-flash / deepseek-v4.1-flash-sg / // gpt-6-astra / kimi-k2.8-preview**,但无 o4-mini / enhance-1.0 / auto-chat) // // 注意两点,都与旧注释相反,勿再按旧注释推断: // 1. 旧注释称「CLI UA 拿到精简目录、IDE UA 才返回完整能力」——实测模型数量恰好相反, // 但 **IDE 响应体积更大**(26003B vs 21111B),故「完整能力」应理解为**单条字段更全**, // 而非模型更多。两路各有独有模型,缺一不可。 // 2. 该常量仅用于 global 侧第二路探测;CN 侧仍走 codeBuddyIDEUA 单路。 const codeBuddyCLIUA = "CLI/2.63.2 CodeBuddy/2.63.2" // FetchModels 调上游动态模型接口(CN 侧;global 账号见 global_models.go 家族)。 // // v3-config-merge:动态目录 = /v3/config(主,IDE UA 完整能力版)+ 企业端点 // (/console,cli 面过滤,补缺)的并集,两路**并发**探测。合并去重 key = 模型 id, // v3 条目优先(credits 等字段以 v3 为准),企业端点只补 v3 缺失的模型。 // /v3 失败(400/网络错/解析失败)不拖累企业端点结果——降级为仅企业端点,warn 日志; // 反之亦然(两路独立容错)。 func (c *Client) FetchModels(a *auth.Auth) ([]ModelInfo, error) { type probeResult struct { infos []ModelInfo err error } enterpriseCh := make(chan probeResult, 1) v3Ch := make(chan probeResult, 1) go func() { infos, err := c.fetchEnterpriseModels(a) enterpriseCh <- probeResult{infos, err} }() go func() { infos, err := c.fetchV3Models(a) v3Ch <- probeResult{infos, err} }() enterprise := <-enterpriseCh v3 := <-v3Ch if enterprise.err != nil && v3.err != nil { return nil, enterprise.err // 两路全失败:返回企业端点错误(既有调用方语义零漂移) } if v3.err != nil { // /v3 失败降级:不拖累企业端点结果(降级仅企业端点 + warn)。 log.Printf("WARN: [upstream] fetch models: v3/config probe failed (degraded to enterprise endpoint): %v", v3.err) } if enterprise.err != nil { log.Printf("WARN: [upstream] fetch models: enterprise endpoint failed (v3/config only): %v", enterprise.err) } out := mergeModelInfos(v3.infos, enterprise.infos) if len(out) == 0 { return nil, fmt.Errorf("models api returned empty list") } c.storeModelRates(a.Realm(), out) // 刷新 effort 能力缓存(供请求体降级;无 supportedEfforts 的模型不入桶)。 // 空桶时跳过写:避免「某探测无档位数据」清掉既有桶。 cache := make(map[string][]string, len(out)) defCache := make(map[string]string, len(out)) for _, mi := range out { if len(mi.Efforts) > 0 { cache[mi.ID] = mi.Efforts } if mi.DefaultEffort != "" { defCache[mi.ID] = mi.DefaultEffort } } if len(cache) == 0 && len(defCache) == 0 { return out, nil } // 按探测账号的 realm 写入对应桶:CN 探测只进 cn 桶,global 同模型名不被污染(C-2)。 c.storeEfforts(a.Realm(), cache, defCache) return out, nil } // mergeModelInfos 合并两路模型目录:primary 为主(同 id 以 primary 条目为准—— // credits 等字段以主端点为权威),secondary 只补 primary 缺失的 id。 // 去重 key = 模型 id;输出顺序 = primary 原序在前、secondary 补充项(secondary 原序) // 在后——稳定输出,不依赖 map 迭代序。 func mergeModelInfos(primary, secondary []ModelInfo) []ModelInfo { if len(secondary) == 0 { return primary } seen := make(map[string]bool, len(primary)+len(secondary)) out := make([]ModelInfo, 0, len(primary)+len(secondary)) for _, mi := range primary { if mi.ID == "" || seen[mi.ID] { continue } seen[mi.ID] = true out = append(out, mi) } for _, mi := range secondary { if mi.ID == "" || seen[mi.ID] { continue } seen[mi.ID] = true out = append(out, mi) } return out } // fetchEnterpriseModels 单路探测企业模型端点(/console/enterprises/personal/models)。 // 解析口径:agents[cli].models 过滤 + nonChatModel 剔除 + disabled 剔除。 func (c *Client) fetchEnterpriseModels(a *auth.Auth) ([]ModelInfo, error) { // 局部变量名避开 url(本包已 import net/url,同名会造成阅读混淆)。 endpoint := c.chatBase(a) + "/console/enterprises/personal/models" req, err := http.NewRequest(http.MethodGet, endpoint, nil) if err != nil { return nil, err } c.CommonHeaders(req, a) // 复用共享请求头(Origin/Referer/UA/Accept/Content-Type) // AccessToken 加锁快照(见 auth.AccessTokenValue:keepalive 刷新在 a.mu 内改写)。 req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) resp, err := c.HTTP.Do(req) if err != nil { return nil, err } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { // 读失败 → 传输层错误(handler 侧该路径不 NoteError)。 return nil, fmt.Errorf("read body: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("models api status %d: %s", resp.StatusCode, truncate(string(raw), 120)) } var env struct { Code int `json:"code"` Data struct { Models []dynModelEntry `json:"models"` Agents []struct { Name string `json:"name"` Models []string `json:"models"` } `json:"agents"` } `json:"data"` } if err := json.Unmarshal(raw, &env); err != nil { return nil, fmt.Errorf("models parse: %w", err) } if env.Code != 0 { return nil, fmt.Errorf("models api code=%d", env.Code) } var cliIDs []string for _, ag := range env.Data.Agents { if ag.Name == "cli" { cliIDs = ag.Models break } } if len(cliIDs) == 0 { return nil, fmt.Errorf("no cli agent models found") } // dynMap 收集模型字段;nonChatModel 过滤在写入 dynMap 前执行, // 确保非对话条目(nes-/completion-/codewise- 前缀、maxOutputTokens≤256、 // tags 含 text-to-image)根本不进返回列表(来源:harness buddy.ts:547-555)。 dynMap := make(map[string]dynModelEntry, len(env.Data.Models)) for _, m := range env.Data.Models { if nonChatModel(m.ID, m.MaxOutputTokens, m.Tags) { continue } dynMap[m.ID] = m } out := make([]ModelInfo, 0, len(cliIDs)) for _, id := range cliIDs { if m, ok := dynMap[id]; ok && !m.Disabled { out = append(out, m.modelInfo()) } } if len(out) == 0 { return nil, fmt.Errorf("models api returned empty list") } return out, nil } // fetchV3Models 单路探测 /v3/config(IDE UA 完整能力版,见 codeBuddyIDEUA)。 // v3 面取全量 models(不按 agents[cli] 过滤,与 global 探测口径一致),按同一 // nonChatModel 规则剔除非对话条目(selected 会选模型报 code=11102)。 // 失败返回错误(调用方降级为仅企业端点)。 func (c *Client) fetchV3Models(a *auth.Auth) ([]ModelInfo, error) { byID, err := c.fetchV3ConfigModelMap(a, codeBuddyIDEUA) if err != nil { return nil, err } out := make([]ModelInfo, 0, len(byID)) for _, mi := range byID { if nonChatModel(mi.ID, mi.MaxTokens, mi.Tags) { continue } out = append(out, mi) } if len(out) == 0 { return nil, fmt.Errorf("v3/config returned empty models") } return out, nil } // v3ModelPromotion /v3/config data.modelPromotions 单条优惠定义(2026-09-23 实测 // 7 条:deepseek 系错峰五折、glm-5.2 夜间五折、hy3 与 hy4-preview-f 限时免费)。 // discount 只在部分条目上存在:有 factor 的可算生效价;「错峰使用」类只有时段 // 文案(factor 藏在 hover 文本里,无机器可读值),仅透出标签与说明。 type v3ModelPromotion struct { Enabled bool `json:"enabled"` Priority int `json:"priority"` ModelIDs []string `json:"modelIds"` Badge *struct { Label string `json:"label"` } `json:"badge"` Discount *struct { DiscountedCredits string `json:"discountedCredits"` Factor float64 `json:"factor"` } `json:"discount"` Hover *struct { TextZh string `json:"textZh"` } `json:"hover"` Schedule *struct { Daily []struct { Start string `json:"start"` // "23:00" End string `json:"end"` // "7:50"(可跨午夜) } `json:"daily"` Timezone string `json:"timezone"` // 实测恒 Asia/Shanghai ValidFrom string `json:"validFrom"` // RFC3339,可缺省 ValidUntil string `json:"validUntil"` } `json:"schedule"` } // promoZone 优惠时区:上游恒 Asia/Shanghai(UTC+8 无夏令时),用 FixedZone 免依赖 // 系统 tzdata(Windows 无 IANA 库时 LoadLocation 会失败)。 var promoZone = time.FixedZone("CST", 8*3600) // promoClock 解析 "HH:MM" 为当日分钟数;坏值返回 (-1, false)。 func promoClock(hhmm string) (int, bool) { parts := strings.Split(hhmm, ":") if len(parts) != 2 { return -1, false } h, err1 := strconv.Atoi(strings.TrimSpace(parts[0])) m, err2 := strconv.Atoi(strings.TrimSpace(parts[1])) if err1 != nil || err2 != nil || h < 0 || h > 24 || m < 0 || m > 59 { return -1, false } return h*60 + m, true } // promoActive 评估优惠在 now 是否生效:enabled + validFrom/validUntil 内 + 落在 // 任一 daily 窗口(支持跨午夜,如 23:00→7:50)。schedule 为 nil 视为全天生效。 func promoActive(p *v3ModelPromotion, now time.Time) bool { if !p.Enabled { return false } if sc := p.Schedule; sc != nil { if sc.ValidFrom != "" { from, err := time.Parse(time.RFC3339, sc.ValidFrom) if err == nil && now.Before(from) { return false } } if sc.ValidUntil != "" { until, err := time.Parse(time.RFC3339, sc.ValidUntil) if err == nil && !now.Before(until) { return false } } if len(sc.Daily) > 0 { cur := now.Hour()*60 + now.Minute() inWindow := false for _, w := range sc.Daily { st, ok1 := promoClock(w.Start) ed, ok2 := promoClock(w.End) if !ok1 || !ok2 { continue } if st <= ed { if cur >= st && cur < ed { inWindow = true break } } else if cur >= st || cur < ed { // 跨午夜(23:00→7:50) inWindow = true break } } if !inWindow { return false } } } return true } // applyModelPromotions 把当前生效的优惠挂到目录条目:同模型多条命中取 priority // 最高(实测 glm-5.2 白天 badge-only(50) 与夜间五折(100) 靠 priority+daily 双轨 // 切换)。无 discount 对象的条目也挂标签/说明(错峰类),PromoFactor 留 nil。 func applyModelPromotions(out map[string]ModelInfo, promos []v3ModelPromotion) { if len(promos) == 0 || len(out) == 0 { return } now := time.Now().In(promoZone) type cand struct { prio int p *v3ModelPromotion } best := map[string]cand{} for i := range promos { p := &promos[i] if !promoActive(p, now) { continue } for _, id := range p.ModelIDs { if _, ok := out[id]; !ok { continue // 目录外模型(如同名 global 变体)不挂 } if b, seen := best[id]; !seen || p.Priority > b.prio { best[id] = cand{prio: p.Priority, p: p} } } } for id, c := range best { mi := out[id] if c.p.Badge != nil { mi.PromoLabel = c.p.Badge.Label } if c.p.Hover != nil { mi.PromoNote = c.p.Hover.TextZh } if c.p.Discount != nil { f := c.p.Discount.Factor mi.PromoFactor = &f mi.PromoCredits = c.p.Discount.DiscountedCredits } out[id] = mi } } // storeEfforts 按 realm 写入 effort 能力缓存桶(efforts + defaultEfforts),并发安全。 // 供 CN FetchModels 与 global 探测共用:拉取到的模型档位落桶后,出站请求体 // normalizeReasoningEffort 才能按域降级。efforts 与 defs 均空时删除该 realm 桶 // (等价「该域无可降级档位」)。调用方负责在「无新数据」时跳过写。 func (c *Client) storeEfforts(realm string, efforts map[string][]string, defs map[string]string) { c.effortsMu.Lock() defer c.effortsMu.Unlock() if c.efforts == nil { c.efforts = make(map[string]map[string][]string) } if c.defaultEfforts == nil { c.defaultEfforts = make(map[string]map[string]string) } k := realmKey(realm) if len(efforts) == 0 && len(defs) == 0 { delete(c.efforts, k) delete(c.defaultEfforts, k) return } c.efforts[k] = efforts c.defaultEfforts[k] = defs } // normalizeModelRate 把上游倍率原文规范化为可比较的数值键。 // 兼容 "x0.05" / "x0.05 credits" / "0.50x" 等形态;无法数值化时保留去除 // credits 后缀与空白后的原文,避免编造倍率。 func normalizeModelRate(raw string) string { s := strings.TrimSpace(raw) if s == "" { return "" } if strings.HasSuffix(strings.ToLower(s), "credits") { s = strings.TrimSpace(s[:len(s)-len("credits")]) } if strings.HasPrefix(strings.ToLower(s), "x") { s = strings.TrimSpace(s[1:]) } else if strings.HasSuffix(strings.ToLower(s), "x") { s = strings.TrimSpace(s[:len(s)-1]) } if s == "" { return "" } v, err := strconv.ParseFloat(s, 64) if err != nil { return strings.TrimSpace(raw) } return strconv.FormatFloat(v, 'f', -1, 64) } // effectiveModelRate 返回模型当前生效倍率:有机器可读优惠时取折扣价, // 否则取牌价;两者均缺省时为空。 func effectiveModelRate(mi ModelInfo) string { if mi.PromoFactor != nil && strings.TrimSpace(mi.PromoCredits) != "" { return normalizeModelRate(mi.PromoCredits) } return normalizeModelRate(mi.Credits) } // storeModelRates 按 realm 整体替换模型倍率快照。目录成功刷新但没有可解析 // 倍率时写入空桶,使旧倍率不会继续冒充当前价。 func (c *Client) storeModelRates(realm string, infos []ModelInfo) { rates := make(map[string]string, len(infos)) for _, mi := range infos { if mi.ID == "" { continue } if rate := effectiveModelRate(mi); rate != "" { rates[mi.ID] = rate } } c.effortsMu.Lock() defer c.effortsMu.Unlock() if c.modelRates == nil { c.modelRates = make(map[string]map[string]string) } c.modelRates[realmKey(realm)] = rates } // ModelRate 返回最近成功刷新的指定域模型生效倍率;未知返回空串。 func (c *Client) ModelRate(realm, model string) string { if c == nil || model == "" { return "" } c.effortsMu.RLock() defer c.effortsMu.RUnlock() return c.modelRates[realmKey(realm)][model] } // GlobalEffortSnapshot 导出 global 域 effort 能力缓存(探测下发 ∪ 静态兜底合并后的桶), // 供 /v1/models 输出 reasoning_supported_efforts / reasoning_default_effort。 // 返回副本;桶未填充(无 global 账号或从未探测)→ nil(调用方回落静态兜底表)。 func (c *Client) GlobalEffortSnapshot() (efforts map[string][]string, defaults map[string]string) { return c.effortsSnapshot("global"), c.defaultEffortsSnapshot("global") } // v3ConfigDomain /v3/config 的 X-Domain:优先账号落盘 domain,否则 chatBase host。 func v3ConfigDomain(a *auth.Auth, chatBase string) string { if a != nil { // Domain 加锁快照(见 auth.DomainValue:keepalive 刷新在 a.mu 内改写)。 if d := strings.TrimSpace(a.DomainValue()); d != "" { d = strings.TrimPrefix(d, "https://") d = strings.TrimPrefix(d, "http://") return strings.TrimSuffix(d, "/") } } if u, err := url.Parse(chatBase); err == nil && u.Host != "" { return u.Host } return "copilot.tencent.com" } // fetchV3ConfigModelMap 拉官方 IDE 配置目录,按模型 id 建能力表。 // 该端点对 UA 敏感:必须带 CodeBuddy/CodeBuddyIDE 版本,否则 400 code=12403。 // ua 为该次请求的 User-Agent;空串等价 codeBuddyIDEUA。该端点对 UA 敏感且**不同 UA 下发 // 不同模型集合**(见 codeBuddyCLIUA 注释),global 探测据此并发两路取并集。 func (c *Client) fetchV3ConfigModelMap(a *auth.Auth, ua string) (map[string]ModelInfo, error) { req, err := http.NewRequest(http.MethodGet, c.chatBase(a)+"/v3/config", nil) if err != nil { return nil, err } req.Header.Set("Accept", "application/json, text/plain, */*") req.Header.Set("X-Requested-With", "XMLHttpRequest") // AccessToken 加锁快照(同 fetchEnterpriseModels)。 req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) if a != nil && a.UID != "" { req.Header.Set("X-User-Id", a.UID) } req.Header.Set("X-Domain", v3ConfigDomain(a, c.chatBase(a))) req.Header.Set("X-Product", "SaaS") if ua == "" { ua = codeBuddyIDEUA } req.Header.Set("User-Agent", ua) c.injectCodeBuddyRequest(req) resp, err := c.HTTP.Do(req) if err != nil { return nil, err } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) if err != nil { // 读失败 → 传输层错误:半截 body 不进解析(不罚号)。 return nil, fmt.Errorf("read body: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("v3/config status %d: %s", resp.StatusCode, truncate(string(raw), 120)) } var env struct { Code int `json:"code"` Data struct { Models []dynModelEntry `json:"models"` // 试用模型横幅:上游把「N 天免费试用」的模型放在这里,**不在 data.models 里**。 // 实测 global 侧 hy4-preview-f 只出现在此(modelId=hy4-preview-f、 // targetModelId=hy4-preview、trialDays=14),纯 data.models 解析会漏掉它。 ProductFeaturesConfig struct { ModelTrialBanner struct { Banners []struct { ModelID string `json:"modelId"` TargetModelID string `json:"targetModelId"` } `json:"banners"` } `json:"ModelTrialBanner"` } `json:"productFeaturesConfig"` ModelPromotions []v3ModelPromotion `json:"modelPromotions"` } `json:"data"` } if err := json.Unmarshal(raw, &env); err != nil { return nil, fmt.Errorf("v3/config parse: %w", err) } if env.Code != 0 { return nil, fmt.Errorf("v3/config code=%d", env.Code) } out := make(map[string]ModelInfo, len(env.Data.Models)) for _, m := range env.Data.Models { if strings.TrimSpace(m.ID) == "" { continue } out[m.ID] = m.modelInfo() } // 补入试用横幅模型(ModelTrialBanner):上游把「N 天免费试用」的模型只放在这里, // data.models 里没有,故纯目录解析会漏(实测 global 侧 hy4-preview-f 即如此, // 但该模型**实际可调用**)。 // // 元数据口径:能力字段(context/maxTokens/efforts/reasoning 等)从 targetModelId // 的既有条目继承——试用版与其转正目标是同族模型,能力应当一致; // 但 **Credits 与 Tags 显式清空**——它们描述的是"转正后"的计费与营销信息 // (如 hy4-preview 的 x0.29 与 badge),用在免费试用版上会误导下游展示。 // // firstUseTimeKey / trialDays 属**账号级**试用状态,不透出给下游。 for _, b := range env.Data.ProductFeaturesConfig.ModelTrialBanner.Banners { id := strings.TrimSpace(b.ModelID) if id == "" { continue } if _, exists := out[id]; exists { continue } mi := ModelInfo{ID: id} if tgt := strings.TrimSpace(b.TargetModelID); tgt != "" { if base, ok := out[tgt]; ok { mi = base mi.ID = id } } mi.Credits = "" mi.Tags = nil out[id] = mi } // 挂当前生效的限时优惠(modelPromotions):Credits 字段是**牌价**(转正后基准 // 倍率,如 hy4-preview-f 的 x0.29),而 WorkBuddy 客户端显示的是生效价(试用/ // 折扣窗口内 factor 打折)——面板据此展示「生效价 + 标签 + 牌价」。 applyModelPromotions(out, env.Data.ModelPromotions) if len(out) == 0 { return nil, fmt.Errorf("v3/config returned empty models") } return out, nil } // UserResource 查询账号积分余额与总额度(所有套餐聚合)。remain 负值钳 0; // total 取与 remain 同源的额度字段(CycleCapacitySize 优先,无周期额度退 // CapacitySize),上游缺 size 的套餐按 remain 兜底,保证百分比不超 100%。 // CreditPackage 单个积分包的构成明细(面板「积分构成」用)。 // // 两个账号即使任务完成度完全一致,余额也可能相差上千——差别藏在包的**面额与 // 来源**里(「国内运营裂变包」「拉新权益包」按次发放,面额 6~1500 不等)。 // 只看聚合值看不出这件事,所以把逐包明细暴露出来。 type CreditPackage struct { Name string `json:"name"` Remain int64 `json:"remain"` Used int64 `json:"used"` Size int64 `json:"size"` // EndTime 该包的失效时刻:优先 DeductionEndTime(可抵扣窗口结束,真「用不完 // 就没了」),缺失依次回落 ExpiredTime / PackageEndTime / CycleEndTime(周期 // 边界,仅兜底)。RFC3339 或上游墙钟字符串,前端取日期部分展示。 EndTime string `json:"end_time,omitempty"` // ExpiresAt 与 EndTime 同源的 Unix 毫秒时间戳,供面板按精确剩余天数聚合。 ExpiresAt int64 `json:"expires_at,omitempty"` // CreatedAt 发放时刻,RFC3339。**这是区分「首登赠送」与「活动奖励」的唯一依据**: // 两类包的 PackageName 与 PackageCode 完全相同(例如都是「国内运营裂变包」+ // TCACA_code_007_*),只看名字无法区分,只有时间能说明它是不是账号首次授权那刻发的。 CreatedAt string `json:"created_at,omitempty"` // PackageCode / SubProductCode 上游的包类型标识。同 Name 不同 Code 的包可能 // 是不同来源;同 Code 不同面额则是同来源分批发放(首登 1500 与活动 300 即如此)。 PackageCode string `json:"package_code,omitempty"` SubProductCode string `json:"sub_product_code,omitempty"` SubProductName string `json:"sub_product_name,omitempty"` // Cycle 为 true 表示按周期发放的包(读 Cycle* 字段),否则读 Capacity*。 Cycle bool `json:"cycle,omitempty"` } // CreditPackages 返回账号当前的逐包构成。remain/size 为各包求和。 // // 字段选择与 UserResourceDetailed 的聚合口径一致:CycleCapacitySize > 0 时按 // 周期字段算,否则按 Capacity 字段算——两条路径不能混,否则同一个包会被算两次。 func (c *Client) CreditPackages(a *auth.Auth) ([]CreditPackage, int64, int64, error) { now := time.Now() body := map[string]any{ "PageNumber": 1, "PageSize": 100, "ProductCode": "p_tcaca", "Status": []int{0, 3}, "PackageEndTimeRangeBegin": now.Format(packageEndLayout), "PackageEndTimeRangeEnd": now.Add(365 * 101 * 24 * time.Hour).Format(packageEndLayout), } data, err := c.billingMeterJSON(a, c.billingMeterPaths(a), http.MethodPost, body) if err != nil { return nil, 0, 0, err } // 注意层级:doJSON 已经解过 apiEnvelope 并返回 env.Data,所以这里从 // Response 开始解析——**不能**再套一层 Code/Data,否则 Accounts 恒为空, // 表现为「每个号都 0 个包」(实测踩过)。 var resp struct { Response struct { Data struct { Accounts []struct { PackageName string `json:"PackageName"` CapacityRemain int64 `json:"CapacityRemain"` CapacityUsed int64 `json:"CapacityUsed"` CapacitySize int64 `json:"CapacitySize"` CycleCapacityRemain int64 `json:"CycleCapacityRemain"` CycleCapacityUsed int64 `json:"CycleCapacityUsed"` CycleCapacitySize int64 `json:"CycleCapacitySize"` // 到期时间字段名在上游存在三种口径:ExpiredTime / PackageEndTime // 在 CN/global 实测字段全集里均恒 miss(见 UserResourceDetailed // 处注释),真实下发的是 CycleEndTime——三者都读,谁有值用谁。 ExpiredTime string `json:"ExpiredTime"` PackageEndTime string `json:"PackageEndTime"` CycleEndTime string `json:"CycleEndTime"` // DeductionEndTime 可抵扣窗口结束(epoch 毫秒)——「这个包什么时候 // 不能再花」的真失效时刻。CycleEndTime 是周期边界(额度重置点), // 两者语义不同:判「用不完就没了」以本字段为准,CycleEndTime 兜底 //(OkRoromori 分支实测结论:请求参数叫 PackageEndTimeRange*,但 // 响应里 ExpiredTime 恒空,真正的失效时刻只有这里下发)。 DeductionEndTime int64 `json:"DeductionEndTime"` // 发放时刻(epoch 毫秒)。 CreateTime int64 `json:"CreateTime"` PackageCode string `json:"PackageCode"` SubProductCode string `json:"SubProductCode"` SubProductName string `json:"SubProductName"` } `json:"Accounts"` } `json:"Data"` } `json:"Response"` } if err := json.Unmarshal(data, &resp); err != nil { return nil, 0, 0, fmt.Errorf("packages parse: %w", err) } packs := resp.Response.Data.Accounts out := make([]CreditPackage, 0, len(packs)) var sumRemain, sumSize int64 for _, p := range packs { cp := CreditPackage{ Name: p.PackageName, PackageCode: p.PackageCode, SubProductCode: p.SubProductCode, SubProductName: p.SubProductName, } switch { case p.DeductionEndTime > 0: // 真失效时刻(可抵扣窗口结束),语义见上方字段注释:判「用不完就没了」 // 用它而不是周期边界。epoch 毫秒 → RFC3339,与 CycleEndTime 字符串口径 // 共存(前端统一 slice(0,10) 取日期)。ExpiresAt 直接用原始毫秒—— // RFC3339 不是 packageEndLayout 形态,交给下方解析会静默失败得 0。 cp.EndTime = time.UnixMilli(p.DeductionEndTime).Format(time.RFC3339) cp.ExpiresAt = p.DeductionEndTime case p.ExpiredTime != "": cp.EndTime = p.ExpiredTime case p.PackageEndTime != "": cp.EndTime = p.PackageEndTime default: cp.EndTime = p.CycleEndTime } if cp.ExpiresAt == 0 && cp.EndTime != "" { if end, perr := time.ParseInLocation(packageEndLayout, cp.EndTime, softRateResetLoc); perr == nil { cp.ExpiresAt = end.UnixMilli() } } // CreateTime 是 epoch 毫秒;0 表示上游没给,留空而不是伪造 1970。 if p.CreateTime > 0 { cp.CreatedAt = time.UnixMilli(p.CreateTime).Format(time.RFC3339) } if p.CycleCapacitySize > 0 { cp.Cycle = true cp.Remain, cp.Size = p.CycleCapacityRemain, p.CycleCapacitySize cp.Used = cp.Size - cp.Remain if p.CycleCapacityUsed > cp.Used { cp.Used = p.CycleCapacityUsed cp.Remain = cp.Size - cp.Used } if cp.Remain < 0 { cp.Remain = 0 } } else { cp.Remain, cp.Used, cp.Size = p.CapacityRemain, p.CapacityUsed, p.CapacitySize if cp.Used == 0 && cp.Size > cp.Remain { cp.Used = cp.Size - cp.Remain } } sumRemain += cp.Remain sumSize += cp.Size out = append(out, cp) } // 面额降序:大包一眼可见,正是差异最可能出现的地方。 sort.SliceStable(out, func(i, j int) bool { return out[i].Size > out[j].Size }) return out, sumRemain, sumSize, nil } func (c *Client) UserResource(a *auth.Auth) (remain, total int64, err error) { remain, total, _, err = c.UserResourceDetailed(a, 0) return remain, total, err } // packageEndLayout 上游套餐到期时间的墙钟格式(UTC+8,与 softRateResetLoc 同口径)。 const packageEndLayout = "2006-01-02 15:04:05" // parsePackageEndTime 统一解析上游套餐到期时间。空值、格式异常返回 false, // 调用方据此保守地不把该包计入最早到期路由。 func parsePackageEndTime(raw string) (time.Time, bool) { if raw == "" { return time.Time{}, false } t, err := time.ParseInLocation(packageEndLayout, raw, softRateResetLoc) if err != nil { return time.Time{}, false } return t, true } // UserResourceDetailed 在 UserResource 基础上额外返回「快过期」积分子集: // soon > 0 且套餐 CycleEndTime 解析成功且到期时刻 ≤ now+soon 的余额计入 expiring // (pool 据此优先消耗,避免官方活动赠送的奖励积分到期作废);soon ≤ 0 时 expiring // 恒 0(禁用分桶,行为与引入前一致)。expiring 是 remain 的一部分。 // // 到期时间判据是 CycleEndTime(上游实测:CN/global 两域字段全集均无 PackageEndTime, // 旧判据恒 miss 致 expiring 恒 0;CycleEndTime 是上游真实下发的到期时刻—— // global Bonus Pack 14 天赠送积分的到期时间即此字段)。解析失败/缺失的套餐保守 // 不计入 expiring(不误标为快过期而插队)。 // 单套餐取数统一调 packageRemainUsed(与 CreditPackages 同一事实来源,含 remain // 钳 [0,size] 与 used 修正;消除双份逻辑漂移——旧中间 switch 只钳负值,上游脏数据 // CycleRemain>Size 时会高估)。 func (c *Client) UserResourceDetailed(a *auth.Auth, soon time.Duration) (remain, total, expiring int64, err error) { remain, total, expiring, _, _, err = c.UserResourceDetailedWithExpiry(a, soon) return remain, total, expiring, err } // UserResourceDetailedWithExpiry 在 UserResourceDetailed 基础上返回最早未来到期批次: // earliestAt 是最早的可用到期时刻,earliestRemaining 是同一时刻所有正余额包的剩余量之和。 // 已过期、剩余为 0、缺少或无法解析到期时间的包都不会成为最早批次;无有效批次时返回零值。 func (c *Client) UserResourceDetailedWithExpiry(a *auth.Auth, soon time.Duration) (remain, total, expiring int64, earliestAt time.Time, earliestRemaining int64, err error) { now := time.Now() body := map[string]any{ "PageNumber": 1, "PageSize": 100, "ProductCode": "p_tcaca", "Status": []int{0, 3}, "PackageEndTimeRangeBegin": now.Format(packageEndLayout), "PackageEndTimeRangeEnd": now.Add(365 * 101 * 24 * time.Hour).Format(packageEndLayout), } // 余额查询同样做瞬时错误有界重试(签到后紧接着的 user-resource 偶发 500 会让 // 该账号错过本次解冻/到期快照更新,只能等下一个刷新周期)。 var data json.RawMessage err = c.retryBillingTransient(func() error { var e error data, e = c.billingMeterJSON(a, c.billingMeterPaths(a), http.MethodPost, body) return e }) if err != nil { return 0, 0, 0, time.Time{}, 0, err } var resp struct { Response struct { Data struct { Accounts []struct { PackageName string `json:"PackageName"` CycleEndTime string `json:"CycleEndTime"` // "2006-01-02 15:04:05",缺省/空 = 无到期 CapacitySize int64 `json:"CapacitySize"` CapacityRemain int64 `json:"CapacityRemain"` CapacityUsed int64 `json:"CapacityUsed"` CycleCapacitySize int64 `json:"CycleCapacitySize"` CycleCapacityRemain int64 `json:"CycleCapacityRemain"` CycleCapacityUsed int64 `json:"CycleCapacityUsed"` } `json:"Accounts"` } `json:"Data"` } `json:"Response"` } if err := json.Unmarshal(data, &resp); err != nil { return 0, 0, 0, time.Time{}, 0, fmt.Errorf("resource parse: %w", err) } for _, acct := range resp.Response.Data.Accounts { r, _, size := packageRemainUsed(respAccount{ CapacityRemain: acct.CapacityRemain, CapacityUsed: acct.CapacityUsed, CapacitySize: acct.CapacitySize, CycleCapacityRemain: acct.CycleCapacityRemain, CycleCapacityUsed: acct.CycleCapacityUsed, CycleCapacitySize: acct.CycleCapacitySize, }) if r < 0 { r = 0 } if size < r { size = r } remain += r total += size if r <= 0 { continue } end, ok := parsePackageEndTime(acct.CycleEndTime) if !ok || !end.After(now) { continue } if earliestAt.IsZero() || end.Before(earliestAt) { earliestAt = end earliestRemaining = r } else if end.Equal(earliestAt) { earliestRemaining += r } // 分桶:仅 soon>0 且确实在窗口内 → expiring。 if soon > 0 && !end.After(now.Add(soon)) { expiring += r } } return remain, total, expiring, earliestAt, earliestRemaining, nil } // respAccount 供 packageRemainUsed 解析的套餐字段(CreditPackages 的逐包结构同构)。 type respAccount struct { CapacityRemain int64 CapacityUsed int64 CapacitySize int64 CycleCapacityRemain int64 CycleCapacityUsed int64 CycleCapacitySize int64 } // packageRemainUsed 聚合单套餐的 remain/used/size(与 CreditPackages/cmd/credit 的 // 历史口径一致,收敛至此作为单一事实来源)。Cycle 期套餐优先:用 CycleCapacity // 三字段,used 取 CycleUsed 与 size-remain 的较大者;否则回退 Capacity 三字段。 func packageRemainUsed(a respAccount) (remain, used, size int64) { if a.CycleCapacitySize > 0 { remain = a.CycleCapacityRemain size = a.CycleCapacitySize if remain < 0 { remain = 0 } if remain > size { remain = size } used = size - remain if a.CycleCapacityUsed > used { used = a.CycleCapacityUsed if size >= used { remain = size - used } } return remain, used, size } remain = a.CapacityRemain used = a.CapacityUsed size = a.CapacitySize if used == 0 && size > remain { used = size - remain } return remain, used, size } // DailyCheckin 执行每日签到。已签到(业务 code 非 0)也返回错误,调用方按 msg 区分。 // 偶发上游 5xx(code 10000)做有界重试(见 retryBillingTransient)——单次抖动不再 // 让该账号整天漏签;「已签到」等业务错误不重试。 func (c *Client) DailyCheckin(a *auth.Auth) error { return c.retryBillingTransient(func() error { _, err := c.billingMeterJSON(a, c.checkinMeterPaths(a), http.MethodPost, map[string]any{}) return err }) } // IsAlreadyCheckin 报告 err 是否表示"今天已签到"(上游幂等拒绝重复签到)。 // 只认带分类的 *Error(业务 code 或 HTTP 错误):网络层/解析层错误不得当作幂等成功, // 否则停机补签遇到抖动会误记为 already,账号当天实际未签到却被判定正常。 func IsAlreadyCheckin(err error) bool { var ue *Error if !errors.As(err, &ue) { return false } for _, m := range alreadyCheckinMarkers { if strings.Contains(ue.Msg, m) || strings.Contains(strings.ToLower(ue.Msg), strings.ToLower(m)) { return true } } return false } func truncate(s string, n int) string { return logfmt.Truncate(s, n) }