Download internal/upstream/client.go from a3216/gcli2api: direct link, hf CLI and curl.
- Browser
- Download file 94.9 kB
-
https://huggingface.co/spaces/a3216/gcli2api/resolve/main/internal/upstream/client.go
- Command line
-
hf download hf://spaces/a3216/gcli2api/internal/upstream/client.go
-
curl -L -o client.go https://huggingface.co/spaces/a3216/gcli2api/resolve/main/internal/upstream/client.go
94.9 kB
| // 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/<ver> WorkBuddy/<ver> CLI/<cliVer>`;billing 走 `WorkBuddy/<ver>` | |
| // (仅当 client_name 非空)。 | |
| UserAgent string | |
| // ClientVersion WorkBuddy 客户端版本段(出站 UA 的 `WorkBuddy/<ver>` + X-IDE-Version)。 | |
| // 空 = 内置默认(对齐官方 5.5.4 分发包)。 | |
| ClientVersion string | |
| // CliVersion 出站 UA 中 `CLI/<ver>` 段版本。空 = 内置默认(官方内置 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) | |
| } | |