From 1ac02bd77e5d53a63d9858e6adab2b759e37fddc Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 13:06:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(gateway,admin-web):=20=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E5=8F=AF=E7=BC=96=E8=BE=91=20provider=20=E6=8E=A8=E7=90=86?= =?UTF-8?q?=E5=BD=A2=E7=8A=B6=20/=20=E8=A7=92=E8=89=B2=E7=BA=A7=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=20/=20multimodal=20=E7=9C=9F=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=EF=BC=88=E8=A3=81=E5=86=B3=2003.4.2/03.4.1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: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」切换 --- frontend/web/src/admin/modules/Llm.tsx | 72 +++-- .../src/admin/modules/llm/LlmCatalogPanel.tsx | 62 ++-- .../src/admin/modules/llm/LlmProviderForm.tsx | 119 ++++++- .../admin/modules/llm/LlmProviderRouting.tsx | 302 ++++++++++++++++++ frontend/web/src/lib/types/admin.ts | 20 ++ frontend/web/src/lib/types/adminWrite.ts | 10 + internal/config/runtimeconfig/models.go | 24 +- internal/services/gateway/admin.go | 41 ++- .../services/gateway/admin_llm_provider.go | 67 ++++ .../gateway/admin_llm_provider_test.go | 276 ++++++++++++++++ internal/services/gateway/admin_query.go | 22 +- .../gateway/admin_write_llm_channels.go | 68 +++- 12 files changed, 998 insertions(+), 85 deletions(-) create mode 100644 frontend/web/src/admin/modules/llm/LlmProviderRouting.tsx create mode 100644 internal/services/gateway/admin_llm_provider.go create mode 100644 internal/services/gateway/admin_llm_provider_test.go diff --git a/frontend/web/src/admin/modules/Llm.tsx b/frontend/web/src/admin/modules/Llm.tsx index c9d9956..1d494b1 100644 --- a/frontend/web/src/admin/modules/Llm.tsx +++ b/frontend/web/src/admin/modules/Llm.tsx @@ -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 (
{p.name} @@ -29,9 +31,13 @@ function ProviderRow({ {p.maxTokens ? `maxTokens ${p.maxTokens}` : "maxTokens 默认"} + + {`推理 ${p.reasoning?.kind || "未声明"}`} + + {routes > 0 ? {`按角色路由 ${routes}`} : null} {p.enabled ? "启用" : "停用"} - {p.multimodal ? "多模态" : "文本"} + {p.multimodal ? "主模型可收图" : "主模型不收图"} @@ -131,7 +137,7 @@ function LlmPageStatus({ fail, result }: { fail: string | null; result: string | <> {fail ? {fail} : null} {result ? {result} : 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 ( +

+ 按角色路由: + {personas + .map((id) => `${id} → ${models[id] || "(未配模型,不生效)"}${providers[id] ? `(协议 ${providers[id]})` : ""}`) + .join(",")} +

+ ); +} + +/** 生效链:只画接口真的返回过的分组、它们的备选模型与已配置的 per-persona 路由; + * 本地第三级由后端开关决定、本端点不报告其运行态,因此不臆造。 */ function FallbackChain({ providers }: { providers: AdminLlmProvider[] }): React.ReactNode { return ( -

生效链(来自接口返回的真实分组)

+

生效链与按角色路由(来自接口返回的真实分组)

{providers.length === 0 ? (

暂无分组:上面保存一个之后,这里会显示它的主/备链。

) : ( providers.map((p) => ( -
- {p.enabled ? "生效组" : "备用组"} - {p.model} - → {p.fallback || "无备选模型"} - 接口报错自动降级 +
+
+ {p.enabled ? "生效组" : "备用组"} + {p.model} + → {p.fallback || "无备选模型"} + 接口报错自动降级 +
+
)) )} - 本地模型第三级与 per-persona 路由由后端开关/配置决定,本端点不报告它们的运行态,因此这里不显示,也不臆造。 + 本地模型第三级由后端开关与本地组配置决定,本端点不报告它的运行态,因此这里不显示,也不臆造。 ); @@ -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(null); const [result, setResult] = useState(null); + // 目录候选带进来的能力建议(可推翻);没有建议时是 null。 + const [hint, setHint] = useState(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)} /> { + 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} /> - 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,由后端缓存成快照(离线仍可用),绝不进模型运行时热路径。
); diff --git a/frontend/web/src/admin/modules/llm/LlmCatalogPanel.tsx b/frontend/web/src/admin/modules/llm/LlmCatalogPanel.tsx index 63970b1..d7f975d 100644 --- a/frontend/web/src/admin/modules/llm/LlmCatalogPanel.tsx +++ b/frontend/web/src/admin/modules/llm/LlmCatalogPanel.tsx @@ -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({
{model.name} {model.id} - {model.attachment ? 多模态 : null} - {model.reasoning ? 推理 : null} + {model.attachment === true ? 可收图 : null} + {model.reasoning === true ? 推理 : null} {model.toolCall ? 工具 : null} {model.outputLimit ? {`输出上限 ${model.outputLimit}`} : null} {provider.api ? null : 目录未标 baseURL} @@ -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; diff --git a/frontend/web/src/admin/modules/llm/LlmProviderForm.tsx b/frontend/web/src/admin/modules/llm/LlmProviderForm.tsx index 5576448..667fee6 100644 --- a/frontend/web/src/admin/modules/llm/LlmProviderForm.tsx +++ b/frontend/web/src/admin/modules/llm/LlmProviderForm.tsx @@ -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 = (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 ( <>
@@ -84,9 +117,21 @@ function ProviderFields({ form, set, editing }: { form: FormState; set: FieldSet />
-
- set("multimodal", v)} label="多模态" /> -
+ set("multimodal", v)} /> + set("reasoning", v)} + /> + 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(() => 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({ ) : null}
- +
- {editing ? "只提交本表单填写的字段,未填字段保持原值" : "保存后写入 models.json 并热更新运行中的 LLM provider"} + {editing + ? "只提交本表单填写的字段,未填字段保持原值(按角色路由与推理形状按当前表单内容整块提交)" + : "保存后写入 models.json 并热更新运行中的 LLM provider"}
@@ -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"; + diff --git a/frontend/web/src/admin/modules/llm/LlmProviderRouting.tsx b/frontend/web/src/admin/modules/llm/LlmProviderRouting.tsx new file mode 100644 index 0000000..012c072 --- /dev/null +++ b/frontend/web/src/admin/modules/llm/LlmProviderRouting.tsx @@ -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; + personaProviders: Record; +} { + const personaModels: Record = {}; + const personaProviders: Record = {}; + 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(); + 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 ( +
+ +

+ 勾选 = 主模型自己接收图片输入。图片识别 / OCR 工具用的模型由 .env 的 LLM_VISION_MODEL / + LLM_OCR_MODEL 另配,与这里无关。 +

+ {hint ? ( +

+ 目录(models.dev)对 {hint.model} 的标注: + {hint.attachment === true + ? "接受图片输入" + : hint.attachment === false + ? "不接受图片输入" + : "未标注该能力(请按你的端点实际能力填)"} + {hint.supportsReasoning ? " · 支持推理(开关形状仍需按端点选)" : ""} —— 只是建议,可随时推翻。 +

+ ) : ( +

未从目录带入建议(目录不可用或本次是手填),不影响编辑。

+ )} +
+ ); +} + +/** reasoning.kind 的展示文案:键是后端下发的取值,值是**形状说明**。 + * 后端将来新增的取值没有文案时原样显示,不编造含义。 */ +const REASONING_LABEL: Record = { + 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 ( +
+ + {kinds.length === 0 ? ( +

+ 后端未下发可选形状(响应缺少 reasoningKinds):这里只能选「未声明」,或按 design/04 §3.2 手填后由后端校验(非法值会 400)。 +

+ ) : null} + {provider === "anthropic" && value !== "" && value !== "auto" ? ( +

+ anthropic 组只接受「未声明 / auto」:这些形状属于 OpenAI 兼容端点,保存时后端会回 400。 +

+ ) : null} +
+ ); +} + +/** 一行 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) => 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 ( +
+ onPatch(index, { persona: e.target.value })} + list={listId} + placeholder="persona id" + className={`${inputCls} w-40`} + aria-label="persona id" + /> + onPatch(index, { model: e.target.value })} + placeholder="模型名(留空 = 不绑定)" + className={`${inputCls} w-56 font-mono text-xs`} + aria-label="该 persona 的模型名" + /> + + {dead ? 只有协议没有模型 → 不生效 : null} + + + +
+ ); +} + +/** 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): void => + onChange(routes.map((r, i) => (i === index ? { ...r, ...p } : r))); + const remove = (index: number): void => onChange(routes.filter((_, i) => i !== index)); + return ( +
+
+ 按角色路由(persona → 模型 / 协议) + + 未列出的 persona 一律用本组默认模型(协议 {groupProvider || "未定"}) + + +
+ + {knownIds.map((id) => ( + + {personaListError ? ( +

+ 角色清单未取到({personaListError}):persona id 仍可手填,不影响保存。 +

+ ) : null} + {routes.length === 0 ? ( +

没有 per-persona 路由:该组所有角色都用本组的 model。

+ ) : ( + routes.map((r, i) => ( + + )) + )} + {providerOptions.length === 0 ? ( +

+ 后端未下发可选的 persona 协议(响应缺少 personaProviderOptions):这里只能选「跟随本组协议」。 +

+ ) : null} +
+ ); +} + +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"; diff --git a/frontend/web/src/lib/types/admin.ts b/frontend/web/src/lib/types/admin.ts index afd06c6..c7ff1cd 100644 --- a/frontend/web/src/lib/types/admin.ts +++ b/frontend/web/src/lib/types/admin.ts @@ -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; + /** persona → wire 协议的覆盖表;取值只能是 personaProviderOptions。 */ + personaProviders?: Record; } export interface AdminLlmConfig { providers: AdminLlmProvider[]; + /** `reasoning.kind` 的合法取值(不含空串),由后端配置层直接下发 —— 面板的 + * 下拉框据此渲染候选,绝不自己硬编码一套枚举。旧构建缺省该字段。 */ + reasoningKinds?: string[]; + /** `personaProviders` 值的合法取值(协议名)。旧构建缺省该字段。 */ + personaProviderOptions?: string[]; } /** 目录里的一个模型能力位(GET /api/v1/admin/llm/catalog)。attachment 为 true diff --git a/frontend/web/src/lib/types/adminWrite.ts b/frontend/web/src/lib/types/adminWrite.ts index 6770546..787e119 100644 --- a/frontend/web/src/lib/types/adminWrite.ts +++ b/frontend/web/src/lib/types/adminWrite.ts @@ -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; + /** persona → 协议覆盖表,规则同上;取值非法(非 openai/anthropic)回 400。 */ + personaProviders?: Record; } /** POST /api/v1/admin/llm/test body. */ diff --git a/internal/config/runtimeconfig/models.go b/internal/config/runtimeconfig/models.go index cec48fc..f66c9ff 100644 --- a/internal/config/runtimeconfig/models.go +++ b/internal/config/runtimeconfig/models.go @@ -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) diff --git a/internal/services/gateway/admin.go b/internal/services/gateway/admin.go index cf565fe..5e13cc3 100644 --- a/internal/services/gateway/admin.go +++ b/internal/services/gateway/admin.go @@ -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 是管理列表中一条自动化/提醒的线上结构。 diff --git a/internal/services/gateway/admin_llm_provider.go b/internal/services/gateway/admin_llm_provider.go new file mode 100644 index 0000000..92755fe --- /dev/null +++ b/internal/services/gateway/admin_llm_provider.go @@ -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 +} diff --git a/internal/services/gateway/admin_llm_provider_test.go b/internal/services/gateway/admin_llm_provider_test.go new file mode 100644 index 0000000..5769456 --- /dev/null +++ b/internal/services/gateway/admin_llm_provider_test.go @@ -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) + } +} diff --git a/internal/services/gateway/admin_query.go b/internal/services/gateway/admin_query.go index c1ede4b..5c3468c 100644 --- a/internal/services/gateway/admin_query.go +++ b/internal/services/gateway/admin_query.go @@ -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 分组:接线了实时栈时 diff --git a/internal/services/gateway/admin_write_llm_channels.go b/internal/services/gateway/admin_write_llm_channels.go index e61a14d..bcb8115 100644 --- a/internal/services/gateway/admin_write_llm_channels.go +++ b/internal/services/gateway/admin_write_llm_channels.go @@ -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"`