();
+ fetched.forEach((p) => byId.set(p.id, p));
+ pasted.forEach((p) => byId.set(p.id, p));
+ return [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
+}
+
+/** 从目录的 SDK 包名/供应商 id 推断 wire 协议:只认能明确判定的,
+ * 其余按 OpenAI 兼容(面板仍可手改成 anthropic)。 */
+function protocolFromCatalog(p: CatalogProvider): string {
+ return /anthropic/i.test(p.npm) || /anthropic/i.test(p.id) ? "anthropic" : "openai";
+}
+
+/** 目录候选 → 表单草稿。分组 id 取目录 id,重复填入即幂等更新同名分组;
+ * maxTokens 建议值取模型的输出上限(目录真值,没有任何猜测成分)。 */
+function draftFromCatalog(p: CatalogProvider, m: CatalogModel): AdminLlmProvider {
+ return {
+ id: p.id,
+ name: p.name,
+ provider: protocolFromCatalog(p),
+ baseUrl: p.api,
+ model: m.id,
+ multimodal: m.attachment,
+ enabled: false,
+ maxTokens: m.outputLimit || undefined,
+ };
+}
+
+/** 目录里的一行候选:能力位如实来自目录,点「填入表单」变成表单草稿。 */
+function CatalogModelRow({
+ provider,
+ model,
+ onPick,
+}: {
+ provider: CatalogProvider;
+ model: CatalogModel;
+ onPick: () => void;
+}): React.ReactNode {
+ return (
+
+ {model.name}
+ {model.id}
+ {model.attachment ? 多模态 : null}
+ {model.reasoning ? 推理 : null}
+ {model.toolCall ? 工具 : null}
+ {model.outputLimit ? {`输出上限 ${model.outputLimit}`} : null}
+ {provider.api ? null : 目录未标 baseURL}
+
+
+
+
+ );
+}
+
+/** 候选列表(按供应商 + 关键词过滤)。渲染条数有上限,超出部分用文字说明,
+ * 不做任何截断式伪数据。 */
+function CatalogList({
+ providers,
+ query,
+ providerId,
+ onPick,
+}: {
+ providers: CatalogProvider[];
+ query: string;
+ providerId: string;
+ onPick: (draft: AdminLlmProvider) => void;
+}): React.ReactNode {
+ const q = query.trim().toLowerCase();
+ const rows: { provider: CatalogProvider; model: CatalogModel }[] = [];
+ providers.forEach((provider) => {
+ if (providerId && provider.id !== providerId) return;
+ provider.models.forEach((model) => {
+ if (q && !`${provider.id} ${provider.name} ${model.id} ${model.name}`.toLowerCase().includes(q)) return;
+ rows.push({ provider, model });
+ });
+ });
+ if (rows.length === 0) {
+ return 没有匹配的模型:换个关键词,或清空供应商过滤。
;
+ }
+ const shown = rows.slice(0, CATALOG_MAX_ROWS);
+ return (
+
+ {shown.map(({ provider, model }) => (
+
onPick(draftFromCatalog(provider, model))}
+ />
+ ))}
+ {rows.length > shown.length ? (
+
+ 匹配 {rows.length} 个模型,已显示前 {shown.length} 个;用关键词缩小范围。
+
+ ) : null}
+
+ );
+}
+
+/** 离线路径:粘贴导入。后端目录拿不到(断网/未接线)时候选照样能落地,
+ * 解析失败只影响这一栏,绝不碰表单里已填的内容。 */
+function PasteImport({ onParsed }: { onParsed: (providers: CatalogProvider[]) => void }): React.ReactNode {
+ const [text, setText] = useState("");
+ const [err, setErr] = useState(null);
+ const submit = () => {
+ try {
+ const parsed = parsePastedCatalog(text);
+ if (parsed.length === 0) {
+ setErr("粘贴内容里没有可用供应商(需要 id + 至少一个模型)");
+ return;
+ }
+ setErr(null);
+ onParsed(parsed);
+ } catch (e: unknown) {
+ setErr(e instanceof Error ? e.message : "粘贴内容解析失败");
+ }
+ };
+ return (
+
+
+ 离线路径:粘贴 models.dev 的 api.json(或单个供应商片段)
+
+
+ );
+}
+
+/** 目录面板的头部与状态行:加载中 / 端点未实现 / 抓取失败 / 解析失败 / 目录为空
+ * 各自独立且诚实,绝不用一句「暂无可选」把不同原因糊在一起。 */
+function CatalogStatus({
+ loading,
+ error,
+ hasData,
+ unimplemented,
+ parseErr,
+ empty,
+ force,
+ onForce,
+}: {
+ loading: boolean;
+ error: string | null;
+ hasData: boolean;
+ unimplemented: boolean;
+ parseErr: string | null;
+ empty: boolean;
+ force: boolean;
+ onForce: () => void;
+}): React.ReactNode {
+ return (
+ <>
+
+
从模型目录自动化填入
+
+ 目录候选(models.dev)自动带出 baseURL / 模型 id / 多模态 / 输出上限,省去手抄
+
+
+
+ {loading ? (
+ 正在读取目录(首次会去 models.dev 抓一次,可能较慢)…
+ ) : null}
+ {hasData && error ? 刷新目录失败,下面是上次拿到的候选:{error} : null}
+ {!hasData && unimplemented ? (
+
+ 后端未提供 GET /api/v1/admin/llm/catalog(404/501):拿不到在线候选。仍可用下面的「粘贴导入」离线喂入目录。
+
+ ) : null}
+ {!hasData && !unimplemented && error ? (
+ 目录不可用(后端不可达、离线且无本地快照,或抓取失败):{error}
+ ) : null}
+ {parseErr ? {parseErr} : null}
+ {empty ? 目录为空:接口返回了空候选列表。用下面的粘贴导入,或直接手填表单。 : null}
+ >
+ );
+}
+
+/** 目录过滤条:关键词 + 供应商。 */
+function CatalogFilters({
+ providers,
+ query,
+ providerId,
+ onQuery,
+ onProvider,
+}: {
+ providers: CatalogProvider[];
+ query: string;
+ providerId: string;
+ onQuery: (q: string) => void;
+ onProvider: (id: string) => void;
+}): React.ReactNode {
+ return (
+
+ onQuery(e.target.value)}
+ placeholder="搜索供应商 / 模型"
+ className="w-56 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-1.5 text-xs outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
+ />
+
+ 填入 = 用候选重建下方表单;动表单前请先保存已填内容
+
+ );
+}
+
+/** 目录面板:把「在线目录(后端快照 + 离线兜底)」与「粘贴导入」汇成同一份候选。
+ * 任何失败都只影响本栏,绝不编造候选、也绝不改动表单内容。 */
+export function CatalogPanel({ onPick }: { onPick: (draft: AdminLlmProvider) => void }): React.ReactNode {
+ // 第一次点「强制在线刷新」起,请求带上 ?refresh=1(再次点击即再刷一次)。
+ const [refreshSeq, setRefreshSeq] = useState(0);
+ const force = refreshSeq > 0;
+ const { data, error, loading } = useAsyncData(
+ () => request(force ? "/admin/llm/catalog?refresh=1" : "/admin/llm/catalog"),
+ [refreshSeq]
+ );
+ const [pasted, setPasted] = useState([]);
+ const [query, setQuery] = useState("");
+ const [providerId, setProviderId] = useState("");
+
+ let fetched: CatalogProvider[] = [];
+ let parseErr: string | null = null;
+ if (data !== null) {
+ try {
+ fetched = parseCatalogProviders(data, "目录响应");
+ } catch (e: unknown) {
+ parseErr = e instanceof Error ? e.message : "目录响应无法解析";
+ }
+ }
+ const providers = mergeProviders(fetched, pasted);
+ const selected = providers.find((p) => p.id === providerId);
+ const empty = !loading && !error && !parseErr && providers.length === 0;
+
+ return (
+
+ setRefreshSeq((n) => n + 1)}
+ />
+ setPasted(list)} />
+ {providers.length > 0 ? (
+
+ ) : null}
+ {selected ? (
+
+ {selected.name}:{selected.api ? `baseURL ${selected.api}` : "目录未标 baseURL,填入后请手填"}
+ {selected.env.length ? ` · 官方 key 变量 ${selected.env.join("/")}` : ""}
+ {selected.doc ? ` · 文档 ${selected.doc}` : ""}
+
+ ) : null}
+ {providers.length > 0 ? (
+
+ ) : null}
+
+ );
+}
diff --git a/internal/services/gateway/admin_llm_catalog.go b/internal/services/gateway/admin_llm_catalog.go
new file mode 100644
index 0000000..29c2e12
--- /dev/null
+++ b/internal/services/gateway/admin_llm_catalog.go
@@ -0,0 +1,195 @@
+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)
+}
diff --git a/internal/services/gateway/admin_llm_catalog_cache.go b/internal/services/gateway/admin_llm_catalog_cache.go
new file mode 100644
index 0000000..28c8a1a
--- /dev/null
+++ b/internal/services/gateway/admin_llm_catalog_cache.go
@@ -0,0 +1,72 @@
+package gateway
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+// 目录快照的本地缓存:抓下来的候选落成一份 JSON,使断网时面板仍能给出
+// 候选(裁决 03.4.2「服务端缓存 + 离线兜底」)。缓存不含任何凭据,
+// 与 models.json 同目录(data/ 已 gitignore)。
+
+// catalogFresh 报告快照是否仍在 TTL 内。时间和零值都要显式处理:
+// 缺失/解析不出抓取时间的快照一律按「不新鲜」对待,促使调用方去刷新。
+func catalogFresh(fetchedAt, now time.Time) bool {
+ if fetchedAt.IsZero() {
+ return false
+ }
+ return now.Sub(fetchedAt) < catalogCacheTTL
+}
+
+// loadCatalogCache 读取快照缓存。文件不存在时返回 (nil, 零值, nil) ——
+// 「没有快照」是正常状态,不是错误;只有存在但读不动/解不开才算错误,
+// 由调用方记一条 WARN 并继续走在线抓取。
+func loadCatalogCache(path string) ([]AdminLlmCatalogProvider, time.Time, error) {
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, time.Time{}, nil
+ }
+ return nil, time.Time{}, fmt.Errorf("read catalog cache %s: %w", path, err)
+ }
+ var snap AdminLlmCatalog
+ if err := json.Unmarshal(raw, &snap); err != nil {
+ return nil, time.Time{}, fmt.Errorf("decode catalog cache %s: %w", path, err)
+ }
+ if len(snap.Providers) == 0 {
+ // 空快照等于没快照:宁可重新抓,也不要拿空列表冒充候选。
+ return nil, time.Time{}, fmt.Errorf("catalog cache %s: no providers", path)
+ }
+ at, _ := time.Parse(time.RFC3339, snap.FetchedAt)
+ return snap.Providers, at, nil
+}
+
+// saveCatalogCache 原子写入快照(先写临时文件再改名,读者不会看到半个文件)。
+// 缓存只是加速手段:写失败由调用方记 WARN,绝不影响本次回答。
+func saveCatalogCache(path string, providers []AdminLlmCatalogProvider) error {
+ snap := AdminLlmCatalog{
+ Source: catalogSourceOnline,
+ FetchedAt: time.Now().Format(time.RFC3339),
+ Providers: providers,
+ }
+ raw, err := json.Marshal(snap)
+ if err != nil {
+ return fmt.Errorf("marshal catalog cache: %w", err)
+ }
+ if dir := filepath.Dir(path); dir != "" && dir != "." {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("mkdir %s: %w", dir, err)
+ }
+ }
+ tmp := path + ".tmp"
+ if err := os.WriteFile(tmp, raw, 0o644); err != nil {
+ return fmt.Errorf("write %s: %w", tmp, err)
+ }
+ if err := os.Rename(tmp, path); err != nil {
+ return fmt.Errorf("rename %s: %w", path, err)
+ }
+ return nil
+}
diff --git a/internal/services/gateway/admin_llm_catalog_source.go b/internal/services/gateway/admin_llm_catalog_source.go
new file mode 100644
index 0000000..bab25b3
--- /dev/null
+++ b/internal/services/gateway/admin_llm_catalog_source.go
@@ -0,0 +1,142 @@
+package gateway
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "sort"
+ "strings"
+)
+
+// models.dev 原始形状的解码与裁剪。只保留面板真正用得上的字段
+// (baseURL / 模型 id / 能力位 / 上限),既让响应小得多,也让契约稳定:
+// 上游加了新字段不会改变我们的线上结构。
+
+// modelsDevModel 是 models.dev `api.json` 里一个模型的原始形状(节选)。
+type modelsDevModel struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Attachment bool `json:"attachment"`
+ Reasoning bool `json:"reasoning"`
+ ToolCall bool `json:"tool_call"`
+ Limit struct {
+ Context int `json:"context"`
+ Output int `json:"output"`
+ } `json:"limit"`
+}
+
+// modelsDevProvider 是 models.dev `api.json` 里一个供应商的原始形状(节选)。
+type modelsDevProvider struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ API string `json:"api"`
+ Doc string `json:"doc"`
+ Env []string `json:"env"`
+ NPM string `json:"npm"`
+ Models map[string]modelsDevModel `json:"models"`
+}
+
+// modelsDevCatalogFetcher 返回指向裁决指定数据源的抓取闭包。handler 与测试
+// 共用同一函数签名,因此目录的决策逻辑可以不碰真实网络地被演练。
+func modelsDevCatalogFetcher() func(context.Context) ([]AdminLlmCatalogProvider, error) {
+ return func(ctx context.Context) ([]AdminLlmCatalogProvider, error) {
+ return fetchModelsDevCatalog(ctx, modelsDevCatalogURL)
+ }
+}
+
+// fetchModelsDevCatalog 拉取并裁剪一份目录。它只做「取 + 解 + 裁」,
+// 缓存与兜底由调用方(catalogServe)决定。
+func fetchModelsDevCatalog(ctx context.Context, url string) ([]AdminLlmCatalogProvider, error) {
+ ctx, cancel := context.WithTimeout(ctx, catalogFetchTimeout)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("build catalog request: %w", err)
+ }
+ req.Header.Set("Accept", "application/json")
+ client := &http.Client{Timeout: catalogFetchTimeout}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("fetch catalog %s: %w", url, err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("fetch catalog %s: unexpected status %d", url, resp.StatusCode)
+ }
+ // 多给 1 字节,使「超出上限」与「刚好等于上限」可区分。
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, catalogMaxBytes+1))
+ if err != nil {
+ return nil, fmt.Errorf("read catalog %s: %w", url, err)
+ }
+ if len(raw) > catalogMaxBytes {
+ return nil, fmt.Errorf("catalog %s: response exceeds %d bytes", url, catalogMaxBytes)
+ }
+ return trimCatalog(raw)
+}
+
+// trimCatalog 把 models.dev 的原始 JSON 裁剪成面板的候选结构。
+// 输出顺序是确定的(供应商与模型都按 id 排序),便于测试与前端 diff。
+func trimCatalog(raw []byte) ([]AdminLlmCatalogProvider, error) {
+ var decoded map[string]modelsDevProvider
+ if err := json.Unmarshal(raw, &decoded); err != nil {
+ return nil, fmt.Errorf("decode catalog: %w", err)
+ }
+ if len(decoded) == 0 {
+ return nil, fmt.Errorf("decode catalog: no providers")
+ }
+ out := make([]AdminLlmCatalogProvider, 0, len(decoded))
+ for key, p := range decoded {
+ id := strings.TrimSpace(p.ID)
+ if id == "" {
+ id = strings.TrimSpace(key)
+ }
+ if id == "" {
+ continue
+ }
+ // 没有模型清单的供应商给不出候选,不进面板(避免噪声行)。
+ if len(p.Models) == 0 {
+ continue
+ }
+ out = append(out, AdminLlmCatalogProvider{
+ ID: id,
+ Name: firstNonEmpty(p.Name, id),
+ API: strings.TrimSpace(p.API),
+ Doc: strings.TrimSpace(p.Doc),
+ Env: p.Env,
+ NPM: strings.TrimSpace(p.NPM),
+ Models: trimCatalogModels(p.Models),
+ })
+ }
+ if len(out) == 0 {
+ return nil, fmt.Errorf("decode catalog: no provider carries models")
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
+ return out, nil
+}
+
+// trimCatalogModels 把供应商的模型映射裁成有序切片。
+func trimCatalogModels(models map[string]modelsDevModel) []AdminLlmCatalogModel {
+ out := make([]AdminLlmCatalogModel, 0, len(models))
+ for key, m := range models {
+ id := strings.TrimSpace(m.ID)
+ if id == "" {
+ id = strings.TrimSpace(key)
+ }
+ if id == "" {
+ continue
+ }
+ out = append(out, AdminLlmCatalogModel{
+ ID: id,
+ Name: firstNonEmpty(m.Name, id),
+ Attachment: m.Attachment,
+ Reasoning: m.Reasoning,
+ ToolCall: m.ToolCall,
+ ContextLimit: m.Limit.Context,
+ OutputLimit: m.Limit.Output,
+ })
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
+ return out
+}
diff --git a/internal/services/gateway/admin_llm_catalog_test.go b/internal/services/gateway/admin_llm_catalog_test.go
new file mode 100644
index 0000000..31e33b5
--- /dev/null
+++ b/internal/services/gateway/admin_llm_catalog_test.go
@@ -0,0 +1,383 @@
+package gateway
+
+import (
+ "compress/gzip"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
+ "git.yeij.top/AskaEth/Cyrene_v2/internal/config"
+ "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
+ apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
+)
+
+// 目录端点(裁决 03.4.2)的测试:裁剪、抓取、缓存/离线兜底、诚实失败与 RBAC。
+// 全部用例都不打真实外网 —— 抓取源用 httptest,缓存用预置快照。
+
+// catalogSampleJSON 是一份 models.dev 形状的样本:含两个可用供应商
+// (alpha 带 api/npm/env,beta 不带 api)、一个只有单模型的供应商,
+// 以及一个「键名与 id 不一致」的模型(以 id 为准)。
+const catalogSampleJSON = `{
+ "zeta": {
+ "id": "zeta",
+ "name": "Zeta",
+ "doc": "https://zeta.example/docs",
+ "models": {"only": {"id": "only", "name": "Only"}}
+ },
+ "alpha": {
+ "id": "alpha",
+ "name": "Alpha",
+ "api": "https://api.alpha.example/v1",
+ "doc": "https://alpha.example/docs",
+ "env": ["ALPHA_API_KEY"],
+ "npm": "@ai-sdk/anthropic",
+ "models": {
+ "alpha-large": {
+ "id": "alpha-large",
+ "name": "Alpha Large",
+ "attachment": true,
+ "reasoning": true,
+ "tool_call": true,
+ "limit": {"context": 200000, "output": 64000}
+ },
+ "key-mismatch": {"id": "alpha-mini", "name": "Alpha Mini"}
+ }
+ },
+ "beta": {
+ "id": "beta",
+ "name": "Beta",
+ "npm": "@ai-sdk/openai-compatible",
+ "models": {"beta-1": {"id": "beta-1"}}
+ }
+}`
+
+func TestTrimCatalogKeepsCapabilityBits(t *testing.T) {
+ t.Parallel()
+ got, err := trimCatalog([]byte(catalogSampleJSON))
+ if err != nil {
+ t.Fatalf("trimCatalog: %v", err)
+ }
+ if len(got) != 3 {
+ t.Fatalf("providers = %d, want 3 (%+v)", len(got), got)
+ }
+ // 输出按 id 排序:供应商与模型都确定。
+ if got[0].ID != "alpha" || got[1].ID != "beta" || got[2].ID != "zeta" {
+ t.Fatalf("provider order = %s/%s/%s, want alpha/beta/zeta", got[0].ID, got[1].ID, got[2].ID)
+ }
+ alpha := got[0]
+ if alpha.API != "https://api.alpha.example/v1" || alpha.NPM != "@ai-sdk/anthropic" {
+ t.Fatalf("alpha base/sdk = %q/%q", alpha.API, alpha.NPM)
+ }
+ if len(alpha.Env) != 1 || alpha.Env[0] != "ALPHA_API_KEY" {
+ t.Fatalf("alpha env = %v", alpha.Env)
+ }
+ if len(alpha.Models) != 2 || alpha.Models[0].ID != "alpha-large" || alpha.Models[1].ID != "alpha-mini" {
+ t.Fatalf("alpha models = %+v, want sorted alpha-large,alpha-mini", alpha.Models)
+ }
+ big := alpha.Models[0]
+ if !big.Attachment || !big.Reasoning || !big.ToolCall {
+ t.Fatalf("alpha-large capability bits = %+v, want attachment/reasoning/tool_call all true", big)
+ }
+ if big.ContextLimit != 200000 || big.OutputLimit != 64000 {
+ t.Fatalf("alpha-large limits = %d/%d, want 200000/64000", big.ContextLimit, big.OutputLimit)
+ }
+ // 键名(key-mismatch)与声明的 id(alpha-mini)不一致时以 id 为准。
+ if alpha.Models[1].Name != "Alpha Mini" {
+ t.Fatalf("key/id mismatch resolved to %q, want the declared id (Alpha Mini)", alpha.Models[1].Name)
+ }
+ // beta 没有 api:必须如实留空,由面板提示手填,不得编造地址。
+ if got[1].API != "" {
+ t.Fatalf("beta api = %q, want empty (the catalog does not declare one)", got[1].API)
+ }
+ if len(got[1].Models) != 1 || got[1].Models[0].ID != "beta-1" {
+ t.Fatalf("beta models = %+v", got[1].Models)
+ }
+}
+
+func TestTrimCatalogSkipsProviderWithoutModelsAndRejectsGarbage(t *testing.T) {
+ t.Parallel()
+ got, err := trimCatalog([]byte(`{"empty":{"id":"empty","name":"Empty","api":"https://e.example","models":{}},"ok":{"id":"ok","models":{"m":{"id":"m"}}}}`))
+ if err != nil {
+ t.Fatalf("trimCatalog: %v", err)
+ }
+ if len(got) != 1 || got[0].ID != "ok" {
+ t.Fatalf("providers = %+v, want only the one carrying models", got)
+ }
+ for name, raw := range map[string]string{
+ "empty object": `{}`,
+ "not json": ``,
+ "no models": `{"a":{"id":"a","models":{}}}`,
+ } {
+ if _, err := trimCatalog([]byte(raw)); err == nil {
+ t.Fatalf("%s: trimCatalog returned no error, want an honest failure", name)
+ }
+ }
+}
+
+func TestFetchModelsDevCatalogFromHTTPSource(t *testing.T) {
+ t.Parallel()
+ var accept string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ accept = r.Header.Get("Accept")
+ _, _ = w.Write([]byte(catalogSampleJSON))
+ }))
+ t.Cleanup(ts.Close)
+ got, err := fetchModelsDevCatalog(context.Background(), ts.URL)
+ if err != nil {
+ t.Fatalf("fetchModelsDevCatalog: %v", err)
+ }
+ if len(got) != 3 {
+ t.Fatalf("providers = %d, want 3", len(got))
+ }
+ if accept == "" {
+ t.Fatal("catalog request carried no Accept header")
+ }
+
+ bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusBadGateway)
+ }))
+ t.Cleanup(bad.Close)
+ if _, err := fetchModelsDevCatalog(context.Background(), bad.URL); err == nil {
+ t.Fatal("non-200 catalog response returned no error")
+ }
+ down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {}))
+ down.Close()
+ if _, err := fetchModelsDevCatalog(context.Background(), down.URL); err == nil {
+ t.Fatal("unreachable catalog source returned no error")
+ }
+}
+
+// catalogEnv 是一台 LLM 栈指向临时 models.json 的 Server,加上它的
+// 目录快照缓存路径(快照只落在临时目录里,绝不碰工作树)。
+type catalogEnv struct {
+ srv *Server
+ ts *httptest.Server
+ jwt *auth.JWTService
+ cachePath string
+}
+
+func newCatalogEnv(t *testing.T) *catalogEnv {
+ t.Helper()
+ modelsPath := filepath.Join(t.TempDir(), "models.json")
+ env := config.LLMConfig{Provider: "openai", BaseURL: "https://api.openai.com/v1", Model: "gpt-4o"}
+ runtime := newLlmRuntimeForGateway(t, modelsPath, env)
+ srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime})
+ return &catalogEnv{srv: srv, ts: ts, jwt: jwt, cachePath: filepath.Join(filepath.Dir(modelsPath), catalogCacheName)}
+}
+
+// seedCatalogCache 写一份「刚抓过」的快照,模拟上次抓取的结果。
+func (e *catalogEnv) seedCatalogCache(t *testing.T, model string) {
+ t.Helper()
+ providers := []AdminLlmCatalogProvider{{
+ ID: "seed", Name: "Seed", API: "https://api.seed.example/v1",
+ Models: []AdminLlmCatalogModel{{ID: model, Name: model, OutputLimit: 8192}},
+ }}
+ raw, err := json.Marshal(AdminLlmCatalog{Source: catalogSourceOnline, FetchedAt: time.Now().Format(time.RFC3339), Providers: providers})
+ if err != nil {
+ t.Fatalf("marshal seed: %v", err)
+ }
+ if err := os.WriteFile(e.cachePath, raw, 0o644); err != nil {
+ t.Fatalf("seed cache: %v", err)
+ }
+}
+
+// ageCatalogCache 把快照的抓取时间推到 TTL 之前,使它必须去刷新。
+func (e *catalogEnv) ageCatalogCache(t *testing.T) {
+ t.Helper()
+ raw, err := os.ReadFile(e.cachePath)
+ if err != nil {
+ t.Fatalf("read seed: %v", err)
+ }
+ var snap AdminLlmCatalog
+ if err := json.Unmarshal(raw, &snap); err != nil {
+ t.Fatalf("unmarshal seed: %v", err)
+ }
+ snap.FetchedAt = time.Now().Add(-3 * catalogCacheTTL).Format(time.RFC3339)
+ outdated, err := json.Marshal(snap)
+ if err != nil {
+ t.Fatalf("marshal outdated: %v", err)
+ }
+ if err := os.WriteFile(e.cachePath, outdated, 0o644); err != nil {
+ t.Fatalf("rewrite seed: %v", err)
+ }
+}
+
+func TestCatalogServeUsesFreshSnapshotWithoutFetching(t *testing.T) {
+ t.Parallel()
+ env := newCatalogEnv(t)
+ env.seedCatalogCache(t, "seed-model")
+ calls := 0
+ cat, err := env.srv.catalogServe(context.Background(), func(context.Context) ([]AdminLlmCatalogProvider, error) {
+ calls++
+ return nil, errors.New("must not be called")
+ }, false)
+ if err != nil {
+ t.Fatalf("catalogServe: %v", err)
+ }
+ if calls != 0 {
+ t.Fatalf("fetch calls = %d, want 0 for a fresh snapshot", calls)
+ }
+ if cat.Source != catalogSourceCache || cat.Stale {
+ t.Fatalf("source/stale = %q/%v, want %q/false", cat.Source, cat.Stale, catalogSourceCache)
+ }
+ if len(cat.Providers) != 1 || cat.Providers[0].Models[0].ID != "seed-model" {
+ t.Fatalf("providers = %+v, want the seeded snapshot", cat.Providers)
+ }
+}
+
+func TestCatalogServeFallsBackToStaleSnapshotWhenRefreshFails(t *testing.T) {
+ t.Parallel()
+ env := newCatalogEnv(t)
+ env.seedCatalogCache(t, "seed-model")
+ env.ageCatalogCache(t)
+
+ cat, err := env.srv.catalogServe(context.Background(), func(context.Context) ([]AdminLlmCatalogProvider, error) {
+ return nil, errors.New("dial tcp: no route to host")
+ }, false)
+ if err != nil {
+ t.Fatalf("catalogServe: %v", err)
+ }
+ if !cat.Stale || cat.Source != catalogSourceCache {
+ t.Fatalf("stale/source = %v/%q, want true/%q", cat.Stale, cat.Source, catalogSourceCache)
+ }
+ if cat.Warning == "" {
+ t.Fatal("stale snapshot carried no warning; the panel would silently look fresh")
+ }
+ if len(cat.Providers) != 1 {
+ t.Fatalf("providers = %+v, want the stale snapshot", cat.Providers)
+ }
+}
+
+func TestCatalogServeFailsHonestlyWithoutSnapshot(t *testing.T) {
+ t.Parallel()
+ env := newCatalogEnv(t)
+ _, err := env.srv.catalogServe(context.Background(), func(context.Context) ([]AdminLlmCatalogProvider, error) {
+ return nil, errors.New("dial tcp: no route to host")
+ }, false)
+ if err == nil {
+ t.Fatal("no snapshot + unreachable source returned no error, want an honest failure")
+ }
+ appErr, ok := apperr.As(err)
+ if !ok {
+ t.Fatalf("error %v is not an app error", err)
+ }
+ if appErr.Code != "catalog_unavailable" {
+ t.Fatalf("code = %q, want catalog_unavailable", appErr.Code)
+ }
+ if got := apperr.HTTPStatus(err); got != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want 503", got)
+ }
+}
+
+func TestCatalogServeRefreshReplacesSnapshot(t *testing.T) {
+ t.Parallel()
+ env := newCatalogEnv(t)
+ env.seedCatalogCache(t, "old-model")
+ cat, err := env.srv.catalogServe(context.Background(), func(context.Context) ([]AdminLlmCatalogProvider, error) {
+ return []AdminLlmCatalogProvider{{
+ ID: "new", Models: []AdminLlmCatalogModel{{ID: "new-model"}},
+ }}, nil
+ }, true)
+ if err != nil {
+ t.Fatalf("catalogServe: %v", err)
+ }
+ if cat.Source != catalogSourceOnline || len(cat.Providers) != 1 || cat.Providers[0].Models[0].ID != "new-model" {
+ t.Fatalf("forced refresh returned %+v, want the freshly fetched catalog", cat)
+ }
+ cached, _, err := loadCatalogCache(env.cachePath)
+ if err != nil {
+ t.Fatalf("loadCatalogCache: %v", err)
+ }
+ if len(cached) != 1 || cached[0].Models[0].ID != "new-model" {
+ t.Fatalf("cached = %+v, want the refreshed snapshot to be persisted", cached)
+ }
+}
+
+// TestAdminGetLlmCatalogGzipsWhenAccepted 锁「大目录压缩」这条实现:
+// 客户端声明接受 gzip 时,响应必须是 gzip 且解开后仍是同一条信封。
+func TestAdminGetLlmCatalogGzipsWhenAccepted(t *testing.T) {
+ t.Parallel()
+ env := newCatalogEnv(t)
+ env.seedCatalogCache(t, "seed-model")
+ tok := signAny(t, env.jwt, "acct", auth.RoleOwner)
+
+ // 关掉传输层的自动解压,才能看到真正的 Content-Encoding。
+ client := &http.Client{Transport: &http.Transport{DisableCompression: true}}
+ req, err := http.NewRequest(http.MethodGet, env.ts.URL+"/api/v1/admin/llm/catalog", nil)
+ if err != nil {
+ t.Fatalf("new request: %v", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+tok)
+ req.Header.Set("Accept-Encoding", "gzip")
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("do request: %v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.Header.Get("Content-Encoding") != "gzip" {
+ t.Fatalf("Content-Encoding = %q, want gzip", resp.Header.Get("Content-Encoding"))
+ }
+ zr, err := gzip.NewReader(resp.Body)
+ if err != nil {
+ t.Fatalf("gzip.NewReader: %v", err)
+ }
+ raw, err := io.ReadAll(zr)
+ if err != nil {
+ t.Fatalf("read gzip body: %v", err)
+ }
+ var envelope struct {
+ Data AdminLlmCatalog `json:"data"`
+ }
+ if err := json.Unmarshal(raw, &envelope); err != nil {
+ t.Fatalf("decode gunzipped envelope: %v (%s)", err, raw)
+ }
+ if len(envelope.Data.Providers) != 1 || envelope.Data.Source != catalogSourceCache {
+ t.Fatalf("gunzipped catalog = %+v, want the same envelope as the plain response", envelope.Data)
+ }
+}
+
+// TestAdminGetLlmCatalogServesSnapshotAndEnforcesRBAC 锁 HTTP 面:owner 拿到
+// 快照信封,viewer 与匿名被 RBAC 门挡下,且整个端点不写 models.json。
+// 本路由故意不进 rbac_viewer_test.go 的 ownerAdminReadRoutes 清单 —— 那份
+// 清单里的 Server 没有 LLM 栈,会真的去打外网目录;这里用带预置快照的
+// Server 单独锁,同样零网络依赖。
+func TestAdminGetLlmCatalogServesSnapshotAndEnforcesRBAC(t *testing.T) {
+ t.Parallel()
+ env := newCatalogEnv(t)
+ env.seedCatalogCache(t, "seed-model")
+
+ ownerTok := signAny(t, env.jwt, "acct", auth.RoleOwner)
+ resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/llm/catalog", nil, ownerTok)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("owner status = %d, want 200 (body=%s)", resp.StatusCode, body)
+ }
+ var envelope struct {
+ Data AdminLlmCatalog `json:"data"`
+ }
+ if err := json.Unmarshal(body, &envelope); err != nil {
+ t.Fatalf("decode catalog response: %v (%s)", err, body)
+ }
+ if envelope.Data.Source != catalogSourceCache || len(envelope.Data.Providers) != 1 {
+ t.Fatalf("catalog envelope = %+v, want the cached snapshot", envelope.Data)
+ }
+
+ viewerTok := signAny(t, env.jwt, "acct", auth.RoleViewer)
+ if resp, _ := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/llm/catalog", nil, viewerTok); resp.StatusCode != http.StatusForbidden {
+ t.Fatalf("viewer status = %d, want 403", resp.StatusCode)
+ }
+ if resp, _ := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/llm/catalog", nil, ""); resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("anonymous status = %d, want 401", resp.StatusCode)
+ }
+ groups := env.srv.currentLlmGroups()
+ if len(groups) != 1 || groups[0].ID != runtimeconfig.ProviderOpenAI {
+ t.Fatalf("catalog endpoint mutated the LLM groups: %+v", groups)
+ }
+}
diff --git a/internal/services/gateway/routes.go b/internal/services/gateway/routes.go
index c9253cf..52615fe 100644
--- a/internal/services/gateway/routes.go
+++ b/internal/services/gateway/routes.go
@@ -155,6 +155,9 @@ func (s *Server) registerAdminRoutes(
mux.Handle("GET /api/v1/admin/channels/messages", writeAuth(http.HandlerFunc(s.handleChannelMessages)))
mux.Handle("GET /api/v1/admin/channels/{platform}/messages", writeAuth(http.HandlerFunc(s.handleChannelPlatformLog)))
mux.Handle("GET /api/v1/admin/llm", writeAuth(http.HandlerFunc(s.adminGetLlm)))
+ // 模型目录候选(裁决 03.4.2):面板「添加提供商 / 添加模型」的
+ // baseURL / 模型清单 / 能力位来源,服务端快照 + 离线兜底。
+ mux.Handle("GET /api/v1/admin/llm/catalog", writeAuth(http.HandlerFunc(s.adminGetLlmCatalog)))
mux.Handle("GET /api/v1/admin/users", writeAuth(http.HandlerFunc(s.adminListUsers)))
mux.Handle("GET /api/v1/admin/backthink", writeAuth(http.HandlerFunc(s.adminListBackthink)))
mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit)))