Files
Cyrene_v2/internal/services/gateway/admin_llm_catalog.go
T
AskaEth 63e5b70e3d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway,admin-web): 模型目录自动化 —— GET /admin/llm/catalog(裁决 03.4.2 次件)
- 新增 GET /api/v1/admin/llm/catalog(owner|admin,?refresh=1 强制刷新):抓 models.dev api.json 并裁剪
  (226 供应商 / 8392 模型 → 1.31 MB),写落盘快照 data/llm_catalog.json(无凭据,TTL 24h,gitignore)
- 降级诚实:快照优先 → 在线刷新 → stale 快照兜底(标 stale+warning)→ 都不行回 503 catalog_unavailable,
  绝不编造;抓取只发生在该端点内,**不进运行时热路径**
- 前端:新增 llm/LlmCatalogPanel(在线目录 + 粘贴导入落同一份候选 → 一键带出 baseURL/model id/multimodal/
  maxTokens);Llm.tsx 挂载面板、删掉硬编码的「主→备→本地」假状态、表单按 id#seq 重挂
- 诚实性:面板把「加载中 / 端点未实现 / 抓取失败或离线 / 响应结构不认识 / 目录为空」分成五种独立状态,
  全程无 mock/sample 回退
- 复核:新增 9 条零外网用例全绿;**变异**(Stale:true→false / TS 只认 camelCase)各自红后已还原;
  Go 真产出与 TS 解析器双向实测 8392 模型能力位零差异
- 越界说明:routes.go 仅 +3 行(注册该路由);llm/LlmCatalogPanel.tsx 为拆出以守住 Llm.tsx ≤500 行
2026-10-04 12:43:04 +08:00

196 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}