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()
	}
}