File size: 12,578 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 | // model_catalog.go context_length / max_output_tokens 四级查找链 + model.json
// 本地缓存(任务书 model-json-dynamic)。
//
// 查找链(32a3c13 三级 → 四级,上游动态值永远权威不变):
// 1. 上游动态值(ModelInfo.ContextWindow/MaxTokens)——权威,永远压过 model.json
// (即使后者更新:上游才是权威,任务书 §清理);
// 2. 静态种子表(context_catalog.go 的 contextCapFallback,编译期兜底);
// 3. model.json 本地缓存(数据目录,含运行时从 models.dev 补的值 + 仓库种子
// embed 的初值);损坏 → 降级种子并 WARN 不崩溃(手动维护入口的容错);
// 4. 触发 models.dev 按需拉取(异步,不阻塞本次响应)→ 值写入 model.json →
// 本次先落 1M/省略,下次命中缓存;负缓存 24h。
//
// model.json 读写并发安全(单 sync.Mutex 全程持锁 + 原子落盘 tmp+rename,
// 多请求同时 miss 同一模型只写一次);文件损坏/不可写均静默降级(种子表→1M)。
//
// 状态归属:包级 catalogState 单例(与 modelsDev fetcher 同模式,进程一份)。
// 测试用 resetModelCatalog / loadModelCatalogAt 隔离。
package upstream
import (
"embed"
"encoding/json"
"log"
"net/http"
"os"
"path/filepath"
"sync"
"time"
)
// modelSeedFS 仓库种子版 model.json(context_catalog 静态表 27 值迁移,source=seed)。
// 只作 model.json 缺失时的初值来源;运行目录的 model.json 一旦存在则以它为准
// (用户手动维护入口——直接编辑文件,格式容错由 loadModelCatalog 的校验兜底)。
//
//go:embed model.json
var modelSeedFS embed.FS
// ModelCapEntry model.json 单条目(与静态表同字段口径 + 来源与抓取时间)。
// Source:seed(仓库种子迁移)/ modelsdev(运行时按需拉取)/ manual(用户手编
// ——无法区分手编与 seed,手编条目保留其原 source 字符串,语义等同「非拉取」)。
// Context 必须 >0(零/负条目校验拒绝);MaxOutput 0 = 输出上限未知 → 省略字段。
type ModelCapEntry struct {
ContextLength int64 `json:"context_length"`
MaxOutputTokens int64 `json:"max_output_tokens,omitempty"`
FetchedAt string `json:"fetched_at,omitempty"` // RFC3339;seed 条目为空
Source string `json:"source"`
}
// modelCatalog model.json 缓存状态机(包级 catalogState 单例的字段载体)。
type modelCatalog struct {
mu sync.Mutex
path string // 落盘路径(空 = 禁用持久化:纯内存 + 种子)
entries map[string]ModelCapEntry
loaded bool // entries 已初始化(含损坏降级形态)
}
// catalogState 包级单例:全进程一份 model.json 状态。
var catalogState = &modelCatalog{}
// initModelCatalogLocked 确保 entries 已加载(持锁调用):
// - 运行目录 model.json 存在且合法 → 全量加载(校验失败的条目剔除并 WARN);
// - 文件不存在 / 整体损坏(非法 JSON)→ 种子 embed 初值(不落盘:保持「用户
// 尚无缓存」状态,首次拉取成功后再落盘);
// - path 为空(未接线,如测试/工具进程)→ 种子初值。
func (c *modelCatalog) initLocked() {
if c.loaded {
return
}
c.entries = map[string]ModelCapEntry{}
c.loaded = true
if c.path == "" {
c.loadSeedLocked()
return
}
raw, err := os.ReadFile(c.path)
if err != nil {
// 不存在 / 不可读 → 种子(首次启动形态)。
c.loadSeedLocked()
return
}
var file map[string]ModelCapEntry
if err := json.Unmarshal(raw, &file); err != nil {
// 整体损坏:降级种子 + WARN 不崩溃(任务书 §手动维护入口容错)。
log.Printf("WARN: [upstream] model.json 损坏(降级内置种子表): path=%s err=%v", c.path, err)
c.loadSeedLocked()
return
}
for id, e := range file {
if !validCapEntry(e) {
log.Printf("WARN: [upstream] model.json 条目非法剔除: model=%s entry=%+v", id, e)
continue
}
c.entries[id] = e
}
}
// loadSeedLocked 把仓库种子 model.json 灌入 entries(持锁调用)。
func (c *modelCatalog) loadSeedLocked() {
raw, err := modelSeedFS.ReadFile("model.json")
if err != nil {
// embed 编译期保证存在,理论不可达;防御性兜底走静态表(initLocked 调用方
// 查找链第 2 级本来就会兜,这里只需保持 entries 为空)。
return
}
var seed map[string]ModelCapEntry
if err := json.Unmarshal(raw, &seed); err != nil {
return
}
for id, e := range seed {
if validCapEntry(e) {
c.entries[id] = e
}
}
}
// validCapEntry 条目级校验:context 正数 + 输出非负(任务书 §值校验的写入侧)。
func validCapEntry(e ModelCapEntry) bool {
return e.ContextLength > 0 && e.MaxOutputTokens >= 0
}
// get 查 model.json 缓存(第 3 级)。返回条目与是否命中。
// 只读内存,不发网络;加载与网络动作由 ensure/触发侧负责。
func (c *modelCatalog) get(model string) (ModelCapEntry, bool) {
c.mu.Lock()
defer c.mu.Unlock()
c.initLocked()
e, ok := c.entries[model]
if !ok || !validCapEntry(e) {
return ModelCapEntry{}, false
}
return e, true
}
// put 写一条缓存(第 4 级拉取成功后调用)并落盘。同模型已存在(用户手动维护过)
// 仍覆盖——运行时拉取值采信 models.dev 官方源优先口径,比手编更可信;
// 若不希望覆盖,删掉 model.json 里对应条目即可(加载后手编值只在本次进程生效)。
func (c *modelCatalog) put(model string, e ModelCapEntry) {
if model == "" || !validCapEntry(e) {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.initLocked()
c.entries[model] = e
c.saveLocked()
}
// saveLocked 原子落盘(tmp + rename,pool state.json 同模式)。持锁调用。
// path 为空 / 目录不可写 / 序列化失败 → 静默(内存缓存仍生效,下次进程重拉)。
func (c *modelCatalog) saveLocked() {
if c.path == "" {
return
}
raw, err := json.MarshalIndent(c.entries, "", " ")
if err != nil {
return
}
if dir := filepath.Dir(c.path); dir != "" {
_ = os.MkdirAll(dir, 0o755)
}
tmp := c.path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
log.Printf("WARN: [upstream] model.json 落盘失败(内存缓存仍生效): path=%s err=%v", c.path, err)
return
}
if err := os.Rename(tmp, c.path); err != nil {
log.Printf("WARN: [upstream] model.json 落盘改名失败: path=%s err=%v", c.path, err)
}
}
// ---- 包级 API(查找链 3/4 级 + 接线)----
// SetModelCatalogPath 接线 model.json 落盘路径(cmd/server 启动时调用,
// 数据目录与 state.json 同风格)。首次调用生效;后续调用在已加载后仅更新路径
// (不重载——进程内以内存 entries 为准)。
func SetModelCatalogPath(path string) {
c := catalogState
c.mu.Lock()
defer c.mu.Unlock()
c.path = path
}
// modelCatalogGet 第 3 级:model.json 缓存命中(内含种子初值与损坏降级)。
func modelCatalogGet(model string) (ModelCapEntry, bool) {
return catalogState.get(model)
}
// modelCatalogPut 第 4 级写入:models.dev 拉到的值落缓存(含落盘)。
func modelCatalogPut(model string, context, maxOutput int64) {
modelCatalogPutSourced(model, context, maxOutput, "modelsdev")
}
// modelCatalogPutSourced 写入指定来源的条目(测试可注入 fetched_at 检查落盘格式)。
func modelCatalogPutSourced(model string, context, maxOutput int64, source string) {
catalogState.put(model, ModelCapEntry{
ContextLength: context,
MaxOutputTokens: maxOutput,
FetchedAt: time.Now().UTC().Format(time.RFC3339),
Source: source,
})
}
// resetModelCatalog 测试隔离:清空单例状态(entries/path/loaded)。
func resetModelCatalog() {
c := catalogState
c.mu.Lock()
defer c.mu.Unlock()
c.entries = nil
c.path = ""
c.loaded = false
}
// loadModelCatalogAt 测试接线:指向指定路径后立即触发一次加载(同步,可断言文件
// 解析行为)。生产路径用 SetModelCatalogPath(惰性首次 get 触发加载)。
func loadModelCatalogAt(path string) {
SetModelCatalogPath(path)
catalogState.mu.Lock()
defer catalogState.mu.Unlock()
catalogState.initLocked()
}
// ResetLookupChainForTest 跨包测试钩子:清空 model.json 缓存与 models.dev fetcher
// 的全部包级单例状态(含在途拉取冷却——防 server 包测试末尾的异步 goroutine 打
// 真网、防跨测试缓存污染)。仅测试引用(upstream 包内用 resetModelsDev /
// resetModelCatalog 等价内联)。
func ResetLookupChainForTest() {
resetModelCatalog()
resetModelsDev()
}
// ---- 四级查找链(对 handler 暴露的入口,签名与 32a3c13 三级版兼容)----
// ContextWindowListingV4 四级查找链的 context_length 决策:
// 1. remote>0 权威透出(上游动态值永远压过 model.json,任务书 §清理);
// 2. 静态种子表(contextCapFallback);
// 3. model.json 缓存(内含种子初值 / models.dev 运行时补充值);
// 4. 全链 miss 且非负缓存 → 异步触发 models.dev 拉取(本次返回 DefaultContextWindow
// 1M,不阻塞;拉到后写 model.json 供下次命中)。
func ContextWindowListingV4(model string, remote int64, client *http.Client) int64 {
if remote > 0 {
return remote
}
if model == "" {
return DefaultContextWindow
}
if cap, ok := contextCapFallback[model]; ok && cap.context > 0 {
return cap.context // 第 2 级:静态种子表(编译期兜底,永远可用)
}
if e, ok := modelCatalogGet(model); ok {
return e.ContextLength // 第 3 级:model.json
}
if !modelsDev.negativeFresh(model) {
// 第 4 级触发:先查进程内文档索引(拉过一次即常驻),命中直接入缓存
// 返回(不等待异步拉取);未命中 → 记负缓存 + 异步拉取(本次先回 1M)。
if e, ok := modelsDev.lookup(model); ok {
modelCatalogPut(model, e.Context, e.Output)
return e.Context
}
modelsDev.ensureDocAsync(client, "")
}
return DefaultContextWindow // 第 4 级兜底:1M(本次先回,拉到后下次命中)
}
// MaxOutputTokensListingV4 四级查找链的 max_output_tokens 决策(与 context 口径
// 刻意不同:未知 → 省略,无「宁可高估」安全侧)。
func MaxOutputTokensListingV4(model string, remote int64, client *http.Client) (int64, bool) {
if remote > 0 {
return remote, true
}
if model == "" {
return 0, false
}
if cap, ok := contextCapFallback[model]; ok && cap.maxOutput > 0 {
return cap.maxOutput, true // 第 2 级
}
if e, ok := modelCatalogGet(model); ok && e.MaxOutputTokens > 0 {
return e.MaxOutputTokens, true // 第 3 级
}
if !modelsDev.negativeFresh(model) {
// 与 ContextWindowListingV4 同触发:lookup 命中先入缓存再返回
// (两条查找链并发 miss 同一模型时,第二调用方直接拿到刚写入的值)。
if e, ok := modelsDev.lookup(model); ok {
modelCatalogPut(model, e.Context, e.Output)
return e.Output, e.Output > 0
}
modelsDev.ensureDocAsync(client, "")
}
return 0, false // 第 4 级兜底:省略(输出上限不编造)
}
// ---- 第 4 级拉取值的回流(fetchDoc 成功后调用,写 model.json)----
// noteModelsDevMiss 查询未命中 models.dev 索引 → 负缓存已由 lookup 记录。
// 本函数是 lookup + 写缓存的粘合层:fetchDoc 拉到文档后对「曾 miss 过的模型」
// 重查一次并写入 model.json(下次 /v1/models 直接命中第 3 级)。
func (f *modelsDevFetcher) backfillMisses() {
f.mu.Lock()
doc := f.doc
missed := make([]string, 0, len(f.negatives))
for m := range f.negatives {
missed = append(missed, m)
}
f.mu.Unlock()
if doc == nil {
return
}
for _, m := range missed {
e, ok := doc[m]
if !ok {
continue // 仍查不到:负缓存 24h 生效,不写
}
modelCatalogPut(m, e.Context, e.Output)
// 回流成功:清除负缓存条目(该模型已有值,后续走第 3 级缓存,
// 不再进本清单)。
f.mu.Lock()
delete(f.negatives, m)
f.mu.Unlock()
}
}
|