feat(gateway,admin-web): 面板可编辑 provider 推理形状 / 角色级路由 / multimodal 真语义(裁决 03.4.2/03.4.1)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 后端:AdminLlmProvider 暴露 reasoning{kind}(恒下发,""=未声明=auto)、personaModels、personaProviders;
  顶层新增 reasoningKinds / personaProviderOptions 两个**取值域**(唯一来源= runtimeconfig 导出枚举,
  校验错误文案也由枚举拼接 ⇒ 不可能漂移)
- 读写共用同一映射(admin_llm_provider.go),避免「读侧少一个键 ⇒ 面板一保存就抹掉」
- 写侧语义:缺失=保留、给出=整块/整表替换;非法值一律 runtimeconfig.Group.Validate() ⇒ 400 bad_request
  (实测非法 kind 返回 400 且 models.json 完全不动,而非 500 静默失败)
- 前端:新增 LlmProviderRouting(推理形状下拉 + multimodal 开关 + 按角色路由编辑器);
  目录能力位改三态(true/false/未标注),「未标注」不再谎称「不支持」;目录/角色清单不可用时
  **不阻塞编辑**且与「字段为空」是不同状态;绝无 mock/sample 回退
- 复核:build/vet 绿 + gateway/config 包测试绿 + tsc 0 错 + vite build 成功;2 条变异反证(删 reasoning
  补丁 → 2 例红;删 Group.Validate → 该 400 用例红)已还原
- 注意(主会话已知):npm run build 把新 SPA 写进 frontend/web/dist,而运行中的二进制优先从磁盘读该目录
  ⇒ :10942 目前是「新面板 + 旧后端」,等阶段 5 全绿后按红线「先 build 到位、再一次 restart」切换
This commit is contained in:
AskaEth committed 2026-10-04 13:06:54 +08:00
1 parent 8890c30bb5
commit 1ac02bd77e
12 files changed
+998 -85

No files matched your search

