File size: 15,759 Bytes
6d60378 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 | // modelsdev.go models.dev 按需兜底(context_length 四级查找链的第 4 级)。
//
// 定位:只对「上游动态值缺失 + 静态种子表未收录 + model.json 未缓存」的模型查
// models.dev,是兜底的兜底——超时短(默认 5s)、失败静默降级(context_length 落
// DefaultContextWindow=1M),绝不阻塞 /v1/models 主路径(查找异步化,本次请求
// 直接返回兜底值,拉到后写 model.json 供下次命中)。
//
// 数据源(2026-09-16 逆向,结论详见 .claude/reports/model-json-dynamic.md):
// - 官方聚合 JSON 端点 https://models.dev/api.json:~4.7MB 单文档、217 provider、
// 免鉴权、Cloudflare 托管静态站;
// - 无按模型/按 provider 子端点(/z-ai.json 等 302 回 /),「按需」的实现是
// 单次拉全量文档 + 建裸 id 索引(拉一次只发生一次,此后进程内复用索引);
// - schema:{ "<provider>": { "models": { "<id>": { "limit": {"context": N,
// "output": N} } } } },模型 id 有裸名(glm-5.2)与带命名空间(openai/gpt-5.5)
// 两种形态,均取尾段做索引 key;
// - 多 provider 同名值会分歧(聚合网关常自报改动):vendor 官方源(zai/
// moonshotai/openai/google/deepseek/minimax)优先,其余取众数(共识值)。
package upstream
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"sort"
"strings"
"sync"
"time"
)
// ModelsDevURL models.dev 官方聚合 JSON 端点(唯一端点,见文件头逆向结论)。
const ModelsDevURL = "https://models.dev/api.json"
// modelsDevTimeout 单次拉取超时:兜底的兜底,不值得等(任务书 §2:如 5s)。
const modelsDevTimeout = 5 * time.Second
// modelsDevFetchCooldown 拉取节流(进程级):文档是全量聚合体,5min 内不重拉
// (同模型 24h 负缓存之外的整体节流,防短窗反复打 models.dev)。
const modelsDevFetchCooldown = 5 * time.Minute
// modelsDevNegativeTTL 同模型负缓存:查不到的模型 24h 内不重查
// (任务书 §2:如同模型 24h 内不重查,查不到的模型负缓存防反复打)。
const modelsDevNegativeTTL = 24 * time.Hour
// modelsDevNegativesSoftCap 负缓存 map 的软上限:规模超过它时才做一次过期条目淘汰
// 扫描。分批摊销是为避免每次 lookup 都做 O(n) 全扫——第 4 级触发点在 /v1/models 里
// 每个模型各调一次(handler 遍历模型列表逐条 V4),n 大时全扫会被请求数放大成 CPU
// 开销。淘汰只针对 TTL 已过期的条目,故低于上限时不扫也不会让任何有效条目过期失效。
//
// 注意「只扫过期条目」不足以保证有界:TTL 从**首次未命中**起算(见 lookup 的写回
// 判断),若模型名持续出现在 /v1/models 名单里,一个 TTL 窗口内所有条目都会被反复
// 盖章成 fresh,扫描一条也删不掉,规模只增不减。因此超过**硬上限**(两倍软上限)
// 时额外丢弃最旧的条目——负缓存是纯性能优化(miss 时多查一次进程内索引),丢条目
// 只可能让某个模型重新进一次扫描,不改变任何对外语义。
const modelsDevNegativesSoftCap = 1024
// modelsDevNegativesHardCap 负缓存 map 的硬上限:超过即按时间序丢弃最旧条目
// (见 modelsDevNegativesSoftCap 注释)。取两倍软上限,给「一轮 /v1/models 新增」
// 留出余量,正常规模远达不到。
const modelsDevNegativesHardCap = 2 * modelsDevNegativesSoftCap
// modelsDevMaxBody 拉取响应体上限(文档实测 ~4.7MB,留余量;防异常大响应拖死)。
const modelsDevMaxBody = 32 << 20
// modelsDevValueMax 值校验上限:context/output 超过 1e9 视为脏数据拒绝
// (量级上限校验,任务书 §2 值校验;正数下界在 catalog 写入侧兜底)。
const modelsDevValueMax = int64(1e9)
// modelsDevVendorSources 官方 vendor provider 优先名单:models.dev 收录 217 个
// provider,聚合网关(merge-gateway/nano-gpt 等)自报的 limit 常与官方源分歧,
// 采值优先级 = 本名单命中 > 众数共识。
var modelsDevVendorSources = map[string]bool{
"zai": true, // Z.AI(glm 家族官方)
"moonshotai": true, // Moonshot AI(kimi 家族官方,国际版)
"moonshotai-cn": true, // Moonshot AI 中国版
"openai": true,
"google": true,
"deepseek": true,
"minimax": true,
}
// modelsDevEntry models.dev 单模型采值结果(modelsdev json 的 limit 子集)。
type modelsDevEntry struct {
Context int64
Output int64
}
// modelsDevFetcher models.dev 按需拉取器:进程级单例语义(包级变量 modelsDev),
// 拉取节流 + 裸 id 索引缓存 + 同模型负缓存。测试用 resetModelsDev / 独立 base URL
// 注入隔离(newModelsDevForTest)。
type modelsDevFetcher struct {
mu sync.Mutex
// doc 文档解析后的裸 id 索引(多 provider 同名合并采值:vendor 优先/众数)。
// nil = 未拉取;空 map(非 nil)= 拉取过但索引为空(视作失败冷却)。
doc map[string]modelsDevEntry
lastFetch time.Time // 最近一次拉取尝试(成功与失败都算,冷却节流)
fetched bool // 是否已拉取过(doc 字段区分成败)
negatives map[string]time.Time // 查询未命中的模型 → 记录时间(24h 负缓存)
}
// modelsDev 包级拉取器实例(单例:全进程共享一份文档索引与节流状态)。
var modelsDev = &modelsDevFetcher{}
// resetModelsDev 测试隔离:清空单例状态(doc/fetched/lastFetch/negatives)。
func resetModelsDev() {
modelsDev.mu.Lock()
modelsDev.doc = nil
modelsDev.fetched = false
modelsDev.lastFetch = time.Time{}
modelsDev.negatives = nil
modelsDev.mu.Unlock()
}
// lookup 查询一个模型的 (context, output, found):
// - 索引命中且值合法 → found=true;
// - 索引未命中(含索引尚未就绪)→ 记入 negatives(既是 24h 负缓存,也是
// fetchDoc 成功后 backfillMisses 的回流清单——「曾 miss 过的模型」),found=false。
//
// 只读内存索引,不发网络请求;网络动作由 ensureDocAsync(goroutine 内)负责。
// 注意:doc 就绪前的 miss 也记 negatives——backfillMisses 回流时查到即写
// model.json 并清除负缓存条目(freshLookup),查不到的保持 24h 负缓存。
func (f *modelsDevFetcher) lookup(model string) (modelsDevEntry, bool) {
f.mu.Lock()
defer f.mu.Unlock()
if f.doc != nil {
if e, ok := f.doc[model]; ok {
return e, true
}
}
// 未命中(索引在但模型不在,或索引尚未就绪):记 miss。
if f.negatives == nil {
f.negatives = make(map[string]time.Time)
}
now := time.Now()
// 未命中「重复查询」不再刷新记录时刻:TTL 只从**首次未命中**起算。
// 否则覆盖写等于每次查询都把条目续期——一个持续出现在 /v1/models 名单里的
// 未知模型(每条 listing 经 ContextWindowListingV4 + MaxOutputTokensListingV4
// 各查一次,即每请求 2 次写回)其条目永远 fresh,下面的惰性淘汰一条也扫不掉;
// 又因淘汰只在超软上限时才扫,规模超限后只增不减(进程生命周期内无界增长)。
// 续期对行为零影响:negativeFresh 只判是否存在 + within TTL,未过 TTL 的条目
// 无论是否续期都同样短路第 4 级触发。
if _, seen := f.negatives[model]; !seen {
f.negatives[model] = now
}
// 惰性淘汰已过期的负缓存条目:TTL 到期后 negativeFresh 本就判 false(等效不存在),
// 条目继续留着只是内存泄漏——models.dev 永不收录的模型名(model 由客户端任意指定)
// 查一次就永久驻留,而全库唯一的删除点 backfillMisses 只删「文档里查到」的模型,
// 永远不会回收这些条目,map 在进程生命周期内无界增长。
// 仅在规模超软上限时才扫一遍(摊销 O(1),理由见 modelsDevNegativesSoftCap 注释)。
if len(f.negatives) > modelsDevNegativesSoftCap {
for m, t := range f.negatives {
if now.Sub(t) >= modelsDevNegativeTTL {
delete(f.negatives, m)
}
}
}
// 硬上限兜底:TTL 未到期的条目本就无可淘汰(上一轮扫描已删净过期项),若规模
// 仍超硬上限说明输入模型名太多,按时间序丢弃最旧条目、削回软上限
// (纯性能优化:负缓存 miss 只会多查一次进程内索引,丢条目无语义影响)。
if len(f.negatives) > modelsDevNegativesHardCap {
cut := len(f.negatives) - modelsDevNegativesSoftCap
oldest := make([]string, 0, len(f.negatives))
for m := range f.negatives {
oldest = append(oldest, m)
}
sort.Slice(oldest, func(i, j int) bool { return f.negatives[oldest[i]].Before(f.negatives[oldest[j]]) })
for _, m := range oldest[:cut] {
delete(f.negatives, m)
}
}
return modelsDevEntry{}, false
}
// negativeFresh 模型是否在负缓存有效期内(供查找链短路第 4 级触发)。
func (f *modelsDevFetcher) negativeFresh(model string) bool {
f.mu.Lock()
defer f.mu.Unlock()
t, ok := f.negatives[model]
return ok && time.Since(t) < modelsDevNegativeTTL
}
// ensureDocAsync 确保 models.dev 文档索引可用(异步,不阻塞调用方):
// 已有索引 / 拉取冷却期内 / 已有人在拉(in-flight 去重)→ 直接返回。
// 否则起 goroutine 拉取解析,完成后落 f.doc(失败静默:只刷新 lastFetch 冷却,
// 下次查找仍走 1M 兜底,不重试风暴)。
func (f *modelsDevFetcher) ensureDocAsync(client *http.Client, baseOverride string) {
f.mu.Lock()
if f.doc != nil {
f.mu.Unlock()
return
}
if f.fetched && time.Since(f.lastFetch) < modelsDevFetchCooldown {
// 拉取过(成功或失败)且冷却期内:不再打 models.dev。
f.mu.Unlock()
return
}
// in-flight 去重:把 fetched/lastFetch 先置为「本次进行中」,
// 后续并发调用在冷却窗口内直接返回,不重复起拉取。
f.fetched = true
f.lastFetch = time.Now()
f.mu.Unlock()
go f.fetchDoc(client, baseOverride)
}
// fetchDoc 拉取并解析 models.dev 文档,建裸 id 索引(goroutine 内执行,永不 panic
// 上抛:任何失败只静默冷却)。
// 拒绝 nil client(不回落 http.DefaultClient):生产调用方恒传非 nil,nil 只意味着
// 测试疏漏——DefaultClient 无超时(挂起隐患)且会打真网(测试污染 + 不确定延迟),
// 静默 WARN + 返回(与 fetch 失败同语义,降级 1M 兜底)让疏漏显式化。
func (f *modelsDevFetcher) fetchDoc(client *http.Client, baseOverride string) {
if client == nil {
log.Printf("WARN: [upstream] models.dev fetch: nil client rejected (no DefaultClient fallback, silent fallback to 1M)")
return
}
url := ModelsDevURL
if baseOverride != "" {
url = baseOverride
}
ctx, cancel := context.WithTimeout(context.Background(), modelsDevTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
log.Printf("WARN: [upstream] models.dev fetch: build request: %v", err)
return
}
resp, err := client.Do(req)
if err != nil {
log.Printf("WARN: [upstream] models.dev fetch failed (silent fallback to 1M): %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("WARN: [upstream] models.dev fetch status %d (silent fallback to 1M)", resp.StatusCode)
return
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, modelsDevMaxBody))
if err != nil {
log.Printf("WARN: [upstream] models.dev fetch read: %v", err)
return
}
doc, err := parseModelsDevDoc(raw)
if err != nil {
log.Printf("WARN: [upstream] models.dev parse failed (silent fallback to 1M): %v", err)
return
}
f.mu.Lock()
f.doc = doc
f.mu.Unlock()
// 文档就绪后把「曾 miss 过的模型」回流 model.json(第 4 级 → 第 3 级,
// 下次 /v1/models 直接命中缓存)。仍查不到的保持负缓存。
f.backfillMisses()
}
// parseModelsDevDoc 解析 models.dev api.json:{provider:{models:{id:{limit:{context,
// output}}}}} → 裸 id 索引。同名多 provider 采值优先级四级:官方 vendor 源
// (modelsDevVendorSources)> 票数众数 > provider 字典序 > 先出现。
// 第 3 级 provider 字典序是确定性 tie-break:聚合时维护候选的最小 provider 名
// (minProvider,同 doc 稳定的选择器身份),消灭 map 迭代序随机化导致的
// 「同票先到先得」值抖动(同 binary 两次拉取同一文档可能落不同的值进 model.json,
// /v1/models 的 context_length 不可复现)。不引入「值字典序」——那会把
// 「选谁」变成「选什么值」的启发式,语义不如 provider 名干净。
func parseModelsDevDoc(raw []byte) (map[string]modelsDevEntry, error) {
var doc map[string]struct {
Models map[string]struct {
Limit *struct {
Context int64 `json:"context"`
Output int64 `json:"output"`
} `json:"limit"`
} `json:"models"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("models.dev doc: %w", err)
}
// 同名 id 的候选值收集:vendorOfficial 标记官方源,votes 计众数,
// minProvider 维护该候选已见的最小 provider 名(tie-break 用)。
type candidate struct {
entry modelsDevEntry
vendor bool
votes int
aggKey string // 去重聚合 key(同值多 provider 只计票不重复存)
minProvider string
}
byModel := map[string][]candidate{}
for provider, pv := range doc {
for fullID, mv := range pv.Models {
if mv.Limit == nil {
continue
}
id := fullID
if i := strings.LastIndex(fullID, "/"); i >= 0 {
id = fullID[i+1:]
}
if id == "" {
continue
}
// 值校验(任务书 §2):正数 + 量级上限,脏值不进索引。
ctx, out := mv.Limit.Context, mv.Limit.Output
if ctx <= 0 || ctx > modelsDevValueMax {
continue
}
if out < 0 || out > modelsDevValueMax {
continue
}
key := fmt.Sprintf("%d/%d", ctx, out)
cs := byModel[id]
dup := false
for i := range cs {
if cs[i].aggKey == key {
cs[i].votes++
if modelsDevVendorSources[provider] {
cs[i].vendor = true
}
if provider < cs[i].minProvider {
cs[i].minProvider = provider
}
dup = true
break
}
}
if !dup {
byModel[id] = append(cs, candidate{
entry: modelsDevEntry{Context: ctx, Output: out},
vendor: modelsDevVendorSources[provider],
votes: 1,
aggKey: key,
minProvider: provider,
})
}
}
}
out := make(map[string]modelsDevEntry, len(byModel))
for id, cs := range byModel {
best := 0
for i, c := range cs {
// 优先级:官方 vendor 源 > 票数众数 > provider 字典序(tie-break 确定性)。
cur := cs[best]
better := false
if c.vendor && !cur.vendor {
better = true
} else if c.vendor == cur.vendor && c.votes > cur.votes {
better = true
} else if c.vendor == cur.vendor && c.votes == cur.votes && c.minProvider < cur.minProvider {
better = true
}
if better {
best = i
}
}
out[id] = cs[best].entry
}
return out, nil
}
|