gcli2api / internal /upstream /global_models.go
a3216's picture
chore: 同步到上游 1.12.0-panel + 凭证同步/独立启动器/保活
6d60378 verified
Raw History Blame Contribute Delete
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
}