+54 -18
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminGetLlm, adminTestLlm, isNotImplemented } from "../../lib/api";
import { adminGetLlm, adminListPersonas, adminTestLlm, isNotImplemented } from "../../lib/api";
import type { AdminLlmProvider } from "../../lib/types";
import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
@@ -8,8 +8,9 @@ import { Button, EmptyState, Surface, Tag } from "../../components/primitives";
import { Icon } from "../../components/Icon";
import { CatalogPanel } from "./llm/LlmCatalogPanel";
import { LlmProviderForm } from "./llm/LlmProviderForm";
import type { CatalogHint } from "./llm/LlmProviderRouting";
/** 一个供应商分组行:身份 + 当前 token 预算 + 测试/编辑动作。 */
/** 一个供应商分组行:身份 + token 预算 + 推理形状 + 路由 + 测试/编辑动作。 */
function ProviderRow({
p,
busy,
@@ -21,6 +22,7 @@ function ProviderRow({
onTest: (p: AdminLlmProvider) => void;
onEdit: (p: AdminLlmProvider) => void;
}): React.ReactNode {
const routes = Object.keys(p.personaModels ?? {}).length;
return (
<div className="flex flex-wrap items-center gap-3 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2.5">
<span className="font-medium">{p.name}</span>
@@ -29,9 +31,13 @@ function ProviderRow({
<Tag tone={p.maxTokens ? "accent" : "neutral"}>
{p.maxTokens ? `maxTokens ${p.maxTokens}` : "maxTokens 默认"}
</Tag>
<Tag tone={p.reasoning?.kind ? "accent" : "neutral"}>
{`推理 ${p.reasoning?.kind || "未声明"}`}
</Tag>
{routes > 0 ? <Tag tone="accent">{`按角色路由 ${routes}`}</Tag> : null}
<span className="ml-auto flex items-center gap-4">
<Tag tone={p.enabled ? "ok" : "neutral"}>{p.enabled ? "启用" : "停用"}</Tag>
<Tag tone={p.multimodal ? "accent" : "neutral"}>{p.multimodal ? "多模态" : "文本"}</Tag>
<Tag tone={p.multimodal ? "accent" : "neutral"}>{p.multimodal ? "主模型可收图" : "主模型不收图"}</Tag>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => onEdit(p)}>
编辑
</Button>
@@ -131,7 +137,7 @@ function LlmPageStatus({ fail, result }: { fail: string | null; result: string |
<>
<PageHeader
title="LLM 模型"
subtitle="供应商 + 模型配置(base/key/多模态检测/token 预算) + 测试(真实调用) + fallback + 按角色路由 + 用量"
subtitle="供应商 + 模型配置(base/key/主模型收图/token 预算/推理开关形状/按角色路由) + 测试(真实调用) + fallback"
/>
{fail ? <Notice tone="err">{fail}</Notice> : null}
{result ? <Notice tone="ok">{result}</Notice> : null}
@@ -139,26 +145,46 @@ function LlmPageStatus({ fail, result }: { fail: string | null; result: string |
);
}
/** 生效链:只画接口真的返回过的分组与它们的备选模型,不臆造本地第三级
* 或 per-persona 路由的运行态(那两个由后端配置决定,本端点不报告)。 */
/** 一个分组已配置的 per-persona 路由(接口返回的配置真值)。两张表取并集,
* 使「只声明了协议没声明模型」的死配置也照实显示,而不是看不见。 */
function PersonaRouteList({ p }: { p: AdminLlmProvider }): React.ReactNode {
const providers = p.personaProviders ?? {};
const models = p.personaModels ?? {};
const personas = [...new Set([...Object.keys(models), ...Object.keys(providers)])].sort();
if (personas.length === 0) return null;
return (
<p className="pl-1 text-xs text-[var(--mu-text-lo)]">
按角色路由:
{personas
.map((id) => `${id} → ${models[id] || "(未配模型,不生效)"}${providers[id] ? `(协议 ${providers[id]})` : ""}`)
.join(",")}
</p>
);
}
/** 生效链:只画接口真的返回过的分组、它们的备选模型与已配置的 per-persona 路由;
* 本地第三级由后端开关决定、本端点不报告其运行态,因此不臆造。 */
function FallbackChain({ providers }: { providers: AdminLlmProvider[] }): React.ReactNode {
return (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">生效链(来自接口返回的真实分组)</h3>
<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 key={p.id} className="space-y-1">
<div 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>
<PersonaRouteList p={p} />
</div>
))
)}
<Notice>
本地模型第三级与 per-persona 路由由后端开关/配置决定,本端点不报告它们的运行态,因此这里不显示,也不臆造。
本地模型第三级由后端开关与本地组配置决定,本端点不报告它的运行态,因此这里不显示,也不臆造。
</Notice>
</Surface>
);
@@ -166,6 +192,8 @@ function FallbackChain({ providers }: { providers: AdminLlmProvider[] }): React.
export function LlmModule(): React.ReactNode {
const { data, error, loading, reload } = useAsyncData(adminGetLlm, []);
// 角色清单只作候选提示:取不到也不阻塞编辑(persona id 可手填)。
const personas = useAsyncData(adminListPersonas, []);
const log = useAuditStore((s) => s.log);
// 端点未实现 / 请求失败必须分开:前者是 404/501,后者是断连或 5xx。
const unimplemented = isNotImplemented(error);
@@ -179,9 +207,12 @@ export function LlmModule(): React.ReactNode {
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const [result, setResult] = useState<string | null>(null);
// 目录候选带进来的能力建议(可推翻);没有建议时是 null。
const [hint, setHint] = useState<CatalogHint | null>(null);
const edit = (p: AdminLlmProvider | null) => {
const edit = (p: AdminLlmProvider | null, next: CatalogHint | null) => {
setEditing(p);
setHint(next);
setDraftSeq((n) => n + 1);
};
@@ -198,14 +229,14 @@ export function LlmModule(): React.ReactNode {
unimplemented={unimplemented}
busy={busy}
onTest={(p) => runLlmTest(p, log, setBusy, setFail, setResult)}
onEdit={(p) => edit(p)}
onEdit={(p) => edit(p, null)}
/>
</Surface>
<CatalogPanel
onPick={(draft) => {
onPick={(draft, picked) => {
log("llm.catalog.fill", "ok");
edit(draft);
edit(draft, picked);
}}
/>
@@ -218,12 +249,17 @@ export function LlmModule(): React.ReactNode {
onError={setFail}
onSaved={reload}
onCancel={() => setEditing(null)}
hint={hint}
reasoningKinds={data?.reasoningKinds ?? []}
personaProviderOptions={data?.personaProviderOptions ?? []}
personaIds={(personas.data ?? []).map((p) => p.id)}
personaListError={personas.error}
/>
<FallbackChain providers={providers} />
<Notice>
per-role 路由 + 降级逻辑由后端 loop 实现;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1)。.env 为兜底。每组 maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。目录候选来自 models.dev,由后端缓存成快照(离线仍可用),绝不进模型运行时热路径。
每组的 reasoning.kind(推理开关形状)按端点选:形状属于端点而不属于模型,选错会 400,因此面板只给出「未声明 / auto」与后端下发的几种形状。按角色路由(persona → 模型 / 协议)与降级链由后端 loop 真接线;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1),.env 为兜底。maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。multimodal 只表示主模型自己能否收图;图片/OCR 工具用的模型走 .env 的 LLM_VISION_MODEL / LLM_OCR_MODEL。目录候选来自 models.dev,由后端缓存成快照(离线仍可用),绝不进模型运行时热路径。
</Notice>
</div>
);
@@ -4,6 +4,7 @@ import { adminGetLlmCatalog, isNotImplemented } from "../../../lib/api";
import type { AdminLlmProvider } from "../../../lib/types";
import { Notice } from "../../components";
import { Button, Surface, Tag } from "../../../components/primitives";
import type { CatalogHint } from "./LlmProviderRouting";
/** 目录候选一次最多渲染多少行(其余用一行文字如实说明还有多少条)。 */
const CATALOG_MAX_ROWS = 40;
@@ -12,9 +13,12 @@ const CATALOG_MAX_ROWS = 40;
interface CatalogModel {
id: string;
name: string;
/** 接受图片/文件输入 → 表单的 multimodal。 */
attachment: boolean;
reasoning: boolean;
/** 接受图片/文件输入 → 表单的 multimodal。undefined = **目录未标注**该能力
* (不是「不支持」),面板据此既不预填也不说反话。 */
attachment?: boolean;
/** 目录标注该模型会推理;undefined = 未标注。它**只描述模型**,不描述端点
* 的开关形状,因此面板只作提示,绝不据此猜 reasoning.kind。 */
reasoning?: boolean;
toolCall: boolean;
contextLimit: number;
outputLimit: number;
@@ -60,8 +64,8 @@ function parseCatalogModel(raw: unknown, modelId: string): CatalogModel | null {
return {
id,
name: str(obj.name) || id,
attachment: obj.attachment === true,
reasoning: obj.reasoning === true,
attachment: typeof obj.attachment === "boolean" ? obj.attachment : undefined,
reasoning: typeof obj.reasoning === "boolean" ? obj.reasoning : undefined,
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),
@@ -153,18 +157,27 @@ 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 {
/** 目录候选 → 表单草稿 + 能力建议。分组 id 取目录 id,重复填入即幂等更新同名
* 分组;multimodal 只在目录**明确标注**接受图片输入时预填,maxTokens 建议值取
* 模型的输出上限(目录真值,没有任何猜测成分)——两者都只是预填,用户可推翻。 */
function draftFromCatalog(p: CatalogProvider, m: CatalogModel): { draft: AdminLlmProvider; hint: CatalogHint } {
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,
draft: {
id: p.id,
name: p.name,
provider: protocolFromCatalog(p),
baseUrl: p.api,
model: m.id,
multimodal: m.attachment === true,
enabled: false,
maxTokens: m.outputLimit || undefined,
},
hint: {
provider: p.id,
model: m.id,
attachment: m.attachment,
supportsReasoning: m.reasoning === true,
},
};
}
@@ -182,8 +195,8 @@ function CatalogModelRow({
<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.attachment === true ? <Tag tone="accent">可收图</Tag> : null}
{model.reasoning === true ? <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>}
@@ -207,7 +220,7 @@ function CatalogList({
providers: CatalogProvider[];
query: string;
providerId: string;
onPick: (draft: AdminLlmProvider) => void;
onPick: (draft: AdminLlmProvider, hint: CatalogHint) => void;
}): React.ReactNode {
const q = query.trim().toLowerCase();
const rows: { provider: CatalogProvider; model: CatalogModel }[] = [];
@@ -229,7 +242,10 @@ function CatalogList({
key={`${provider.id}/${model.id}`}
provider={provider}
model={model}
onPick={() => onPick(draftFromCatalog(provider, model))}
onPick={() => {
const pick = draftFromCatalog(provider, model);
onPick(pick.draft, pick.hint);
}}
/>
))}
{rows.length > shown.length ? (
@@ -373,7 +389,11 @@ function CatalogFilters({
/** 目录面板:把「在线目录(后端快照 + 离线兜底)」与「粘贴导入」汇成同一份候选。
* 任何失败都只影响本栏,绝不编造候选、也绝不改动表单内容。 */
export function CatalogPanel({ onPick }: { onPick: (draft: AdminLlmProvider) => void }): React.ReactNode {
export function CatalogPanel({
onPick,
}: {
onPick: (draft: AdminLlmProvider, hint: CatalogHint) => void;
}): React.ReactNode {
// 第一次点「强制在线刷新」起,请求带上 ?refresh=1(再次点击即再刷一次)。
const [refreshSeq, setRefreshSeq] = useState(0);
const force = refreshSeq > 0;
@@ -2,7 +2,16 @@ import { useState } from "react";
import { adminAddLlmProvider } from "../../../lib/api";
import type { AdminLlmProvider, LlmProviderRequest } from "../../../lib/types";
import { useAuditStore } from "../../audit";
import { Button, Surface, Switch } from "../../../components/primitives";
import { Button, Surface } from "../../../components/primitives";
import {
MultimodalField,
PersonaRoutesEditor,
ReasoningField,
initPersonaRoutes,
personaRouteProblem,
routesToMaps,
} from "./LlmProviderRouting";
import type { CatalogHint, PersonaRouteDraft } from "./LlmProviderRouting";
/** Add/edit form for one LLM provider group. The group list hands in an
* `initial` group to edit (remounted via key) or null to add a new one. The
@@ -17,6 +26,10 @@ interface FormState {
apiKey: string;
multimodal: boolean;
maxTokens: string;
/** 推理开关形状;空串 = 未声明(等同 auto)。 */
reasoning: string;
/** per-persona 路由行(persona → 模型 / 协议覆盖)。 */
personaRoutes: PersonaRouteDraft[];
}
type FieldSetter = <K extends keyof FormState>(key: K, value: FormState[K]) => void;
@@ -30,11 +43,31 @@ function initState(initial: AdminLlmProvider | null): FormState {
apiKey: "",
multimodal: initial?.multimodal ?? false,
maxTokens: initial?.maxTokens ? String(initial.maxTokens) : "",
reasoning: initial?.reasoning?.kind ?? "",
personaRoutes: initPersonaRoutes(initial),
};
}
/** The form fields; state lives in the form component below. */
function ProviderFields({ form, set, editing }: { form: FormState; set: FieldSetter; editing: boolean }): React.ReactNode {
function ProviderFields({
form,
set,
editing,
hint,
reasoningKinds,
personaProviderOptions,
personaIds,
personaListError,
}: {
form: FormState;
set: FieldSetter;
editing: boolean;
hint: CatalogHint | null;
reasoningKinds: string[];
personaProviderOptions: string[];
personaIds: string[];
personaListError: string | null;
}): React.ReactNode {
return (
<>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
@@ -84,9 +117,21 @@ function ProviderFields({ form, set, editing }: { form: FormState; set: FieldSet
/>
</label>
</div>
<div className="flex items-end gap-2 pb-[2px]">
<Switch checked={form.multimodal} onChange={(v) => set("multimodal", v)} label="多模态" />
</div>
<MultimodalField checked={form.multimodal} hint={hint} onChange={(v) => set("multimodal", v)} />
<ReasoningField
value={form.reasoning}
kinds={reasoningKinds}
provider={form.provider}
onChange={(v) => set("reasoning", v)}
/>
<PersonaRoutesEditor
routes={form.personaRoutes}
knownIds={personaIds}
personaListError={personaListError}
providerOptions={personaProviderOptions}
groupProvider={form.provider}
onChange={(next) => set("personaRoutes", next)}
/>
</>
);
}
@@ -99,6 +144,16 @@ interface LlmProviderFormProps {
onError: (msg: string | null) => void;
onSaved: () => void;
onCancel: () => void;
/** 目录候选带进来的能力建议(可推翻);null = 没有建议。 */
hint: CatalogHint | null;
/** reasoning.kind 的可选取值,由后端下发(缺省 = 后端未报告)。 */
reasoningKinds: string[];
/** personaProviders 的可选取值,由后端下发(缺省 = 后端未报告)。 */
personaProviderOptions: string[];
/** 已知 persona id,仅作为候选提示(可手填)。 */
personaIds: string[];
/** 角色清单取不到时的原因;null = 清单可用。 */
personaListError: string | null;
}
/** Owns the field state and the save round-trip; the module supplies callbacks. */
@@ -110,25 +165,53 @@ export function LlmProviderForm({
onError,
onSaved,
onCancel,
hint,
reasoningKinds,
personaProviderOptions,
personaIds,
personaListError,
}: LlmProviderFormProps): React.ReactNode {
const editing = initial !== null;
const [form, setForm] = useState<FormState>(() => initState(initial));
const log = useAuditStore((s) => s.log);
const set: FieldSetter = (key, value) => setForm((prev) => ({ ...prev, [key]: value }));
const save = () => {
const buildRequest = (): LlmProviderRequest | null => {
const budget = form.maxTokens.trim();
if (budget !== "" && !/^\d+$/.test(budget)) {
onError("maxTokens 必须为非负整数(留空 = 保持/默认)");
return;
return null;
}
setBusy(true);
onError(null);
const req: LlmProviderRequest = { provider: form.provider, baseUrl: form.baseUrl, model: form.model, multimodal: form.multimodal };
const problem = personaRouteProblem(form.personaRoutes);
if (problem) {
onError(problem);
return null;
}
const { personaModels, personaProviders } = routesToMaps(form.personaRoutes);
const req: LlmProviderRequest = {
provider: form.provider,
baseUrl: form.baseUrl,
model: form.model,
multimodal: form.multimodal,
// reasoning 整块给出:kind 空串 = 显式回到「未声明」,否则面板无法把
// 已存形状改回去(缺省字段意味着「保留原值」)。
reasoning: { kind: form.reasoning },
// 两张路由表整表提交:面板删掉一行后要真的能从存储里删掉。
personaModels,
personaProviders,
};
if (editing && initial) req.id = initial.id;
else if (form.name.trim()) req.id = form.name.trim();
if (form.apiKey.trim()) req.apiKey = form.apiKey.trim();
if (budget !== "") req.maxTokens = Number(budget);
return req;
};
const save = () => {
const req = buildRequest();
if (!req) return;
setBusy(true);
onError(null);
adminAddLlmProvider(req)
.then((r) => {
log("llm.addProvider", "ok");
@@ -155,13 +238,24 @@ export function LlmProviderForm({
</Button>
) : null}
</div>
<ProviderFields form={form} set={set} editing={editing} />
<ProviderFields
form={form}
set={set}
editing={editing}
hint={hint}
reasoningKinds={reasoningKinds}
personaProviderOptions={personaProviderOptions}
personaIds={personaIds}
personaListError={personaListError}
/>
<div className="flex items-center gap-2">
<Button variant="primary" disabled={busy || !form.model || !form.baseUrl} onClick={save}>
{editing ? "保存修改" : "保存模型"}
</Button>
<span className="text-xs text-[var(--mu-text-lo)]">
{editing ? "只提交本表单填写的字段,未填字段保持原值" : "保存后写入 models.json 并热更新运行中的 LLM provider"}
{editing
? "只提交本表单填写的字段,未填字段保持原值(按角色路由与推理形状按当前表单内容整块提交)"
: "保存后写入 models.json 并热更新运行中的 LLM provider"}
</span>
</div>
</Surface>
@@ -170,3 +264,4 @@ export function LlmProviderForm({
const inputCls =
"w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)] disabled:opacity-60";
@@ -0,0 +1,302 @@
import type { AdminLlmProvider } from "../../../lib/types";
import { Button, Switch, Tag } from "../../../components/primitives";
/* ---------------------------------------------------------------------------
* 阶段 5 新增的字段(multimodal / reasoning.kind / per-persona 路由)的编辑界面。
* 取值域一律由后端下发(GET /admin/llm 的 reasoningKinds / personaProviderOptions),
* 本文件只提供展示文案与本地校验,不硬编码第二套枚举,也不拿任何示例值顶替。
* -------------------------------------------------------------------------*/
/** 一个 persona 路由行的编辑草稿。 */
export interface PersonaRouteDraft {
persona: string;
model: string;
provider: string;
}
/** 从已存分组播种路由行:两张表的键取并集,使「只声明了 provider、没声明模型」
* 的条目也照实显示(面板绝不悄悄丢掉已存配置)。 */
export function initPersonaRoutes(p: AdminLlmProvider | null): PersonaRouteDraft[] {
const models = p?.personaModels ?? {};
const providers = p?.personaProviders ?? {};
const keys = [...new Set([...Object.keys(models), ...Object.keys(providers)])].sort();
return keys.map((persona) => ({
persona,
model: models[persona] ?? "",
provider: providers[persona] ?? "",
}));
}
/** 路由行 → 两张表。空 persona 的行是没填完的行(提交前已被拦下),不编造;
* 「有 provider 没 model」的行按原样保留在后端已有的 schema 语义里
* (它不生效,面板在行上明说),因此绝不把已存配置悄悄变成别的形状。 */
export function routesToMaps(routes: PersonaRouteDraft[]): {
personaModels: Record<string, string>;
personaProviders: Record<string, string>;
} {
const personaModels: Record<string, string> = {};
const personaProviders: Record<string, string> = {};
routes.forEach((r) => {
const persona = r.persona.trim();
if (!persona) return;
const model = r.model.trim();
const provider = r.provider.trim();
if (model) personaModels[persona] = model;
if (provider) personaProviders[persona] = provider;
});
return { personaModels, personaProviders };
}
/** 路由行的本地校验。只拦两类确定会让整张表出错的输入:persona 没填就填了别的
* 东西(后端会 400),以及同一 persona 出现多次(对象键会悄悄合并成一条)。 */
export function personaRouteProblem(routes: PersonaRouteDraft[]): string | null {
const seen = new Set<string>();
for (const r of routes) {
const persona = r.persona.trim();
if (!persona) {
if (r.model.trim() || r.provider.trim()) return "按角色路由:有行的 persona 没填(补上 persona,或删掉该行)";
continue;
}
if (seen.has(persona)) return `按角色路由:persona「${persona}」出现多次——同一 persona 只能有一条`;
seen.add(persona);
}
return null;
}
/** 目录(models.dev)对候选模型的能力标注,用于给面板**建议**。
* attachment 为 undefined = 目录未标注该能力(不是「不支持」)。 */
export interface CatalogHint {
provider: string;
model: string;
attachment?: boolean;
supportsReasoning: boolean;
}
/** multimodal 开关 + 语义说明 + 目录建议。目录只是建议,随时可推翻;
* 目录不可用时这一栏照常可编辑(hint 为 null)。 */
export function MultimodalField({
checked,
hint,
onChange,
}: {
checked: boolean;
hint: CatalogHint | null;
onChange: (next: boolean) => void;
}): React.ReactNode {
return (
<div className="space-y-1">
<Switch checked={checked} onChange={onChange} label="主模型可收图(multimodal)" />
<p className="text-xs text-[var(--mu-text-lo)]">
勾选 = 主模型自己接收图片输入。图片识别 / OCR 工具用的模型由 .env 的 LLM_VISION_MODEL /
LLM_OCR_MODEL 另配,与这里无关。
</p>
{hint ? (
<p className="text-xs text-[var(--mu-text-mid)]">
目录(models.dev)对 {hint.model} 的标注:
{hint.attachment === true
? "接受图片输入"
: hint.attachment === false
? "不接受图片输入"
: "未标注该能力(请按你的端点实际能力填)"}
{hint.supportsReasoning ? " · 支持推理(开关形状仍需按端点选)" : ""} —— 只是建议,可随时推翻。
</p>
) : (
<p className="text-xs text-[var(--mu-text-lo)]">未从目录带入建议(目录不可用或本次是手填),不影响编辑。</p>
)}
</div>
);
}
/** reasoning.kind 的展示文案:键是后端下发的取值,值是**形状说明**。
* 后端将来新增的取值没有文案时原样显示,不编造含义。 */
const REASONING_LABEL: Record<string, string> = {
auto: "auto(按模型名 / baseURL 推断,形状有歧义就不下发)",
deepseek: "deepseek(顶层 thinking:{type} + reasoning_effort)",
openai: "openai(只有 reasoning_effort)",
vllm: "vllm(chat_template_kwargs.enable_thinking)",
dashscope: "dashscope(顶层 enable_thinking)",
};
/** 推理开关形状下拉框。候选值 = 后端下发的 reasoningKinds(外加「未声明」);
* 已存值不在候选里时原样列出并标注,绝不悄悄改成别的值。 */
export function ReasoningField({
value,
kinds,
provider,
onChange,
}: {
value: string;
kinds: string[];
provider: string;
onChange: (next: string) => void;
}): React.ReactNode {
const opts = kinds.map((k) => ({ value: k, label: REASONING_LABEL[k] ?? k }));
if (value && !kinds.includes(value)) {
opts.unshift({ value, label: `${value}(当前值:后端未把它列为可选形状)` });
}
return (
<div className="space-y-1">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">推理开关形状 reasoning.kind</span>
<select value={value} onChange={(e) => onChange(e.target.value)} className={`${inputCls} w-full`}>
<option value="">未声明(等同 auto:按模型名 / baseURL 推断)</option>
{opts.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
{kinds.length === 0 ? (
<p className="text-xs text-[var(--mu-warn)]">
后端未下发可选形状(响应缺少 reasoningKinds):这里只能选「未声明」,或按 design/04 §3.2 手填后由后端校验(非法值会 400)。
</p>
) : null}
{provider === "anthropic" && value !== "" && value !== "auto" ? (
<p className="text-xs text-[var(--mu-warn)]">
anthropic 组只接受「未声明 / auto」:这些形状属于 OpenAI 兼容端点,保存时后端会回 400。
</p>
) : null}
</div>
);
}
/** 一行 persona 路由:persona(可手填,已知角色作为候选)+ 模型 + 协议覆盖。 */
function PersonaRouteRow({
route,
index,
providerOptions,
groupProvider,
listId,
onPatch,
onRemove,
}: {
route: PersonaRouteDraft;
index: number;
providerOptions: string[];
groupProvider: string;
listId: string;
onPatch: (index: number, patch: Partial<PersonaRouteDraft>) => void;
onRemove: (index: number) => void;
}): React.ReactNode {
const persona = route.persona.trim();
const hasModel = route.model.trim() !== "";
const providerValue = route.provider.trim();
const providerChoices = providerValue && !providerOptions.includes(providerValue)
? [providerValue, ...providerOptions]
: providerOptions;
const dead = persona !== "" && providerValue !== "" && !hasModel;
return (
<div className="flex flex-wrap items-center gap-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2">
<input
value={route.persona}
onChange={(e) => onPatch(index, { persona: e.target.value })}
list={listId}
placeholder="persona id"
className={`${inputCls} w-40`}
aria-label="persona id"
/>
<input
value={route.model}
onChange={(e) => onPatch(index, { model: e.target.value })}
placeholder="模型名(留空 = 不绑定)"
className={`${inputCls} w-56 font-mono text-xs`}
aria-label="该 persona 的模型名"
/>
<select
value={providerValue}
onChange={(e) => onPatch(index, { provider: e.target.value })}
className={`${inputCls} w-56`}
aria-label="该 persona 的协议覆盖"
>
<option value="">跟随本组协议({groupProvider || "未定"})</option>
{providerChoices.map((p) => (
<option key={p} value={p}>
{providerOptions.includes(p) ? p : `${p}(当前值:后端未把它列为可选)`}
</option>
))}
</select>
{dead ? <Tag tone="warn">只有协议没有模型 → 不生效</Tag> : null}
<span className="ml-auto">
<Button size="sm" variant="ghost" onClick={() => onRemove(index)}>
删除
</Button>
</span>
</div>
);
}
/** per-persona 路由编辑器。角色清单只是**候选**(可手填 id):清单取不到时
* 照常手填,绝不阻塞保存;每条路由的含义与生效条件在下方如实说明。 */
export function PersonaRoutesEditor({
routes,
knownIds,
personaListError,
providerOptions,
groupProvider,
onChange,
}: {
routes: PersonaRouteDraft[];
knownIds: string[];
personaListError: string | null;
providerOptions: string[];
groupProvider: string;
onChange: (next: PersonaRouteDraft[]) => void;
}): React.ReactNode {
const listId = "llm-persona-ids";
const patch = (index: number, p: Partial<PersonaRouteDraft>): void =>
onChange(routes.map((r, i) => (i === index ? { ...r, ...p } : r)));
const remove = (index: number): void => onChange(routes.filter((_, i) => i !== index));
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-[var(--mu-text-lo)]">按角色路由(persona → 模型 / 协议)</span>
<span className="text-xs text-[var(--mu-text-lo)]">
未列出的 persona 一律用本组默认模型(协议 {groupProvider || "未定"})
</span>
<Button
size="sm"
variant="ghost"
className="ml-auto"
onClick={() => onChange([...routes, { persona: "", model: "", provider: "" }])}
>
添加一行
</Button>
</div>
<datalist id={listId}>
{knownIds.map((id) => (
<option key={id} value={id} />
))}
</datalist>
{personaListError ? (
<p className="text-xs text-[var(--mu-warn)]">
角色清单未取到({personaListError}):persona id 仍可手填,不影响保存。
</p>
) : null}
{routes.length === 0 ? (
<p className="text-xs text-[var(--mu-text-lo)]">没有 per-persona 路由:该组所有角色都用本组的 model。</p>
) : (
routes.map((r, i) => (
<PersonaRouteRow
key={i}
route={r}
index={i}
providerOptions={providerOptions}
groupProvider={groupProvider}
listId={listId}
onPatch={patch}
onRemove={remove}
/>
))
)}
{providerOptions.length === 0 ? (
<p className="text-xs text-[var(--mu-warn)]">
后端未下发可选的 persona 协议(响应缺少 personaProviderOptions):这里只能选「跟随本组协议」。
</p>
) : null}
</div>
);
}
const inputCls =
"rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)] disabled:opacity-60";
+20
View File
@@ -123,6 +123,13 @@ export interface AdminUserRecord {
createdAt?: string;
}
/** 一个 provider 组的推理开关形状声明(models.json 的 `reasoning` 块的线上投影)。
* kind 为空串 = **未声明形状**(等同 auto:按模型名/baseURL 推断,形状有歧义时
* 不下发任何开关),与「后端没报告这个字段」是两种状态。 */
export interface AdminLlmReasoning {
kind: string;
}
/** LLM provider/model configuration (GET/PUT /api/v1/admin/llm, design/04 §3.1).
* API key is never returned by the backend; provider is the wire protocol. */
export interface AdminLlmProvider {
@@ -131,15 +138,28 @@ export interface AdminLlmProvider {
provider: string;
baseUrl: string;
model: string;
/** 主模型**自己能否收图**(裁决 14.4.1)。它不表示本部署有视觉能力:图片识别 /
* OCR 工具用的模型由 .env 的 LLM_VISION_MODEL / LLM_OCR_MODEL 单独配置。 */
multimodal: boolean;
enabled: boolean;
fallback?: string;
/** The group's token budget. Absent/0 = unset (the runtime falls back to
* LLM_FALLBACK_MAX_TOKENS on the last step, then the adapter default). */
maxTokens?: number;
/** 该组的推理开关形状。后端始终下发(kind 可能为空串),旧构建则整个字段缺失。 */
reasoning?: AdminLlmReasoning;
/** persona → 模型名的路由表(裁决 03.4.1);缺失 = 该组没有 per-persona 绑定。 */
personaModels?: Record<string, string>;
/** persona → wire 协议的覆盖表;取值只能是 personaProviderOptions。 */
personaProviders?: Record<string, string>;
}
export interface AdminLlmConfig {
providers: AdminLlmProvider[];
/** `reasoning.kind` 的合法取值(不含空串),由后端配置层直接下发 —— 面板的
* 下拉框据此渲染候选,绝不自己硬编码一套枚举。旧构建缺省该字段。 */
reasoningKinds?: string[];
/** `personaProviders` 值的合法取值(协议名)。旧构建缺省该字段。 */
personaProviderOptions?: string[];
}
/** 目录里的一个模型能力位(GET /api/v1/admin/llm/catalog)。attachment 为 true
+10
View File
@@ -35,10 +35,20 @@ export interface LlmProviderRequest {
baseUrl: string;
apiKey?: string;
model: string;
/** 主模型能否收图(≠ 视觉/OCR 工具用的模型,那两个走 .env)。 */
multimodal?: boolean;
fallback?: string;
/** Absent = keep the stored budget (or leave unset for a new group). */
maxTokens?: number;
/** 推理开关形状:**缺省 = 保留已存形状**;给出(哪怕 `{}`)= 用其中的 kind
* 覆盖,kind 缺省/空串 = 回到「未声明」(等同 auto)。取值见
* AdminLlmConfig.reasoningKinds —— 非法 kind 后端回 400。 */
reasoning?: { kind?: string };
/** persona → 模型名路由表:缺省 = 保留已存表;给出(哪怕 `{}`)= **整表替换**
* (否则面板无法删除一条已存路由)。 */
personaModels?: Record<string, string>;
/** persona → 协议覆盖表,规则同上;取值非法(非 openai/anthropic)回 400。 */
personaProviders?: Record<string, string>;
}
/** POST /api/v1/admin/llm/test body. */
+23 -1
View File
@@ -54,6 +54,28 @@ const (
ReasoningKindDashScope = "dashscope"
)
// ReasoningKinds 返回本 build 接受的 reasoning.kind 取值全集(auto 在首位,
// 与面板下拉框的推荐顺序一致)。它是**校验与面板的同一来源**:管理端点把这份
// 清单随配置一起下发,面板据此渲染下拉框,因此「面板能选的」与「配置层接受的」
// 不可能漂移。空串(未声明)不在此列——它是「不声明形状」而不是一个形状名,
// 面板另行给出该选项。返回新切片,调用方改不动包内状态。
func ReasoningKinds() []string {
return []string{
ReasoningKindAuto,
ReasoningKindDeepSeek,
ReasoningKindOpenAI,
ReasoningKindVLLM,
ReasoningKindDashScope,
}
}
// ProviderNames 返回本 build 承认的 wire 协议名,用作 per-persona 路由
// (personaProviders)的合法值域。与面板共用同一来源,避免面板给出一个
// 配置层会拒收的协议名。
func ProviderNames() []string {
return []string{ProviderOpenAI, ProviderAnthropic}
}
// ReasoningOptions 是一个 provider 组的推理开关形状声明。它是「按提供商
// 独立适配」的配置载体(裁决 03.4.2 / 08.4.1):面板与 models.json 对每个
// 供应商各写一份,因此 vLLM 端点与 DashScope 端点可以同时存在、各自下发
@@ -150,7 +172,7 @@ func validateReasoning(provider string, r ReasoningOptions) error {
case "", ReasoningKindAuto, ReasoningKindDeepSeek, ReasoningKindOpenAI,
ReasoningKindVLLM, ReasoningKindDashScope:
default:
return fmt.Errorf("reasoning.kind %q must be one of auto/deepseek/openai/vllm/dashscope", kind)
return fmt.Errorf("reasoning.kind %q must be one of %s", kind, strings.Join(ReasoningKinds(), "/"))
}
if provider == ProviderAnthropic && kind != "" && kind != ReasoningKindAuto {
return fmt.Errorf("reasoning.kind %q applies to the openai-compatible provider only", kind)
+36 -5
View File
@@ -38,14 +38,28 @@ type policyRequest struct {
AllowInStranger bool `json:"allowInStranger"`
}
// AdminLlmReasoning 是一个 provider 组的推理开关形状声明(models.json 的
// `reasoning` 块的线上投影)。kind 为空串表示「未声明形状」(等同 auto:
// 按模型名/baseURL 推断,有歧义就不下发),而不是「后端没报告」——
// 后者由整个字段缺失表示。
type AdminLlmReasoning struct {
Kind string `json:"kind"`
}
// AdminLlmProvider 是管理配置中一个 LLM provider 的线上结构。
// API key 刻意缺位:它绝不返回给客户端。
//
// 这里列出的每个可编辑字段都必须在读侧如实回填:面板是「读回来 → 改一处 →
// 整组提交」,读侧少一个键就等于保存时把它抹掉。
type AdminLlmProvider struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
BaseURL string `json:"baseUrl"`
Model string `json:"model"`
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
BaseURL string `json:"baseUrl"`
Model string `json:"model"`
// Multimodal = **主模型自己能否收图**(裁决 14.4.1)。它不表示「本部署
// 有视觉能力」:图片识别/OCR 工具用的模型由 .env 的 LLM_VISION_MODEL /
// LLM_OCR_MODEL 单独配置,与这里无关。
Multimodal bool `json:"multimodal"`
Enabled bool `json:"enabled"`
Fallback string `json:"fallback,omitempty"`
@@ -53,11 +67,28 @@ type AdminLlmProvider struct {
// 在最后一个 fallback 步骤回退到 LLM_FALLBACK_MAX_TOKENS,
// 再回退到适配器默认值)。
MaxTokens int `json:"maxTokens,omitempty"`
// Reasoning 是该组的推理开关形状(裁决 03.4.2 / 08.4.1)。始终下发
// (kind 可能为空串),使面板能把「未声明」当成一种明确状态回填。
Reasoning *AdminLlmReasoning `json:"reasoning"`
// PersonaModels 是 persona → 模型名的路由表(裁决 03.4.1),键为该组内
// 的 persona id;空/缺失 = 该组没有 per-persona 模型绑定。
PersonaModels map[string]string `json:"personaModels,omitempty"`
// PersonaProviders 是 persona → wire 协议的覆盖表,取值只能是本 build
// 承认的协议名(见 AdminLlmConfig.PersonaProviderOptions)。
PersonaProviders map[string]string `json:"personaProviders,omitempty"`
}
// AdminLlmConfig 是 GET /api/v1/admin/llm 的线上结构。
//
// ReasoningKinds / PersonaProviderOptions 是**面板表单选项的取值域**:它们由
// 配置层的常量清单直接生成,因此面板给出的候选与后端会接受的取值永远一致
// (面板不硬编码自己的一套枚举)。
type AdminLlmConfig struct {
Providers []AdminLlmProvider `json:"providers"`
// ReasoningKinds 是 responses 中 reasoning.kind 的合法取值(不含空串)。
ReasoningKinds []string `json:"reasoningKinds"`
// PersonaProviderOptions 是 personaProviders 值的合法取值(协议名)。
PersonaProviderOptions []string `json:"personaProviderOptions"`
}
// AdminAutomationRecord 是管理列表中一条自动化/提醒的线上结构。
@@ -0,0 +1,67 @@
package gateway
import (
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// LLM provider 分组的线上投影:读(GET /admin/llm)与写(POST /admin/llm 的
// 响应)**必须同一套映射**。面板是「读回来 → 改一处 → 整组提交」,两个方向
// 一旦不一致(例如读侧漏了 reasoning),面板保存一次就会把那把键抹掉。
// adminLlmProviderDTO 把存储分组映射成线上的 AdminLlmProvider。API key 绝不
// 进入线上结构。reasoning 无条件下发(kind 可能为空串 = 未声明形状),
// persona 路由表按原值深拷贝,使调用方拿到的结构不会被后续改动影响。
func adminLlmProviderDTO(g runtimeconfig.Group, name string, enabled bool) AdminLlmProvider {
return AdminLlmProvider{
ID: g.ID,
Name: name,
Provider: g.Provider,
BaseURL: g.BaseURL,
Model: g.Model,
Multimodal: g.Multimodal,
Enabled: enabled,
Fallback: g.Fallback,
MaxTokens: g.MaxTokens,
Reasoning: &AdminLlmReasoning{Kind: strings.TrimSpace(g.Reasoning.Kind)},
PersonaModels: cloneStringMap(g.PersonaModels),
PersonaProviders: cloneStringMap(g.PersonaProviders),
}
}
// cloneStringMap 浅拷贝一张 persona 路由表。nil 保持 nil,因此 omitempty 仍会
// 省略该键——「该组没有 per-persona 绑定」与「绑定表为空对象」在线上是同一种
// 状态(都表示没有路由),不需要两套表示。
func cloneStringMap(m map[string]string) map[string]string {
if m == nil {
return nil
}
out := make(map[string]string, len(m))
for k, v := range m {
out[k] = v
}
return out
}
// normalizePersonaMap 规整面板提交的 persona 路由表:键与值去除首尾空白。
// 只做规整,绝不丢弃或改写成别的值——空键/空值/未知协议名一律留给
// runtimeconfig.Group.Validate 报错(写端点据此回 400),因此非法输入
// 永远不会被静默吸收。规整的理由是路由查找用的是 trim 后的 persona id,
// 带空白的键会变成永远匹配不上的静默死配置;两个键在 trim 后撞到一起
// (" cyrene " 与 "cyrene")则直接报错,而不是悄悄让其中一个消失。
func normalizePersonaMap(field string, m map[string]string) (map[string]string, error) {
if m == nil {
return nil, nil
}
out := make(map[string]string, len(m))
for k, v := range m {
key := strings.TrimSpace(k)
if _, dup := out[key]; dup {
return nil, fmt.Errorf("%s: persona id %q 去除首尾空白后与另一条目重复", field, key)
}
out[key] = strings.TrimSpace(v)
}
return out, nil
}
@@ -0,0 +1,276 @@
package gateway
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"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"
)
// 阶段 5 新增的模型配置字段(reasoning / personaModels / personaProviders /
// multimodal)在管理面板与 admin DTO 上的读写锁定:读侧必须如实回填(否则
// 面板一保存就把键抹掉),写侧必须落库,非法值必须 400 显式报错。
// llmAddResult 是 POST /api/v1/admin/llm 的响应体。
type llmAddResult struct {
Provider AdminLlmProvider `json:"provider"`
Persisted bool `json:"persisted"`
Path string `json:"path"`
}
// postLlmProvider 发一次 provider 保存请求并返回响应。
func postLlmProvider(t *testing.T, ts *httptest.Server, tok string, body map[string]any) (*http.Response, []byte) {
t.Helper()
return doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm", body, tok)
}
// llmEnvForTest 是测试用的 .env 兜底 LLM 块。
func llmEnvForTest() config.LLMConfig {
return config.LLMConfig{Provider: "openai", BaseURL: "https://api.openai.com/v1", APIKey: "sk-env", Model: "gpt-4o"}
}
// llmTestEnv 起一个接线的 LLM 栈 + models.json 落盘路径,返回
// (models.json 路径, 测试服务, owner token)。
func llmTestEnv(t *testing.T) (string, *httptest.Server, string) {
t.Helper()
modelsPath := filepath.Join(t.TempDir(), "models.json")
runtime := newLlmRuntimeForGateway(t, modelsPath, llmEnvForTest())
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime})
return modelsPath, ts, signAny(t, jwt, "acct", auth.RoleOwner)
}
// storedGroups 直接从磁盘读回 models.json(真实落盘断言,绕过运行中的栈)。
func storedGroups(t *testing.T, path string) ([]runtimeconfig.Group, string) {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var groups []runtimeconfig.Group
if err := json.Unmarshal(raw, &groups); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return groups, string(raw)
}
// findStored 返回磁盘上 id 对应的分组。
func findStored(t *testing.T, groups []runtimeconfig.Group, id string) runtimeconfig.Group {
t.Helper()
g, ok := runtimeconfig.Find(groups, id)
if !ok {
t.Fatalf("group %q not on disk: %+v", id, groups)
}
return g
}
func TestAdminAddLlmProviderPersistsNewFields(t *testing.T) {
t.Parallel()
modelsPath, ts, tok := llmTestEnv(t)
resp, body := postLlmProvider(t, ts, tok, map[string]any{
"id": "vllm-qwen", "provider": "openai", "baseUrl": "https://vllm.local/v1",
"apiKey": "sk-vllm", "model": "qwen3-max", "multimodal": true,
"reasoning": map[string]any{"kind": "vllm"},
"personaModels": map[string]any{"cyrene": "qwen3-max", "natsume": "qwen3-32b"},
"personaProviders": map[string]any{"natsume": "anthropic"},
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
var res llmAddResult
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
t.Fatalf("decode add: %v", err)
}
p := res.Provider
if p.Reasoning == nil || p.Reasoning.Kind != "vllm" || !p.Multimodal ||
p.PersonaModels["cyrene"] != "qwen3-max" || p.PersonaModels["natsume"] != "qwen3-32b" ||
p.PersonaProviders["natsume"] != "anthropic" {
t.Fatalf("saved provider = %+v, want the new fields echoed back", p)
}
// models.json 真的含这些键与值(不只是内存栈里对)。
groups, raw := storedGroups(t, modelsPath)
g := findStored(t, groups, "vllm-qwen")
if g.Reasoning.Kind != "vllm" || !g.Multimodal || g.PersonaModels["cyrene"] != "qwen3-max" ||
g.PersonaProviders["natsume"] != "anthropic" {
t.Fatalf("stored group = %+v, want reasoning/persona routing persisted", g)
}
for _, key := range []string{`"reasoning"`, `"personaModels"`, `"personaProviders"`, `"multimodal"`} {
if !strings.Contains(raw, key) {
t.Fatalf("models.json missing key %s: %s", key, raw)
}
}
// GET /admin/llm 同样回填这些字段(面板靠它播种表单)。
gResp, gBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/llm", nil, tok)
if gResp.StatusCode != http.StatusOK {
t.Fatalf("get status = %d, want 200 (body=%s)", gResp.StatusCode, gBody)
}
var cfg AdminLlmConfig
if err := json.Unmarshal(decodeBody(t, gBody)["data"], &cfg); err != nil {
t.Fatalf("decode get: %v", err)
}
got, ok := findDTO(cfg.Providers, "vllm-qwen")
if !ok || got.Reasoning == nil || got.Reasoning.Kind != "vllm" || got.PersonaModels["cyrene"] != "qwen3-max" {
t.Fatalf("GET providers = %+v, want vllm-qwen with reasoning vllm and persona route", cfg.Providers)
}
}
// findDTO 在线上列表里按 id 找分组。
func findDTO(list []AdminLlmProvider, id string) (AdminLlmProvider, bool) {
for _, p := range list {
if p.ID == id {
return p, true
}
}
return AdminLlmProvider{}, false
}
func TestAdminAddLlmProviderPartialEditKeepsNewFields(t *testing.T) {
t.Parallel()
modelsPath, ts, tok := llmTestEnv(t)
seed, body := postLlmProvider(t, ts, tok, map[string]any{
"id": "vllm-qwen", "provider": "openai", "baseUrl": "https://vllm.local/v1",
"apiKey": "sk-vllm", "model": "qwen3-max", "multimodal": true,
"reasoning": map[string]any{"kind": "vllm"},
"personaModels": map[string]any{"cyrene": "qwen3-max"},
"personaProviders": map[string]any{"cyrene": "openai"},
})
if seed.StatusCode != http.StatusCreated {
t.Fatalf("seed status = %d, want 201 (body=%s)", seed.StatusCode, body)
}
// 只改 model:新字段一个都不在请求里,必须原样保留。
resp, body2 := postLlmProvider(t, ts, tok, map[string]any{"id": "vllm-qwen", "model": "qwen3-vl"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("edit status = %d, want 201 (body=%s)", resp.StatusCode, body2)
}
groups, _ := storedGroups(t, modelsPath)
g := findStored(t, groups, "vllm-qwen")
if g.Model != "qwen3-vl" || g.Reasoning.Kind != "vllm" || !g.Multimodal ||
g.PersonaModels["cyrene"] != "qwen3-max" || g.PersonaProviders["cyrene"] != "openai" {
t.Fatalf("stored group = %+v, want model updated and new fields preserved", g)
}
}
func TestAdminAddLlmProviderClearsNewFieldsExplicitly(t *testing.T) {
t.Parallel()
modelsPath, ts, tok := llmTestEnv(t)
seed, body := postLlmProvider(t, ts, tok, map[string]any{
"id": "vllm-qwen", "provider": "openai", "baseUrl": "https://vllm.local/v1",
"apiKey": "sk-vllm", "model": "qwen3-max", "multimodal": true,
"reasoning": map[string]any{"kind": "vllm"},
"personaModels": map[string]any{"cyrene": "qwen3-max"},
"personaProviders": map[string]any{"cyrene": "openai"},
})
if seed.StatusCode != http.StatusCreated {
t.Fatalf("seed status = %d, want 201 (body=%s)", seed.StatusCode, body)
}
// 显式清空:reasoning 给出但 kind 空 = 回到「未声明」,两张路由表给空对象
// = 整表替换成空(面板据此能删掉一条已存路由)。
resp, body2 := postLlmProvider(t, ts, tok, map[string]any{
"id": "vllm-qwen", "reasoning": map[string]any{}, "personaModels": map[string]any{},
"personaProviders": map[string]any{},
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("clear status = %d, want 201 (body=%s)", resp.StatusCode, body2)
}
groups, raw := storedGroups(t, modelsPath)
g := findStored(t, groups, "vllm-qwen")
if g.Reasoning.Kind != "" || len(g.PersonaModels) != 0 || len(g.PersonaProviders) != 0 {
t.Fatalf("stored group = %+v, want reasoning/persona routing cleared", g)
}
if strings.Contains(raw, "personaModels") || strings.Contains(raw, `"kind"`) {
t.Fatalf("cleared keys must be omitted from models.json: %s", raw)
}
}
func TestAdminAddLlmProviderRejectsInvalidNewFields(t *testing.T) {
t.Parallel()
modelsPath, ts, tok := llmTestEnv(t)
seed, body := postLlmProvider(t, ts, tok, map[string]any{
"id": "base-group", "provider": "openai", "baseUrl": "https://api.example.com/v1",
"apiKey": "sk-base", "model": "m1", "reasoning": map[string]any{"kind": "auto"},
})
if seed.StatusCode != http.StatusCreated {
t.Fatalf("seed status = %d, want 201 (body=%s)", seed.StatusCode, body)
}
cases := []struct {
name string
body map[string]any
}{
{"未知 reasoning.kind", map[string]any{"id": "base-group", "reasoning": map[string]any{"kind": "gemini"}}},
{"anthropic 组不得声明 openai 系形状", map[string]any{
"id": "anthropic-group", "provider": "anthropic", "baseUrl": "https://api.anthropic.com",
"model": "claude-sonnet-4", "reasoning": map[string]any{"kind": "vllm"},
}},
{"personaProviders 非法协议名", map[string]any{
"id": "base-group", "personaProviders": map[string]any{"cyrene": "gemini"},
}},
{"personaModels 空值", map[string]any{"id": "base-group", "personaModels": map[string]any{"cyrene": " "}}},
{"personaModels 空 persona id", map[string]any{"id": "base-group", "personaModels": map[string]any{"": "m2"}}},
{"persona 键去空白后重复", map[string]any{
"id": "base-group", "personaModels": map[string]any{"cyrene": "m2", " cyrene ": "m3"},
}},
}
for _, tc := range cases {
resp, respBody := postLlmProvider(t, ts, tok, tc.body)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("%s: status = %d, want 400 (body=%s)", tc.name, resp.StatusCode, respBody)
}
if errObj := decodeBody(t, respBody)["error"]; len(errObj) == 0 {
t.Fatalf("%s: 400 without an error envelope (body=%s)", tc.name, respBody)
}
}
// 被拒的请求绝不落库:磁盘上仍是种子分组(auto / 无路由表),也没有
// 出现 anthropic-group 这样的半成品。
groups, raw := storedGroups(t, modelsPath)
g := findStored(t, groups, "base-group")
if g.Reasoning.Kind != runtimeconfig.ReasoningKindAuto || len(g.PersonaModels) != 0 || len(g.PersonaProviders) != 0 {
t.Fatalf("seed group changed by a rejected write: %+v", g)
}
if _, ok := runtimeconfig.Find(groups, "anthropic-group"); ok {
t.Fatalf("a rejected group was persisted: %+v", groups)
}
if strings.Contains(raw, "gemini") {
t.Fatalf("a rejected value leaked into models.json: %s", raw)
}
}
func TestAdminGetLlmReportsFieldValueDomain(t *testing.T) {
t.Parallel()
_, ts, tok := llmTestEnv(t)
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/llm", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var cfg AdminLlmConfig
if err := json.Unmarshal(decodeBody(t, body)["data"], &cfg); err != nil {
t.Fatalf("decode get: %v", err)
}
// 面板的候选直接来自后端:与配置层校验同一份清单。
if strings.Join(cfg.ReasoningKinds, ",") != strings.Join(runtimeconfig.ReasoningKinds(), ",") {
t.Fatalf("reasoningKinds = %v, want %v", cfg.ReasoningKinds, runtimeconfig.ReasoningKinds())
}
if strings.Join(cfg.PersonaProviderOptions, ",") != strings.Join(runtimeconfig.ProviderNames(), ",") {
t.Fatalf("personaProviderOptions = %v, want %v", cfg.PersonaProviderOptions, runtimeconfig.ProviderNames())
}
// 未接线 models.json 时也要如实回填(env 兜底分组同样带 reasoning 块)。
if len(cfg.Providers) == 0 || cfg.Providers[0].Reasoning == nil {
t.Fatalf("env-fallback provider = %+v, want a reasoning block", cfg.Providers)
}
}
+9 -13
View File
@@ -274,24 +274,20 @@ func (s *Server) adminListIoT(w http.ResponseWriter, r *http.Request) {
// adminGetLlm 返回从实时 provider 栈读取的 LLM provider/model
// 配置(models.json,回退到 .env)。API key 绝不返回 ——
// 线上结构中不含 key 字段 —— 因此该 handler
// 不泄露任何敏感信息。
// 不泄露任何敏感信息。除分组本身外还下发**表单选项的取值域**
// (reasoningKinds / personaProviderOptions),使面板的下拉框与后端校验
// 永远同一份来源。
func (s *Server) adminGetLlm(w http.ResponseWriter, r *http.Request) {
groups := s.currentLlmGroups()
providers := make([]AdminLlmProvider, 0, len(groups))
for i, g := range groups {
providers = append(providers, AdminLlmProvider{
ID: g.ID,
Name: providerDisplayName(g),
Provider: g.Provider,
BaseURL: g.BaseURL,
Model: g.Model,
Multimodal: g.Multimodal,
Enabled: i == 0,
Fallback: g.Fallback,
MaxTokens: g.MaxTokens,
})
providers = append(providers, adminLlmProviderDTO(g, providerDisplayName(g), i == 0))
}
writeJSON(w, http.StatusOK, AdminLlmConfig{Providers: providers})
writeJSON(w, http.StatusOK, AdminLlmConfig{
Providers: providers,
ReasoningKinds: runtimeconfig.ReasoningKinds(),
PersonaProviderOptions: runtimeconfig.ProviderNames(),
})
}
// currentLlmGroups 返回要展示的 provider 分组:接线了实时栈时
@@ -36,12 +36,19 @@ const (
llmProbePrompt = "Reply with the single word: OK"
)
// llmReasoningRequest 是请求体里的推理开关形状块。整块缺省 = 保留已存形状;
// 整块给出(哪怕只有 `{}`)= 整块替换,因此面板能把形状改回「未声明」。
type llmReasoningRequest struct {
Kind *string `json:"kind"`
}
// llmProviderRequest 是 POST /api/v1/admin/llm 的请求体,用于添加/更新
// provider 分组。每个字段都是指针,使 handler 能区分「缺失」
// (保留已存值)与「显式零值」(覆盖):来自面板的局部
// provider 分组。每个字段都是指针(映射类型用 nil 判定),使 handler 能区分
// 「缺失」(保留已存值)与「显式零值」(覆盖):来自面板的局部
// 编辑绝不清除它未触碰的字段。Name 是客户端侧
// 显示标签(不持久化 —— models.json 结构含 id/provider/
// baseUrl/apiKey/model/fallback/multimodal/maxTokens)。
// baseUrl/apiKey/model/fallback/multimodal/maxTokens/reasoning/personaModels/
// personaProviders)。
type llmProviderRequest struct {
ID *string `json:"id"`
Name *string `json:"name"`
@@ -54,6 +61,14 @@ type llmProviderRequest struct {
// MaxTokens 是该分组自身的 token 预算。缺失则保留已存
// 预算(新分组 0 = 未设置)。负值被拒绝。
MaxTokens *int `json:"maxTokens"`
// Reasoning 缺失 = 保留已存形状;给出(含 `{}`)= 用其中的 kind 覆盖
// (kind 缺失/空串 = 回到「未声明」,等同 auto)。
Reasoning *llmReasoningRequest `json:"reasoning"`
// PersonaModels / PersonaProviders 是 persona 路由表,缺失 = 保留已存表,
// 给出(含 `{}`)= 整表替换。用整表替换而非合并:否则面板无法删除一条
// 已存路由,只能覆盖它的值。
PersonaModels map[string]string `json:"personaModels"`
PersonaProviders map[string]string `json:"personaProviders"`
}
// adminAddLlmProvider 把 provider 分组保存到 models.json(read-modify-write,
@@ -98,17 +113,7 @@ func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_reload_failed", "saved but failed to hot-reload"))
return
}
saved := AdminLlmProvider{
ID: group.ID,
Name: providerDisplayName(group),
Provider: group.Provider,
BaseURL: group.BaseURL,
Model: group.Model,
Multimodal: group.Multimodal,
Enabled: group.ID == s.llm.Group().ID,
Fallback: group.Fallback,
MaxTokens: group.MaxTokens,
}
saved := adminLlmProviderDTO(group, providerDisplayName(group), group.ID == s.llm.Group().ID)
s.auditClaims(r, "llm.addProvider", "ok")
writeJSON(w, http.StatusCreated, map[string]any{
"provider": saved,
@@ -119,7 +124,10 @@ func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
// mergeLlmProvider 把局部 provider 补丁施加到已存分组之上:
// 请求中出现的字段胜出,缺失字段保留已存值(新分组
// 则用其默认值)。它返回经过校验的分组。
// 则用其默认值)。它返回经过校验的分组——校验走配置层同一入口
// (runtimeconfig.Group.Validate),因此非法的 reasoning.kind /
// 非法的 personaProviders 协议名都在这里以错误浮出(写端点回 400),
// 绝不静默丢弃或悄悄改成别的值。
func mergeLlmProvider(groups []runtimeconfig.Group, req llmProviderRequest) (runtimeconfig.Group, error) {
id := ""
if req.ID != nil {
@@ -160,12 +168,42 @@ func mergeLlmProvider(groups []runtimeconfig.Group, req llmProviderRequest) (run
if req.MaxTokens != nil {
g.MaxTokens = *req.MaxTokens
}
if req.Reasoning != nil {
g.Reasoning = runtimeconfig.ReasoningOptions{Kind: reasoningKindOf(req.Reasoning)}
}
if req.PersonaModels != nil {
models, err := normalizePersonaMap("personaModels", req.PersonaModels)
if err != nil {
return runtimeconfig.Group{}, err
}
g.PersonaModels = models
}
if req.PersonaProviders != nil {
providers, err := normalizePersonaMap("personaProviders", req.PersonaProviders)
if err != nil {
return runtimeconfig.Group{}, err
}
g.PersonaProviders = providers
}
if g.BaseURL == "" || g.Model == "" {
return runtimeconfig.Group{}, fmt.Errorf("baseUrl and model are required")
}
if err := g.Validate(); err != nil {
return runtimeconfig.Group{}, err
}
return g, nil
}
// reasoningKindOf 取出请求里的形状名:kind 缺失/空串 = 未声明形状(等同 auto,
// 等同不写这把键),因此面板能显式「改回未声明」而不是被局部编辑语义锁死在
// 已存形状上。
func reasoningKindOf(req *llmReasoningRequest) string {
if req == nil || req.Kind == nil {
return ""
}
return strings.TrimSpace(*req.Kind)
}
// llmTestRequest 是 POST /api/v1/admin/llm/test 的请求体。
type llmTestRequest struct {
Provider string `json:"provider"`