package gateway import ( "compress/gzip" "context" "encoding/json" "net/http" "os" "path/filepath" "strconv" "strings" "time" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" ) // 模型目录(models.dev)的候选面(裁决 03.4.2 次件):管理面板的 // 「添加提供商 / 添加模型」不再要求主人手抄 baseURL 与模型名,而是从 // 目录里挑一个候选,自动带出 baseUrl / 模型 id / 能力位(多模态、输出上限)。 // // 三条边界(本文件即契约): // 1. **不进运行时热路径**:只有本端点会读外网目录,且只在本地快照缺失或 // 过期(或显式 ?refresh=1)时才真去抓。模型请求链路从不触碰它。 // 2. **离线兜底**:抓取结果落盘成快照(与 models.json 同目录),抓取失败时 // 用上次快照作答并标 stale=true,绝不编造候选。 // 3. **诚实失败**:既无快照又抓不到时返回 503 `catalog_unavailable`, // 面板据此提示改用「粘贴导入」,而不是静默显示空目录。 const ( // modelsDevCatalogURL 是裁决 03.4.2 指定的目录数据源。 modelsDevCatalogURL = "https://models.dev/api.json" // catalogCacheName 是目录快照缓存的文件名,与 models.json 同目录 // (data/ 已 gitignore;快照不含任何凭据)。 catalogCacheName = "llm_catalog.json" // catalogCacheTTL 之后再次打开面板会触发一次在线刷新。 catalogCacheTTL = 24 * time.Hour // catalogFetchTimeout 限定单次抓取;离线时快速失败,不拖住面板。 catalogFetchTimeout = 20 * time.Second // catalogMaxBytes 限制我们愿意读入的目录体积,防御异常响应。 catalogMaxBytes = 64 << 20 ) // AdminLlmCatalogModel 是目录里一个模型的能力位。面板据此预填 // multimodal 与 maxTokens,并把「目录怎么标」如实显示给主人。 type AdminLlmCatalogModel struct { ID string `json:"id"` Name string `json:"name"` // Attachment 为 true 表示该模型接受图片/文件输入(→ 面板的 multimodal)。 Attachment bool `json:"attachment"` Reasoning bool `json:"reasoning"` ToolCall bool `json:"toolCall"` // ContextLimit / OutputLimit 是该模型的上下文与输出上限(0 = 目录未标注)。 // OutputLimit 直接作为面板 maxTokens 的建议值(真值,不是猜测)。 ContextLimit int `json:"contextLimit,omitempty"` OutputLimit int `json:"outputLimit,omitempty"` } // AdminLlmCatalogProvider 是目录里的一个供应商:baseURL + 模型清单。 type AdminLlmCatalogProvider struct { ID string `json:"id"` Name string `json:"name"` // API 是该供应商的 baseURL;目录未标注时为空串,面板会如实提示需手填。 API string `json:"api,omitempty"` // Doc 是供应商文档地址,面板作为“去哪申请 key”的提示。 Doc string `json:"doc,omitempty"` // Env 是目录标注的官方 key 环境变量名,仅作提示(面板不写 .env)。 Env []string `json:"env,omitempty"` // NPM 是目录标注的 SDK 包名,面板据此推荐 wire 协议(anthropic 或 openai)。 NPM string `json:"npm,omitempty"` Models []AdminLlmCatalogModel `json:"models"` } // AdminLlmCatalog 是 GET /api/v1/admin/llm/catalog 的线上结构。 type AdminLlmCatalog struct { // Source 说明这份候选从哪来:`cache`(本地快照)或 `models.dev`(本次在线抓取)。 Source string `json:"source"` // FetchedAt 是快照的抓取时间(RFC3339);本地快照缺失时为空。 FetchedAt string `json:"fetchedAt,omitempty"` // Stale 为 true 表示本次在线刷新失败、返回的是上次快照(离线兜底)。 Stale bool `json:"stale,omitempty"` // Warning 只在 Stale 时给出人类可读的原因(不含凭据)。 Warning string `json:"warning,omitempty"` Providers []AdminLlmCatalogProvider `json:"providers"` } // adminGetLlmCatalog 返回面板的候选目录。?refresh=1 强制在线刷新; // 除此之外优先用本地快照,避免每次打开面板都打外网。 func (s *Server) adminGetLlmCatalog(w http.ResponseWriter, r *http.Request) { force := r.URL.Query().Get("refresh") == "1" cat, err := s.catalogServe(r.Context(), modelsDevCatalogFetcher(), force) if err != nil { s.auditClaims(r, "llm.catalog", "error") writeError(w, err) return } if cat.Stale { s.auditClaims(r, "llm.catalog", "stale") } else { s.auditClaims(r, "llm.catalog", "ok") } s.writeCatalogJSON(w, r, cat) } // writeCatalogJSON 写出 `{data:…}` 信封,并在客户端接受时 gzip 压缩: // 目录有 1MB 量级(226 供应商 / 8392 模型),每次打开面板都传整份明文 // 不划算。浏览器与 Go 客户端都会透明解压,契约(信封形状)不变。 func (s *Server) writeCatalogJSON(w http.ResponseWriter, r *http.Request, cat AdminLlmCatalog) { payload, err := json.Marshal(map[string]any{"data": cat}) if err != nil { writeError(w, apperr.Wrap(apperr.ErrInternal, "internal", "encode catalog: %v", err)) return } w.Header().Set("Content-Type", "application/json") if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { w.Header().Set("Content-Length", strconv.Itoa(len(payload))) w.WriteHeader(http.StatusOK) if _, err := w.Write(payload); err != nil { s.log.Warn("llm catalog response write failed", "error", err) } return } w.Header().Set("Content-Encoding", "gzip") w.WriteHeader(http.StatusOK) gz := gzip.NewWriter(w) // 响应头已发出,压缩中途出错只能如实记一条日志:无法再改回错误信封。 if _, err := gz.Write(payload); err != nil { s.log.Warn("llm catalog gzip write failed", "error", err) return } if err := gz.Close(); err != nil { s.log.Warn("llm catalog gzip close failed", "error", err) } } // catalogServe 是目录的决策核心(与 HTTP 解耦,便于不碰网络地测试): // 快照新鲜且未强制刷新 → 直接用快照;否则尝试在线抓取;抓取失败时有快照 // 就降级返回快照并标 stale,两者都没有才返回诚实的 catalog_unavailable。 func (s *Server) catalogServe( ctx context.Context, fetch func(context.Context) ([]AdminLlmCatalogProvider, error), force bool, ) (AdminLlmCatalog, error) { cachePath := s.llmCatalogCachePath() cached, fetchedAt, cacheErr := loadCatalogCache(cachePath) if cacheErr != nil { s.log.Warn("llm catalog cache unreadable", "path", cachePath, "error", cacheErr) } if cached != nil && !force && catalogFresh(fetchedAt, time.Now()) { return AdminLlmCatalog{Source: catalogSourceCache, FetchedAt: fetchedAt.Format(time.RFC3339), Providers: cached}, nil } fresh, err := fetch(ctx) if err != nil { if cached != nil { s.log.Warn("llm catalog refresh failed; serving cached snapshot", "path", cachePath, "error", err) return AdminLlmCatalog{ Source: catalogSourceCache, FetchedAt: fetchedAt.Format(time.RFC3339), Stale: true, Warning: "在线刷新失败,返回的是上次抓取的目录快照:" + err.Error(), Providers: cached, }, nil } s.log.Error("llm catalog unavailable", "url", modelsDevCatalogURL, "error", err) return AdminLlmCatalog{}, apperr.Wrap(apperr.ErrTimeout, "catalog_unavailable", "模型目录不可用(抓取失败且本地无快照):%s;可改用「粘贴导入」手工喂入目录", err.Error()) } if err := saveCatalogCache(cachePath, fresh); err != nil { s.log.Warn("llm catalog cache write failed", "path", cachePath, "error", err) } return AdminLlmCatalog{ Source: catalogSourceOnline, FetchedAt: time.Now().Format(time.RFC3339), Providers: fresh, }, nil } // catalogSourceCache / catalogSourceOnline 是 Source 字段的取值。 const ( catalogSourceCache = "cache" catalogSourceOnline = "models.dev" ) // llmCatalogCachePath 把快照放在 models.json 的同目录(data/ 已 gitignore), // 因此不需要给 Server 增加字段或配置项。LLM 栈未注入(管理端点回退到环境 // 配置)时退回系统临时目录,绝不往工作树里写文件。 func (s *Server) llmCatalogCachePath() string { if s.llm != nil { if p := strings.TrimSpace(s.llm.Path()); p != "" { if dir := filepath.Dir(p); dir != "" && dir != "." { return filepath.Join(dir, catalogCacheName) } } } return filepath.Join(os.TempDir(), catalogCacheName) }