Download internal/upstream/global_models.go from a3216/gcli2api: direct link, hf CLI and curl.
- Browser
- Download file 26 kB
-
https://huggingface.co/spaces/a3216/gcli2api/resolve/main/internal/upstream/global_models.go
- Command line
-
hf download hf://spaces/a3216/gcli2api/internal/upstream/global_models.go
-
curl -L -o global_models.go https://huggingface.co/spaces/a3216/gcli2api/resolve/main/internal/upstream/global_models.go
26 kB
| // global 模型目录探测:纯动态产出模型名及其窗口 / 能力元数据(v3-config-merge)。 | |
| // | |
| // 探测并发四路:/v3/config 的三种 UA(桌面端主路 + IDE + CLI)与企业端点家族 | |
| // (/v2 → /console 补缺),并集 = v3 条目为主、企业端点补 v3 缺失的 id | |
| // (如 gpt-5.3-codex 只在 /v2 下发)。倍率字段(credits)虽随目录下发,但 | |
| // 只透出展示,不注入 costTier、不参与选号。 | |
| // | |
| // 纯动态:不再回落任何静态名单——拉不出目录即意味着该域上游不可用, | |
| // 假名单只会让客户端选到 11102 的模型(产品决策:无兜底)。 | |
| package upstream | |
| import ( | |
| "encoding/json" | |
| "fmt" | |
| "io" | |
| "log" | |
| "net/http" | |
| "sort" | |
| "strings" | |
| "sync" | |
| "time" | |
| "github.com/linguo2625469/workbuddy2api-panel/internal/auth" | |
| ) | |
| // GlobalModelNames 国际版(global realm)历史静态名单(PLAN §7.2 附录 21 名)。 | |
| // 纯动态化后**不再作为模型目录的基底/兜底**:/v1/models 只透出上游实际下发的模型。 | |
| // 保留仅作历史对照(global e2e 观测日志差集参照)。 | |
| var GlobalModelNames = []string{ | |
| "default-model", | |
| "fast-model", | |
| "balanced-model", | |
| "primary-model", | |
| "hy4-preview", | |
| "gpt-5.6-sol", | |
| "gpt-5.6-terra", | |
| "deep-model", | |
| "deepseek-v4.1-flash", | |
| "gpt-6-astra", | |
| "hy4-preview-f", | |
| "hy3", | |
| "glm-5.2", | |
| "gpt-5.6-luna", | |
| "gpt-5.5", | |
| "gpt-5.4", | |
| "gpt-5.3-codex", | |
| "gemini-3.5-flash", | |
| "glm-5.3", | |
| "kimi-k3", | |
| "kimi-k2.6", | |
| } | |
| // fetchGlobalModelsCache 探测结果缓存(语义参照 CN 侧 handler.dynamicModelsCache:1h TTL + | |
| // 5min 失败负缓存)。按 Client 实例持有(effortsMu 同模式),测试新建 Client 即隔离。 | |
| // Mutex 内嵌,与 modelList 无并发读路径竞争(唯一读写点本文件内)。 | |
| type fetchGlobalModelsCache struct { | |
| sync.Mutex | |
| names []string // 成功缓存:并集模型名(已去重);nil = 未探测/失败 | |
| infos []ModelInfo // 成功缓存:对象形态的全字段条目(窄表/失败形态为 nil) | |
| fetched time.Time | |
| lastFail time.Time | |
| } | |
| // globalModelsTTL / globalModelsFailCooldown 探测缓存时长:成功 1h,失败 5min 负缓存。 | |
| const ( | |
| globalModelsTTL = time.Hour | |
| globalModelsFailCooldown = 5 * time.Minute | |
| ) | |
| // globalModelsProbePaths global 企业模型目录端点候选序列(按 realm 切 base,路径"家族"): | |
| // /v2 家族优先(实测 /v2/enterprises/personal/models 200 含完整模型表), | |
| // /console 作 fallback(同域旧路径,或 500)。v3-config-merge 后该家族降为企业补充路 | |
| // (/v3/config 为主路,与家族并发探测;gpt-5.3-codex 等家族独有模型经此进并集)。 | |
| var globalModelsProbePaths = []string{ | |
| "/v2/enterprises/personal/models", | |
| "/console/enterprises/personal/models", | |
| } | |
| // FetchGlobalModels 探测 global 账号的模型名目录并返回**模型名列表**(无元数据)。 | |
| // | |
| // 纯动态:成功返回并集结果(去重),缓存 1h;失败(两路全非 2xx / 解析失败 / | |
| // 空列表)记 5min 负缓存,返回 nil(无静态回落)。缓存/负缓存命中:直接返回,零上游调用。 | |
| // | |
| // 调用方负责:仅在有 global 账号时调用(无则不探测);GlobalEnabled 关闭时(逃生门) | |
| // 不得调用——本方法由 globalOn(a) 内部兜底,若账号因开关回落 cn 则返回 nil。 | |
| func (c *Client) FetchGlobalModels(a *auth.Auth) []string { | |
| names, _ := c.fetchGlobalModelsOnce(a) | |
| return names | |
| } | |
| // FetchGlobalModelInfos 探测 global 账号的模型目录并返回全字段 ModelInfo 列表。 | |
| // 与 FetchGlobalModels 共享同一次探测与缓存(names + infos 一体落缓存): | |
| // 对象形态 200 → 全字段条目;窄表形态 / 探测失败 / 负缓存 / 非 global 路由账号 | |
| // → nil(调用方按 ID 名单输出裸条目,不编造字段)。 | |
| // 账号因 GlobalEnabled 开关回落 cn 时不探测(globalOn 兜底,零上游调用)。 | |
| func (c *Client) FetchGlobalModelInfos(a *auth.Auth) []ModelInfo { | |
| _, infos := c.fetchGlobalModelsOnce(a) | |
| return infos | |
| } | |
| // fetchGlobalModelsOnce 单次探测决策(缓存命中/负缓存/触发探测),返回 (names, infos)。 | |
| // 纯动态:成功 = 并集结果去重;一切失败 = nil(不回落静态)。 | |
| // infos 仅对象形态成功探测时非 nil。 | |
| func (c *Client) fetchGlobalModelsOnce(a *auth.Auth) (names []string, infos []ModelInfo) { | |
| if !c.globalOn(a) { | |
| // 逃生门兜底:账号不路由 global 上游 → 不探测(零上游调用)。 | |
| return nil, nil | |
| } | |
| c.globalModels.Lock() | |
| if len(c.globalModels.names) > 0 && time.Since(c.globalModels.fetched) < globalModelsTTL { | |
| names, infos := c.globalModels.names, c.globalModels.infos | |
| c.globalModels.Unlock() | |
| return names, infos | |
| } | |
| if !c.globalModels.lastFail.IsZero() && time.Since(c.globalModels.lastFail) < globalModelsFailCooldown { | |
| // 负缓存冷却期内:避免反复打上游,直接按失败处理(无静态回落)。 | |
| c.globalModels.Unlock() | |
| return nil, nil | |
| } | |
| c.globalModels.Unlock() | |
| names, infos, efforts, defaults, err := c.probeGlobalModels(a) | |
| if err != nil || len(names) == 0 { | |
| // 探测失败:负缓存 + 返回 nil(effort 桶不写,prepareBody 走 globalEffortMap 静态兜底)。 | |
| c.globalModels.Lock() | |
| c.globalModels.lastFail = time.Now() | |
| c.globalModels.names = nil | |
| c.globalModels.infos = nil | |
| c.globalModels.Unlock() | |
| return nil, nil | |
| } | |
| // global 域 effort 能力:探测下发的 supportedEfforts/defaultEffort 权威写入 global 桶 | |
| // (raw remote,不并入静态表——静态兜底在 prepareBody 的 globalEffortMap 与 | |
| // /v1/models 的 EffortListing 里按需 fallback)。空探测不写(防清既有桶)。 | |
| if len(efforts) > 0 || len(defaults) > 0 { | |
| c.storeEfforts("global", efforts, defaults) | |
| } | |
| c.storeModelRates("global", infos) | |
| // 成功:探测结果去重。names/infos 均落缓存;倍率等选号敏感字段只透出展示, | |
| // 不注入 costTier。 | |
| seen := make(map[string]bool, len(names)) | |
| merged := make([]string, 0, len(names)) | |
| for _, id := range names { | |
| if id == "" || seen[id] { | |
| continue | |
| } | |
| seen[id] = true | |
| merged = append(merged, id) | |
| } | |
| c.globalModels.Lock() | |
| c.globalModels.names = merged | |
| c.globalModels.infos = infos | |
| c.globalModels.fetched = time.Now() | |
| c.globalModels.lastFail = time.Time{} | |
| c.globalModels.Unlock() | |
| return merged, infos | |
| } | |
| // probeGlobalModels 发起一次 global 模型目录探测(v3-config-merge + desktop-ua): | |
| // /v3/config(三路 UA:桌面端主路 + IDE + CLI)与企业端点家族(/v2 → /console 兜底, | |
| // 补缺)**并发**探测后并集合并。返回模型名列表(已合并、未再去重——去重在 | |
| // fetchGlobalModelsOnce)、全字段 ModelInfo(对象形态;窄表为 nil)及 effort | |
| // 能力桶(supportedEfforts/defaultEffort,可为空)。 | |
| // | |
| // 三路 UA 缺一不可(2026-10-02 实测,同一 global 账号): | |
| // - 桌面端 UA → 29 条:**唯一含 gpt-6-sol / gpt-6-luna / grok-4.7 / gemini-3.8-flash** | |
| // - IDE UA → 13 条:独有 o4-mini / enhance-1.0 / auto-chat | |
| // - CLI UA → 22 条:独有 deepseek 系列 / gpt-6-astra / kimi-k2.8-preview | |
| // | |
| // 桌面端 UA 为主路:它是项目 chat 路径实际使用的 UA(与客户端看到的目录同源), | |
| // 且实测在两路共有的模型上字段完整度不低于另两路(窗口/effort/credits 齐全)。 | |
| // 合并口径:桌面端条目字段权威,IDE/CLI 只补桌面端缺失的模型 id;去重 key = | |
| // 模型 id,输出顺序稳定。全部 v3 路失败且企业端点也失败才返回错误;单路失败 | |
| // 降级为其余路结果 + warn 日志,互不拖累。 | |
| func (c *Client) probeGlobalModels(a *auth.Auth) (names []string, infos []ModelInfo, efforts map[string][]string, defaults map[string]string, err error) { | |
| // probeV3 单次 /v3/config 探测(UA 参数化)。该端点对不同 UA 下发**不同模型集合**: | |
| // 桌面端 / IDE / CLI 三路各有独有模型(见 probeGlobalModels 注释),故并发三路取并集。 | |
| probeV3 := func(ua string) chan probeResult { | |
| ch := make(chan probeResult, 1) | |
| go func() { | |
| // chatBase 已按 realm 切 global base。 | |
| byID, perr := c.fetchV3ConfigModelMap(a, ua) | |
| if perr != nil { | |
| ch <- probeResult{err: perr} | |
| return | |
| } | |
| ids := make([]string, 0, len(byID)) | |
| outInfos := make([]ModelInfo, 0, len(byID)) | |
| for _, mi := range byID { | |
| if nonChatModel(mi.ID, mi.MaxTokens, mi.Tags) { | |
| continue | |
| } | |
| ids = append(ids, mi.ID) | |
| outInfos = append(outInfos, mi) | |
| } | |
| sort.Strings(ids) // map 迭代序随机,排序保输出稳定 | |
| ch <- probeResult{names: ids, infos: outInfos} | |
| }() | |
| return ch | |
| } | |
| // 桌面端 UA(主路):项目 chat 路径实际使用的 UA,与官方客户端看到的目录同源。 | |
| // 按账号 realm 生成(global → `WorkBuddy AI` 平台段),故必须传 a 而非用常量。 | |
| v3DesktopCh := probeV3(c.defaultWorkBuddyUAFor(a)) | |
| v3IDECh := probeV3(codeBuddyIDEUA) | |
| v3CLICh := probeV3(codeBuddyCLIUA) | |
| enterpriseCh := make(chan probeResult, 1) | |
| go func() { | |
| // 企业端点家族:/v2 首选 → /console 兜底(既有探活序,零回归)。 | |
| var lastErr error | |
| for _, path := range globalModelsProbePaths { | |
| names, infos, perr := c.globalModelsOnce(a, path) | |
| if perr != nil { | |
| lastErr = perr | |
| continue | |
| } | |
| enterpriseCh <- probeResult{names: names, infos: infos} | |
| return | |
| } | |
| enterpriseCh <- probeResult{err: lastErr} | |
| }() | |
| v3Desktop := <-v3DesktopCh | |
| v3IDE := <-v3IDECh | |
| v3CLI := <-v3CLICh | |
| enterprise := <-enterpriseCh | |
| // v3 三路自合并:桌面端路字段权威(主路),IDE/CLI 只补主路缺失的模型 id | |
| // (只补 id,字段仍取自各自条目——主路没有该 id 时才轮到它们)。 | |
| // 逐路容错:全失败才带 err 进入下游降级判断,部分失败 warn 后继续。 | |
| v3 := mergeV3Routes([]struct { | |
| label string | |
| res probeResult | |
| }{ | |
| {"desktop-UA", v3Desktop}, | |
| {"IDE-UA", v3IDE}, | |
| {"CLI-UA", v3CLI}, | |
| }) | |
| if v3.err != nil && enterprise.err != nil { | |
| // 两路全失败 → 负缓存语义(等价原家族端点全非 2xx)。 | |
| return nil, nil, nil, nil, v3.err | |
| } | |
| if v3.err != nil { | |
| // /v3 失败降级:不拖累企业端点结果(降级仅企业端点 + warn)。 | |
| log.Printf("WARN: [upstream] global models: v3/config probe failed (degraded to enterprise endpoint): %v", v3.err) | |
| names, infos, efforts, defaults = extractEfforts(enterprise.infos) | |
| return names, infos, efforts, defaults, nil | |
| } | |
| if enterprise.err != nil { | |
| log.Printf("WARN: [upstream] global models: enterprise endpoint failed (v3/config only): %v", enterprise.err) | |
| names, infos, efforts, defaults = extractEfforts(v3.infos) | |
| return names, infos, efforts, defaults, nil | |
| } | |
| // 两路皆成功:v3 为主、企业端点补缺合并(含 effort 桶合并,v3 权威)。 | |
| v3Names, v3Infos, v3Efforts, v3Defaults := extractEfforts(v3.infos) | |
| if len(v3Names) == 0 { | |
| v3Names = v3.names | |
| } | |
| entNames, entInfos, entEfforts, entDefaults := extractEfforts(enterprise.infos) | |
| if len(entNames) == 0 { | |
| entNames = enterprise.names | |
| } | |
| names, infos = mergeGlobalCatalog(v3Names, v3Infos, entNames, entInfos) | |
| efforts = mergeEffortBuckets(v3Efforts, entEfforts) | |
| defaults = mergeEffortDefaults(v3Defaults, entDefaults) | |
| return names, infos, efforts, defaults, nil | |
| } | |
| // probeResult 单路模型目录探测结果(/v3/config 某 UA 路,或企业端点家族)。 | |
| // infos 为对象形态条目(窄表探测为 nil),names 与之平行;err 非 nil 表示该路失败。 | |
| type probeResult struct { | |
| names []string | |
| infos []ModelInfo | |
| err error | |
| } | |
| // mergeV3Routes 按优先级合并 /v3/config 多路 UA 探测结果:routes 必须**按权威 | |
| // 顺序**传入(主路在前)。前序路的字段权威,后续路只补前序路缺失的模型 id | |
| // (条目字段仍取自其所属路——该 id 只有那一路知道)。 | |
| // | |
| // 容错:全路失败才返回 err(取首路错误,与既有「v3 全失败」降级语义一致); | |
| // 部分失败 warn 日志后用成功路继续,单路故障不拖垮整次探测。 | |
| // 全路成功但并集为空时保留空结果(由调用方按「空列表 = 失败」处理)。 | |
| func mergeV3Routes(routes []struct { | |
| label string | |
| res probeResult | |
| }) probeResult { | |
| var out probeResult | |
| var firstErr error | |
| nOK := 0 | |
| for _, r := range routes { | |
| if r.res.err != nil { | |
| log.Printf("WARN: [upstream] global models: v3/config %s probe failed: %v", r.label, r.res.err) | |
| if firstErr == nil { | |
| firstErr = r.res.err | |
| } | |
| continue | |
| } | |
| nOK++ | |
| if out.err != nil || (len(out.names) == 0 && len(out.infos) == 0) { | |
| // 首个成功路:整体作为基底(字段权威)。 | |
| out = r.res | |
| continue | |
| } | |
| // 后续成功路:只补缺失的 id。 | |
| mergedNames, mergedInfos := mergeGlobalCatalog(out.names, out.infos, r.res.names, r.res.infos) | |
| out.names, out.infos = mergedNames, mergedInfos | |
| } | |
| if nOK == 0 { | |
| return probeResult{err: firstErr} | |
| } | |
| return out | |
| } | |
| // extractEfforts 从条目列表抽取 effort 能力桶(supportedEfforts 数组优先; | |
| // 缺数组但 defaultEffort 单档非空也入 defaults 桶)并顺带返回有序 names。 | |
| func extractEfforts(infos []ModelInfo) (names []string, out []ModelInfo, efforts map[string][]string, defaults map[string]string) { | |
| names = make([]string, 0, len(infos)) | |
| for _, mi := range infos { | |
| if mi.ID == "" { | |
| continue | |
| } | |
| names = append(names, mi.ID) | |
| out = append(out, mi) | |
| if len(mi.Efforts) > 0 { | |
| if efforts == nil { | |
| efforts = make(map[string][]string) | |
| } | |
| efforts[mi.ID] = mi.Efforts | |
| } | |
| if mi.DefaultEffort != "" { | |
| if defaults == nil { | |
| defaults = make(map[string]string) | |
| } | |
| defaults[mi.ID] = mi.DefaultEffort | |
| } | |
| } | |
| return names, out, efforts, defaults | |
| } | |
| // mergeGlobalCatalog 两路合并(v3 主、企业补缺):names 按 id 去重(v3 原序在前、 | |
| // 企业端点补充项在其原序后追加——稳定输出);infos 同步合并(v3 条目字段权威, | |
| // 企业端点条目只在 id 缺失时进并集)。 | |
| // 窄表形态(infos nil)时保持 nil——无对象字段不编造。 | |
| func mergeGlobalCatalog(primaryNames []string, primaryInfos []ModelInfo, secondaryNames []string, secondaryInfos []ModelInfo) (names []string, infos []ModelInfo) { | |
| if len(secondaryNames) == 0 { | |
| return primaryNames, primaryInfos | |
| } | |
| seen := make(map[string]bool, len(primaryNames)+len(secondaryNames)) | |
| out := make([]string, 0, len(primaryNames)+len(secondaryNames)) | |
| for _, id := range primaryNames { | |
| if id == "" || seen[id] { | |
| continue | |
| } | |
| seen[id] = true | |
| out = append(out, id) | |
| } | |
| var outInfos []ModelInfo | |
| if primaryInfos != nil { | |
| outInfos = make([]ModelInfo, 0, len(primaryInfos)+len(secondaryInfos)) | |
| outInfos = append(outInfos, primaryInfos...) | |
| } | |
| for _, id := range secondaryNames { | |
| if id == "" || seen[id] { | |
| continue | |
| } | |
| seen[id] = true | |
| out = append(out, id) | |
| // 窄表企业响应(secondaryInfos nil / 超出条目数)时该 id 无对象字段, | |
| // infos 保持原样(调用方按 id 名单输出裸条目,不编造字段)。 | |
| for _, mi := range secondaryInfos { | |
| if mi.ID == id { | |
| outInfos = append(outInfos, mi) | |
| break | |
| } | |
| } | |
| } | |
| return out, outInfos | |
| } | |
| // mergeEffortBuckets 合并两路 effort 桶:主路(v3)权威,企业端点只补主路缺失的模型档位。 | |
| func mergeEffortBuckets(primary, secondary map[string][]string) map[string][]string { | |
| if len(secondary) == 0 { | |
| return primary | |
| } | |
| out := primary | |
| if out == nil { | |
| out = make(map[string][]string, len(secondary)) | |
| } | |
| for id, v := range secondary { | |
| if _, ok := out[id]; !ok { | |
| out[id] = v | |
| } | |
| } | |
| return out | |
| } | |
| // mergeEffortDefaults 合并两路 defaultEffort:主路(v3)权威,企业端点只补缺失。 | |
| func mergeEffortDefaults(primary, secondary map[string]string) map[string]string { | |
| if len(secondary) == 0 { | |
| return primary | |
| } | |
| out := primary | |
| if out == nil { | |
| out = make(map[string]string, len(secondary)) | |
| } | |
| for id, v := range secondary { | |
| if _, ok := out[id]; !ok { | |
| out[id] = v | |
| } | |
| } | |
| return out | |
| } | |
| // globalModelsOnce 单端点探测。2xx + 解析出非空名单 → (names, infos, nil);否则 (nil, nil, err)。 | |
| func (c *Client) globalModelsOnce(a *auth.Auth, path string) ([]string, []ModelInfo, error) { | |
| url := c.chatBase(a) + path // 按 realm 切 base:global 账号 → global base | |
| req, err := http.NewRequest(http.MethodGet, url, nil) | |
| if err != nil { | |
| return nil, nil, err | |
| } | |
| c.CommonHeaders(req, a) // 共享请求头(Origin/Referer/UA),与 FetchModels 同款 | |
| // AccessToken 加锁快照(见 auth.AccessTokenValue:keepalive 刷新在 a.mu 内改写)。 | |
| req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue()) | |
| resp, err := c.HTTP.Do(req) | |
| if err != nil { | |
| return nil, nil, err | |
| } | |
| defer resp.Body.Close() | |
| raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) | |
| if err != nil { | |
| // 读失败 → 传输层错误:半截 body 不进解析(探测负缓存走 lastFail,不罚号)。 | |
| return nil, nil, fmt.Errorf("read body: %w", err) | |
| } | |
| if resp.StatusCode != http.StatusOK { | |
| return nil, nil, fmt.Errorf("global models status %d: %s", resp.StatusCode, truncate(string(raw), 120)) | |
| } | |
| names, infos, _, _, err := parseGlobalModelNames(raw) | |
| return names, infos, err | |
| } | |
| // parseGlobalModelNames 多信封兼容解析模型目录(国际站曾在多种 envelope 间切换, | |
| // 只认单一形态会把登录成功的账号误判为"无模型"): | |
| // - 信封:code 存在且非 0 才拒(无 code 字段也放行);payload = data(非空非 | |
| // null 时)否则整包; | |
| // - 模型数组定位:payload 本身是数组,或是 map 下 models/items/list/data/result | |
| // 任一键(递归一层层往下找第一个非空数组)——覆盖 data.models / data.items / | |
| // data.list / 顶层 models 等变体; | |
| // - 数组主形态:对象数组按 dynModelEntry 全字段解析(与 CN 目录同构,字段零 | |
| // 漂移):maxInputTokens/maxOutputTokens/maxAllowedSize/supportsReasoning/ | |
| // supportsImages/reasoning.*;id 缺省时回退 name;disabled 剔除; | |
| // - 窄表:字符串数组 → 仅 ID,元数据留空(窗口由调用方四级查找链兜底); | |
| // - 动态兜底:主形态解析不出任何可用条目时(上游换了键名),逐对象宽松解析—— | |
| // id 依次回退 id/modelId/model/name,窗口键回退 contextWindow/maxTokens, | |
| // reasoning 档位(defaultEffort 新键优先、effort 老键兜底)保留。 | |
| // | |
| // 同时产出 effort 能力桶(supportedEfforts/defaultEffort)。 | |
| // 解析成功但名单为空 → 返回错误(等价"该端点没给全")。 | |
| func parseGlobalModelNames(raw []byte) (names []string, infos []ModelInfo, efforts map[string][]string, defaults map[string]string, err error) { | |
| fail := func(e error) ([]string, []ModelInfo, map[string][]string, map[string]string, error) { | |
| return nil, nil, nil, nil, e | |
| } | |
| // 信封层:code 拒绝非 0 业务码(字段缺失 = 放行,兼容无信封直出的目录端点)。 | |
| var envelope map[string]json.RawMessage | |
| if err := json.Unmarshal(raw, &envelope); err != nil { | |
| return fail(fmt.Errorf("global models parse: %w", err)) | |
| } | |
| if codeRaw, ok := envelope["code"]; ok { | |
| var code int | |
| if json.Unmarshal(codeRaw, &code) == nil && code != 0 { | |
| return fail(fmt.Errorf("global models code=%d", code)) | |
| } | |
| } | |
| payload := json.RawMessage(raw) | |
| if data, ok := envelope["data"]; ok { | |
| if t := strings.TrimSpace(string(data)); t != "" && t != "null" { | |
| payload = data | |
| } | |
| } | |
| arr, ok := resolveGlobalModelsArray(payload) | |
| if !ok { | |
| return fail(fmt.Errorf("global models empty list")) | |
| } | |
| // 主形态:对象数组 → dynModelEntry 全字段(与 CN FetchModels 共用解析,零口径漂移)。 | |
| var entries []dynModelEntry | |
| if json.Unmarshal(arr, &entries) == nil { | |
| out := make([]string, 0, len(entries)) | |
| objInfos := make([]ModelInfo, 0, len(entries)) | |
| for _, m := range entries { | |
| // id 回退链 id→modelId→model→name(宽松键仅出现在 global 目录变体里)。 | |
| id := m.ID | |
| if id == "" { | |
| id = m.ModelID | |
| } | |
| if id == "" { | |
| id = m.Model | |
| } | |
| if id == "" { | |
| id = m.Name | |
| } | |
| if id == "" || m.Disabled { | |
| continue | |
| } | |
| out = append(out, id) | |
| mi := m.modelInfo() | |
| mi.ID = id // name 兜底形态下 id 取自 name,对齐 names 输出 | |
| objInfos = append(objInfos, mi) | |
| if len(m.Reasoning.SupportedEfforts) > 0 { | |
| if efforts == nil { | |
| efforts = make(map[string][]string) | |
| } | |
| efforts[id] = m.Reasoning.SupportedEfforts | |
| } | |
| if d := m.Reasoning.DefaultEffort; d != "" { | |
| if defaults == nil { | |
| defaults = make(map[string]string) | |
| } | |
| defaults[id] = d | |
| } | |
| } | |
| if len(out) > 0 { | |
| return out, objInfos, efforts, defaults, nil | |
| } | |
| // 可用条目为 0(键名全对不上):落入下方动态兜底,不在这里报空。 | |
| efforts, defaults = nil, nil | |
| } | |
| // 窄表:字符串数组 → 仅 ID(无 effort 元数据、无对象字段 → infos nil)。 | |
| var strs []string | |
| if json.Unmarshal(arr, &strs) == nil { | |
| out := make([]string, 0, len(strs)) | |
| for _, id := range strs { | |
| if id = strings.TrimSpace(id); id != "" { | |
| out = append(out, id) | |
| } | |
| } | |
| if len(out) > 0 { | |
| return out, nil, nil, nil, nil | |
| } | |
| return fail(fmt.Errorf("global models empty list")) | |
| } | |
| // 动态兜底:逐对象宽松解析(上游换键名时不至于整域空列表)。 | |
| var items []any | |
| if json.Unmarshal(arr, &items) != nil { | |
| return fail(fmt.Errorf("global models parse: unsupported payload shape")) | |
| } | |
| out := make([]string, 0, len(items)) | |
| infos = make([]ModelInfo, 0, len(items)) | |
| for _, item := range items { | |
| switch v := item.(type) { | |
| case string: // 混合数组里的裸 ID:按窄表条目处理 | |
| if id := strings.TrimSpace(v); id != "" { | |
| out = append(out, id) | |
| infos = append(infos, ModelInfo{ID: id}) | |
| } | |
| case map[string]any: | |
| mi, ok := parseGlobalModelLoose(v) | |
| if !ok { | |
| continue | |
| } | |
| out = append(out, mi.ID) | |
| infos = append(infos, mi) | |
| if len(mi.Efforts) > 0 { | |
| if efforts == nil { | |
| efforts = make(map[string][]string) | |
| } | |
| efforts[mi.ID] = mi.Efforts | |
| } | |
| if mi.DefaultEffort != "" { | |
| if defaults == nil { | |
| defaults = make(map[string]string) | |
| } | |
| defaults[mi.ID] = mi.DefaultEffort | |
| } | |
| } | |
| } | |
| if len(out) == 0 { | |
| return fail(fmt.Errorf("global models empty list")) | |
| } | |
| return out, infos, efforts, defaults, nil | |
| } | |
| // resolveGlobalModelsArray 从 payload 定位模型数组:payload 本身是数组直接用; | |
| // 是 map 则依次尝试 models/items/list/data/result 键(递归下钻,取第一个能解析出 | |
| // 数组的分支)。找不到数组 → false。 | |
| func resolveGlobalModelsArray(payload json.RawMessage) (json.RawMessage, bool) { | |
| trimmed := strings.TrimSpace(string(payload)) | |
| if strings.HasPrefix(trimmed, "[") { | |
| return payload, true | |
| } | |
| var obj map[string]json.RawMessage | |
| if json.Unmarshal(payload, &obj) != nil { | |
| return nil, false | |
| } | |
| for _, key := range []string{"models", "items", "list", "data", "result"} { | |
| if nested, ok := obj[key]; ok { | |
| if arr, ok2 := resolveGlobalModelsArray(nested); ok2 { | |
| return arr, true | |
| } | |
| } | |
| } | |
| return nil, false | |
| } | |
| // parseGlobalModelLoose 单模型对象的宽松解析(多信封兜底路径):id 依次回退 | |
| // id/modelId/model/name;窗口/上限键回退 contextWindow/maxTokens;reasoning 档位 | |
| // defaultEffort 新键优先、effort 老键兜底。disabled 剔除。Credits 恒不解析 | |
| // (PLAN §3.D2:倍率不进 global 路径)。 | |
| func parseGlobalModelLoose(obj map[string]any) (ModelInfo, bool) { | |
| str := func(key string) string { | |
| s, _ := obj[key].(string) | |
| return strings.TrimSpace(s) | |
| } | |
| id := str("id") | |
| for _, k := range []string{"modelId", "model", "name"} { | |
| if id != "" { | |
| break | |
| } | |
| id = str(k) | |
| } | |
| if id == "" { | |
| return ModelInfo{}, false | |
| } | |
| if disabled, ok := obj["disabled"].(bool); ok && disabled { | |
| return ModelInfo{}, false | |
| } | |
| num := func(keys ...string) int64 { | |
| for _, k := range keys { | |
| if n, ok := obj[k].(float64); ok && n > 0 { | |
| return int64(n) | |
| } | |
| } | |
| return 0 | |
| } | |
| mi := ModelInfo{ | |
| ID: id, | |
| Name: str("name"), | |
| ContextWindow: num("maxInputTokens", "contextWindow"), | |
| MaxTokens: num("maxOutputTokens", "maxTokens"), | |
| MaxAllowedSize: num("maxAllowedSize"), | |
| } | |
| mi.SupportsReasoning, _ = obj["supportsReasoning"].(bool) | |
| mi.SupportsImages, _ = obj["supportsImages"].(bool) | |
| if r, ok := obj["reasoning"].(map[string]any); ok { | |
| reasonStr := func(key string) string { | |
| s, _ := r[key].(string) | |
| return strings.TrimSpace(s) | |
| } | |
| mi.DefaultEffort = reasonStr("defaultEffort") | |
| if mi.DefaultEffort == "" { | |
| mi.DefaultEffort = reasonStr("effort") // 老模型键兜底,与 CN 侧同款 | |
| } | |
| mi.CanDisableThinking, _ = r["canDisableThinking"].(bool) | |
| if arr, ok := r["supportedEfforts"].([]any); ok { | |
| for _, item := range arr { | |
| if s, ok := item.(string); ok { | |
| if s = strings.TrimSpace(s); s != "" { | |
| mi.Efforts = append(mi.Efforts, s) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| return mi, true | |
| } | |