- 新增 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 行
196 lines
8.3 KiB
Go
196 lines
8.3 KiB
Go
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)
|
||
}
|