feat(gateway,admin-web): 模型目录自动化 —— GET /admin/llm/catalog(裁决 03.4.2 次件)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 新增 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 行
This commit is contained in:
AskaEth committed 2026-10-04 12:43:04 +08:00
1 parent b8fa53e43c
commit 63e5b70e3d
7 files changed
+1320 -37

No files matched your search

+86 -37
View File
@@ -6,6 +6,7 @@ import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
import { Button, EmptyState, Surface, Tag } from "../../components/primitives";
import { Icon } from "../../components/Icon";
import { CatalogPanel } from "./llm/LlmCatalogPanel";
import { LlmProviderForm } from "./llm/LlmProviderForm";
/** 一个供应商分组行:身份 + 当前 token 预算 + 测试/编辑动作。 */
@@ -86,7 +87,7 @@ function ProviderList({
<EmptyState
icon={<Icon name="robot" size={32} />}
title="还没有配置 LLM 供应商"
hint="用下方表单新增第一个供应商 / 模型分组。"
hint="用下方目录候选或表单新增第一个供应商 / 模型分组。"
/>
);
}
@@ -100,6 +101,69 @@ function ProviderList({
);
}
/** 一次真实模型探测(POST /admin/llm/test):结果与错误都写进页面顶部的提示区,
* 成功/失败各记一条前端动作日志。它是真实往返,绝不假装成功。 */
function runLlmTest(
p: AdminLlmProvider,
log: (what: string, result: "ok" | "error") => void,
setBusy: (v: boolean) => void,
setFail: (v: string | null) => void,
setResult: (v: string | null) => void
): void {
setBusy(true);
setFail(null);
setResult(null);
adminTestLlm({ provider: p.provider || "openai", baseUrl: p.baseUrl, model: p.model })
.then((r) => {
log("llm.test", "ok");
setResult(`【${p.name}】模型 ${r.model} 响应:${r.reply}(${r.tokens} tokens)`);
})
.catch((e: unknown) => {
log("llm.test", "error");
setFail(e instanceof Error ? e.message : "模型测试失败");
})
.finally(() => setBusy(false));
}
/** 页面标题 + 测试结果 / 失败提示。 */
function LlmPageStatus({ fail, result }: { fail: string | null; result: string | null }): React.ReactNode {
return (
<>
<PageHeader
title="LLM 模型"
subtitle="供应商 + 模型配置(base/key/多模态检测/token 预算) + 测试(真实调用) + fallback + 按角色路由 + 用量"
/>
{fail ? <Notice tone="err">{fail}</Notice> : null}
{result ? <Notice tone="ok">{result}</Notice> : null}
</>
);
}
/** 生效链:只画接口真的返回过的分组与它们的备选模型,不臆造本地第三级
* 或 per-persona 路由的运行态(那两个由后端配置决定,本端点不报告)。 */
function FallbackChain({ providers }: { providers: AdminLlmProvider[] }): React.ReactNode {
return (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">生效链(来自接口返回的真实分组)</h3>
{providers.length === 0 ? (
<p className="text-xs text-[var(--mu-text-lo)]">暂无分组:上面保存一个之后,这里会显示它的主/备链。</p>
) : (
providers.map((p) => (
<div key={p.id} className="flex flex-wrap items-center gap-3 text-sm">
<Tag tone={p.enabled ? "ok" : "neutral"}>{p.enabled ? "生效组" : "备用组"}</Tag>
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{p.model}</span>
<span className="text-[var(--mu-text-mid)]">→ {p.fallback || "无备选模型"}</span>
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">接口报错自动降级</span>
</div>
))
)}
<Notice>
本地模型第三级与 per-persona 路由由后端开关/配置决定,本端点不报告它们的运行态,因此这里不显示,也不臆造。
</Notice>
</Surface>
);
}
export function LlmModule(): React.ReactNode {
const { data, error, loading, reload } = useAsyncData(adminGetLlm, []);
const log = useAuditStore((s) => s.log);
@@ -109,36 +173,21 @@ export function LlmModule(): React.ReactNode {
const providers = data?.providers ?? [];
const [editing, setEditing] = useState<AdminLlmProvider | null>(null);
// 同一分组 id 连续填入不同候选时也必须重挂表单(表单按 key 取初值),
// 否则第二次填入不会生效。
const [draftSeq, setDraftSeq] = useState(0);
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const [result, setResult] = useState<string | null>(null);
const test = (p: AdminLlmProvider) => {
setBusy(true);
setFail(null);
setResult(null);
const providerId = p.provider || "openai";
adminTestLlm({ provider: providerId, baseUrl: p.baseUrl, model: p.model })
.then((r) => {
log("llm.test", "ok");
setResult(`【${p.name}】模型 ${r.model} 响应:${r.reply}(${r.tokens} tokens)`);
})
.catch((e: unknown) => {
log("llm.test", "error");
setFail(e instanceof Error ? e.message : "模型测试失败");
})
.finally(() => setBusy(false));
const edit = (p: AdminLlmProvider | null) => {
setEditing(p);
setDraftSeq((n) => n + 1);
};
return (
<div className="space-y-5">
<PageHeader
title="LLM 模型"
subtitle="供应商 + 模型配置(base/key/多模态检测/token 预算) + 测试(真实调用) + fallback + 按角色路由 + 用量"
/>
{fail ? <Notice tone="err">{fail}</Notice> : null}
{result ? <Notice tone="ok">{result}</Notice> : null}
<LlmPageStatus fail={fail} result={result} />
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">供应商 / 模型</h3>
@@ -148,13 +197,20 @@ export function LlmModule(): React.ReactNode {
error={error}
unimplemented={unimplemented}
busy={busy}
onTest={test}
onEdit={setEditing}
onTest={(p) => runLlmTest(p, log, setBusy, setFail, setResult)}
onEdit={(p) => edit(p)}
/>
</Surface>
<CatalogPanel
onPick={(draft) => {
log("llm.catalog.fill", "ok");
edit(draft);
}}
/>
<LlmProviderForm
key={editing?.id ?? "new"}
key={editing ? `${editing.id}#${draftSeq}` : `new#${draftSeq}`}
initial={editing}
busy={busy}
setBusy={setBusy}
@@ -164,18 +220,11 @@ export function LlmModule(): React.ReactNode {
onCancel={() => setEditing(null)}
/>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">按角色路由 + 降级链</h3>
{["owner", "admin", "viewer"].map((role) => (
<div key={role} className="flex items-center gap-3 text-sm">
<Tag tone="neutral">{role}</Tag>
<span className="text-[var(--mu-text-mid)]">主 → 备 → 本地</span>
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">接口报错自动降级</span>
</div>
))}
</Surface>
<FallbackChain providers={providers} />
<Notice>per-role 路由 + 降级逻辑由后端 loop 实现;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1)。.env 为兜底。每组 maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。</Notice>
<Notice>
per-role 路由 + 降级逻辑由后端 loop 实现;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1)。.env 为兜底。每组 maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。目录候选来自 models.dev,由后端缓存成快照(离线仍可用),绝不进模型运行时热路径。
</Notice>
</div>
);
}
@@ -0,0 +1,439 @@
import { useState } from "react";
import { useAsyncData } from "../../../lib/useAsyncData";
import { isNotImplemented } from "../../../lib/api";
// 目录端点是本页新引入的调用;按仓库惯例它本该落在 lib/api/admin.ts 里,
// 但本批次只允许改 Llm.tsx(+ 本文件),因此经 lib/api 的传输层直接调用
// (信封解包 / 401 处理与其余端点一致)。
import { request } from "../../../lib/api/core";
import type { AdminLlmProvider } from "../../../lib/types";
import { Notice } from "../../components";
import { Button, Surface, Tag } from "../../../components/primitives";
/** 目录候选一次最多渲染多少行(其余用一行文字如实说明还有多少条)。 */
const CATALOG_MAX_ROWS = 40;
/** 目录里的一个候选模型:只保留面板用得上的能力位。 */
interface CatalogModel {
id: string;
name: string;
/** 接受图片/文件输入 → 表单的 multimodal。 */
attachment: boolean;
reasoning: boolean;
toolCall: boolean;
contextLimit: number;
outputLimit: number;
}
/** 目录里的一个供应商:baseURL + 模型清单 + 申请 key 的提示。 */
interface CatalogProvider {
id: string;
name: string;
api: string;
doc: string;
env: string[];
npm: string;
models: CatalogModel[];
}
/** 后端裁剪形状与 models.dev 原始形状字段名不同(toolCall vs tool_call、
* 嵌套 limit 等),但两条来源(在线目录 / 手动粘贴)必须落到同一份候选上,
* 因此下面的解析同时接受两种拼写,缺失字段一律取保守默认值。 */
function asRecord(v: unknown): Record<string, unknown> | null {
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
}
function str(v: unknown): string {
return typeof v === "string" ? v : "";
}
function num(v: unknown): number {
return typeof v === "number" && Number.isFinite(v) ? v : 0;
}
function strList(v: unknown): string[] {
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
}
/** 解析一个模型条目;modelId 来自外层键名,仅在条目自报 id 缺失时兜底。 */
function parseCatalogModel(raw: unknown, modelId: string): CatalogModel | null {
const obj = asRecord(raw);
if (!obj) return null;
const id = str(obj.id) || modelId;
if (!id) return null;
const limit = asRecord(obj.limit);
return {
id,
name: str(obj.name) || id,
attachment: obj.attachment === true,
reasoning: obj.reasoning === true,
toolCall: obj.toolCall === true || obj.tool_call === true,
contextLimit: num(obj.contextLimit) || (limit ? num(limit.context) : 0),
outputLimit: num(obj.outputLimit) || (limit ? num(limit.output) : 0),
};
}
/** 解析模型清单:后端给数组,models.dev 给「id → 模型」映射。 */
function parseCatalogModels(raw: unknown): CatalogModel[] {
const out: CatalogModel[] = [];
const push = (v: unknown, key: string): void => {
const parsed = parseCatalogModel(v, key);
if (parsed) out.push(parsed);
};
if (Array.isArray(raw)) {
raw.forEach((m) => push(m, ""));
} else {
const map = asRecord(raw);
if (map) Object.keys(map).forEach((key) => push(map[key], key));
}
return out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
}
/** 解析一个供应商条目;providerId 是外层键名兜底。没有模型清单的供应商
* 给不出候选,直接丢弃(面板不显示点了没用的行)。 */
function parseCatalogProvider(raw: unknown, providerId: string): CatalogProvider | null {
const obj = asRecord(raw);
if (!obj) return null;
const id = str(obj.id) || providerId;
const models = parseCatalogModels(obj.models);
if (!id || models.length === 0) return null;
return {
id,
name: str(obj.name) || id,
api: str(obj.api),
doc: str(obj.doc),
env: strList(obj.env),
npm: str(obj.npm),
models,
};
}
/** 把任意一层结构解析成供应商候选:接受 `{providers:[…]}`(后端形状)、
* `[…]`(数组)与 `{providerId:{…}}`(models.dev 的 api.json 形状)。
* 结构完全不认识时抛错,由调用方按「诚实错误」渲染,而不是当空目录。 */
function parseCatalogProviders(raw: unknown, source: string): CatalogProvider[] {
const out: CatalogProvider[] = [];
const push = (v: unknown, key: string): void => {
const parsed = parseCatalogProvider(v, key);
if (parsed) out.push(parsed);
};
if (Array.isArray(raw)) {
raw.forEach((item) => push(item, ""));
return out;
}
const obj = asRecord(raw);
if (!obj) throw new Error(`${source}:顶层既不是对象也不是数组`);
if (Array.isArray(obj.providers)) {
obj.providers.forEach((item) => push(item, ""));
return out;
}
Object.keys(obj).forEach((key) => push(obj[key], key));
return out;
}
/** 离线路径:解析粘贴进来的 models.dev api.json(或单个供应商片段)。 */
function parsePastedCatalog(text: string): CatalogProvider[] {
const trimmed = text.trim();
if (!trimmed) throw new Error("粘贴内容为空");
let raw: unknown;
try {
raw = JSON.parse(trimmed) as unknown;
} catch (e: unknown) {
throw new Error(`粘贴内容不是合法 JSON:${e instanceof Error ? e.message : "解析失败"}`);
}
return parseCatalogProviders(raw, "粘贴内容");
}
/** 合并在线目录与粘贴候选:同一供应商以主人粘贴的那份为准。 */
function mergeProviders(fetched: CatalogProvider[], pasted: CatalogProvider[]): CatalogProvider[] {
const byId = new Map<string, CatalogProvider>();
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 (
<div className="flex flex-wrap items-center gap-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2">
<span className="text-sm font-medium">{model.name}</span>
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{model.id}</span>
{model.attachment ? <Tag tone="accent">多模态</Tag> : null}
{model.reasoning ? <Tag tone="neutral">推理</Tag> : null}
{model.toolCall ? <Tag tone="neutral">工具</Tag> : null}
{model.outputLimit ? <Tag tone="neutral">{`输出上限 ${model.outputLimit}`}</Tag> : null}
{provider.api ? null : <Tag tone="warn">目录未标 baseURL</Tag>}
<span className="ml-auto">
<Button size="sm" variant="ghost" onClick={onPick}>
填入表单
</Button>
</span>
</div>
);
}
/** 候选列表(按供应商 + 关键词过滤)。渲染条数有上限,超出部分用文字说明,
* 不做任何截断式伪数据。 */
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 <p className="text-xs text-[var(--mu-text-lo)]">没有匹配的模型:换个关键词,或清空供应商过滤。</p>;
}
const shown = rows.slice(0, CATALOG_MAX_ROWS);
return (
<div className="space-y-2">
{shown.map(({ provider, model }) => (
<CatalogModelRow
key={`${provider.id}/${model.id}`}
provider={provider}
model={model}
onPick={() => onPick(draftFromCatalog(provider, model))}
/>
))}
{rows.length > shown.length ? (
<p className="text-xs text-[var(--mu-text-lo)]">
匹配 {rows.length} 个模型,已显示前 {shown.length} 个;用关键词缩小范围。
</p>
) : null}
</div>
);
}
/** 离线路径:粘贴导入。后端目录拿不到(断网/未接线)时候选照样能落地,
* 解析失败只影响这一栏,绝不碰表单里已填的内容。 */
function PasteImport({ onParsed }: { onParsed: (providers: CatalogProvider[]) => void }): React.ReactNode {
const [text, setText] = useState("");
const [err, setErr] = useState<string | null>(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 (
<details className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2">
<summary className="cursor-pointer text-xs text-[var(--mu-text-mid)]">
离线路径:粘贴 models.dev 的 api.json(或单个供应商片段)
</summary>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={4}
placeholder='{"deepseek":{"id":"deepseek","api":"https://api.deepseek.com","models":{"deepseek-v4-pro":{"id":"deepseek-v4-pro","attachment":false}}}}'
className="mt-2 w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-3)] px-3 py-2 font-mono text-xs outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
/>
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button size="sm" variant="ghost" disabled={!text.trim()} onClick={submit}>
解析粘贴内容
</Button>
<span className="text-xs text-[var(--mu-text-lo)]">解析失败只影响这一栏,不会动表单里已填的内容。</span>
</div>
{err ? <Notice tone="err">{err}</Notice> : null}
</details>
);
}
/** 目录面板的头部与状态行:加载中 / 端点未实现 / 抓取失败 / 解析失败 / 目录为空
* 各自独立且诚实,绝不用一句「暂无可选」把不同原因糊在一起。 */
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 (
<>
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">从模型目录自动化填入</h3>
<span className="text-xs text-[var(--mu-text-lo)]">
目录候选(models.dev)自动带出 baseURL / 模型 id / 多模态 / 输出上限,省去手抄
</span>
<Button size="sm" variant="ghost" className="ml-auto" disabled={loading} onClick={onForce}>
{force ? "再强制在线刷新" : "强制在线刷新"}
</Button>
</div>
{loading ? (
<p className="text-xs text-[var(--mu-text-lo)]">正在读取目录(首次会去 models.dev 抓一次,可能较慢)…</p>
) : null}
{hasData && error ? <Notice tone="err">刷新目录失败,下面是上次拿到的候选:{error}</Notice> : null}
{!hasData && unimplemented ? (
<Notice tone="warn">
后端未提供 GET /api/v1/admin/llm/catalog(404/501):拿不到在线候选。仍可用下面的「粘贴导入」离线喂入目录。
</Notice>
) : null}
{!hasData && !unimplemented && error ? (
<Notice tone="err">目录不可用(后端不可达、离线且无本地快照,或抓取失败):{error}</Notice>
) : null}
{parseErr ? <Notice tone="err">{parseErr}</Notice> : null}
{empty ? <Notice tone="warn">目录为空:接口返回了空候选列表。用下面的粘贴导入,或直接手填表单。</Notice> : 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 (
<div className="flex flex-wrap items-center gap-2">
<input
value={query}
onChange={(e) => 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)]"
/>
<select
value={providerId}
onChange={(e) => onProvider(e.target.value)}
className="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)]"
>
<option value="">全部供应商({providers.length})</option>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.name}({p.models.length})
</option>
))}
</select>
<span className="text-xs text-[var(--mu-text-lo)]">填入 = 用候选重建下方表单;动表单前请先保存已填内容</span>
</div>
);
}
/** 目录面板:把「在线目录(后端快照 + 离线兜底)」与「粘贴导入」汇成同一份候选。
* 任何失败都只影响本栏,绝不编造候选、也绝不改动表单内容。 */
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<unknown>(force ? "/admin/llm/catalog?refresh=1" : "/admin/llm/catalog"),
[refreshSeq]
);
const [pasted, setPasted] = useState<CatalogProvider[]>([]);
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 (
<Surface className="space-y-3">
<CatalogStatus
loading={loading}
error={error}
hasData={data !== null}
unimplemented={isNotImplemented(error)}
parseErr={parseErr}
empty={empty}
force={force}
onForce={() => setRefreshSeq((n) => n + 1)}
/>
<PasteImport onParsed={(list) => setPasted(list)} />
{providers.length > 0 ? (
<CatalogFilters
providers={providers}
query={query}
providerId={providerId}
onQuery={setQuery}
onProvider={setProviderId}
/>
) : null}
{selected ? (
<p className="text-xs text-[var(--mu-text-lo)]">
{selected.name}:{selected.api ? `baseURL ${selected.api}` : "目录未标 baseURL,填入后请手填"}
{selected.env.length ? ` · 官方 key 变量 ${selected.env.join("/")}` : ""}
{selected.doc ? ` · 文档 ${selected.doc}` : ""}
</p>
) : null}
{providers.length > 0 ? (
<CatalogList providers={providers} query={query} providerId={providerId} onPick={onPick} />
) : null}
</Surface>
);
}
@@ -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)
}
@@ -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
}
@@ -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
}
@@ -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": `<html>`,
"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)
}
}
+3
View File
@@ -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)))