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