feat(admin): 面板补全 — 插件安装表单+已装列表 / LLM组maxTokens展示编辑 / POST admin/llm 部分编辑合并
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 前端: api.ts(adminPluginInstall带config/uninstall/listInstalledPlugins) + types + plugins/InstalledPlugins.tsx(已装列表+安装表单, Miuix) + llm/LlmProviderForm.tsx(maxTokens输入) ; Plugins.tsx去假安装按钮
- 后端 admin_write_llm_channels.go: llmProviderRequest字段改指针, mergeLlmProvider部分合并(省略保留原值), runtimeconfig/models.go Find
- 新增测试 admin_write_test TestAdminAddLlmProviderPartialEditPreservesFields
- 前端 build + 后端 build/vet/test 全绿; Docs/api-client v0.29
- (面板无法实机登录, 用 react-dom/server 冒烟渲染 LlmModule/PluginsModule)
This commit is contained in:
AskaEth committed 2026-09-08 23:47:09 +08:00
1 parent 70c9fba529
commit 18b2040c95
10 files changed
+812 -220

No files matched your search

+1
View File
@@ -216,6 +216,7 @@
- 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。
## 5. 变更记录
- `v0.29`(**/admin/llm 读-改-写合并**):`POST /api/v1/admin/llm` 由「全量替换」改为**部分编辑合并**——请求体字段用指针区分「未给」与「显式零值」;`maxTokens` / `apiKey` / `provider` / `baseUrl` / `model` / `fallback` / `multimodal` **省略即保留原值**(不再把省略的 `maxTokens` 重置为 0),新组仍要求 `baseUrl`+`model`、非法 provider / 负 maxTokens 仍 `400`。面板 LLM 页新增每组 `maxTokens` 展示/编辑 + 新增/编辑表单;面板插件页新增「已装插件列表」+「安装 MCP 插件」表单(kind/name/transport/endpoint|command+args/apiKey/env),api.ts 请求体带 `config`(修掉旧 body 400)。`runtimeconfig/models.go` 新增 `Find`。
- `v0.28`(**MCP 插件热安装/卸载**):`POST /api/v1/admin/plugins/install`(**由 501 改为真实实现**,owner-only + 审计)body `{kind:"mcp",name,config:{transport,endpoint?|command?,args?,apiKey?,env?}}` → `200 {data:{installed,name,kind,transport,toolCount}}`——真实连接 MCP server、发现并**热注册工具**进 capability registry(下一轮模型请求即注入),配置落盘 `plugins.json`(gitignore、0600);连接失败回 `502` 且**不落盘**;同名重复 = 刷新。`POST /api/v1/admin/plugins/uninstall` 扩展:MCP 插件走 `Unregister`(移除工具 + close + 删配置),普通能力保持原 `registry.Remove`。新增 `GET /api/v1/admin/plugins/installed`(owner/admin 读)列出已装插件(`apiKey`/敏感 `env` 掩码 + `live`/`toolCount`)。`mcp.MCPConfig` 新增 `APIKey`(http/sse 以 `Authorization: Bearer` 发送)与 `Env`(stdio 子进程环境);`plugins.json` 于启动时 `Restore` 重新安装。**契约版本 `v0.28`**。
- `v0.27`(**LLM token 预算可配置化**):`models.json` 模型组新增可选 **`maxTokens`**(`int`,缺省/`0` = 未设),作为该组主/备链的 token 预算;`GET /api/v1/admin/llm` 的 `AdminLlmProvider` 与 `POST /api/v1/admin/llm` body/回显同步该字段(`omitempty`,未设为 0 时不出现)。`.env` 新增 **`LLM_FALLBACK_MAX_TOKENS`**(`int`,默认 `8192`,`0` = 关闭兜底),只作用于**确有回退模型**时链末端的回退步。**优先级**:显式 `Request.MaxTokens`(审查/判定类小预算)> 生效组 `maxTokens` > `LLM_FALLBACK_MAX_TOKENS`(仅链末端、且组未设时)> 适配器默认(anthropic 8192)。单步链(无 fallback)不套用 env 兜底。`maxTokens` 为负回 `400 bad_request`。**契约版本 `v0.27`**。
- `v0.26`(**A5 消息正向增量**):`GET /api/v1/sessions/{id}/messages` 新增 **`after`** 查询参数(`<createdAt 毫秒>`,升序返回 `created_at > after` 的消息,默认 `limit=50`/上限 200)——解决「WS 重连只回放主会话最近 50 条、断线超 50 条永久丢」的缺口;`before` 现行为不变;**`after` 与 `before` 互斥**(同传 `400`);负数/非数字 `400`。`HistoryStore` 新增 `ListAfter`(走 `idx_chat_msgs_session_created`,`rowid` 兜底同毫秒并列)。**游标契约**:客户端用「本页最后一条 `createdAt`」作下一页 `after`;同毫秒消息 > limit 时纯 `createdAt` 游标会跨页重读(超罕见,客户端按 `id` 去重兜底,与现有绕行一致)。
+52 -89
View File
@@ -1,56 +1,64 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminGetLlm, adminAddLlmProvider, adminTestLlm, isNotImplemented } from "../../lib/api";
import { adminGetLlm, adminTestLlm, isNotImplemented } from "../../lib/api";
import type { AdminLlmProvider, AdminLlmConfig } from "../../lib/types";
import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
import { Button, Surface, Switch, Tag } from "../../components/primitives";
import { Button, Surface, Tag } from "../../components/primitives";
import { LlmProviderForm } from "./llm/LlmProviderForm";
const sample: AdminLlmConfig = {
providers: [
{ id: "p1", name: "主模型", provider: "openai", baseUrl: "https://api.example.com/v1", model: "example-ultra", multimodal: true, enabled: true },
{ id: "p1", name: "主模型", provider: "openai", baseUrl: "https://api.example.com/v1", model: "example-ultra", multimodal: true, enabled: true, maxTokens: 8192 },
{ id: "p2", name: "备选模型", provider: "openai", baseUrl: "https://api.backup.example.com/v1", model: "example-fast", multimodal: false, enabled: true },
],
};
/** One provider group row: identity + current token budget + test/edit actions. */
function ProviderRow({
p,
busy,
onTest,
onEdit,
}: {
p: AdminLlmProvider;
busy: boolean;
onTest: (p: AdminLlmProvider) => void;
onEdit: (p: AdminLlmProvider) => void;
}): React.ReactNode {
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>
<Tag tone="neutral">{p.model}</Tag>
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{p.baseUrl || "—"}</span>
<Tag tone={p.maxTokens ? "accent" : "neutral"}>
{p.maxTokens ? `maxTokens ${p.maxTokens}` : "maxTokens 默认"}
</Tag>
<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>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => onEdit(p)}>
编辑
</Button>
<Button size="sm" variant="ghost" disabled={busy || !p.baseUrl} onClick={() => onTest(p)}>
测试
</Button>
</span>
</div>
);
}
export function LlmModule(): React.ReactNode {
const { data, error, reload } = useAsyncData(adminGetLlm, []);
const log = useAuditStore((s) => s.log);
const backend = !isNotImplemented(error);
const providers = backend && data ? data.providers : sample.providers;
const [name, setName] = useState("");
const [provider, setProvider] = useState("openai");
const [model, setModel] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [multimodal, setMultimodal] = useState(false);
const [editing, setEditing] = useState<AdminLlmProvider | null>(null);
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const [result, setResult] = useState<string | null>(null);
const addProvider = () => {
setBusy(true);
setFail(null);
setResult(null);
adminAddLlmProvider({ id: name || undefined, provider, baseUrl, apiKey, model, multimodal })
.then((r) => {
log("llm.addProvider", "ok");
setResult(`已保存到 models.json 并热更新:${r.provider.name} · ${r.provider.model}${r.path ? `(${r.path})` : ""}`);
setName("");
setModel("");
setBaseUrl("");
setApiKey("");
setMultimodal(false);
reload();
})
.catch((e: unknown) => {
log("llm.addProvider", "error");
setFail(e instanceof Error ? e.message : "新增模型失败");
})
.finally(() => setBusy(false));
};
const test = (p: AdminLlmProvider) => {
setBusy(true);
setFail(null);
@@ -72,12 +80,7 @@ export function LlmModule(): React.ReactNode {
<div className="space-y-5">
<PageHeader
title="LLM 模型"
subtitle="供应商 + 模型配置(base/key/多模态检测) + 测试(真实调用) + fallback + 按角色路由 + 用量"
actions={
<Button variant="primary" disabled={busy || !model || !baseUrl} onClick={addProvider}>
保存模型
</Button>
}
subtitle="供应商 + 模型配置(base/key/多模态检测/token 预算) + 测试(真实调用) + fallback + 按角色路由 + 用量"
/>
{backend && !error ? <Notice tone="ok">后端已返回 LLM 配置(数据以接口为准)。</Notice> : null}
@@ -89,57 +92,20 @@ export function LlmModule(): React.ReactNode {
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">供应商 / 模型</h3>
{providers.map((r) => (
<div key={r.id} 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">{r.name}</span>
<Tag tone="neutral">{r.model}</Tag>
<span className="font-mono text-xs text-[var(--mu-text-lo)]">{r.baseUrl || "—"}</span>
<span className="ml-auto flex items-center gap-4">
<Tag tone={r.enabled ? "ok" : "neutral"}>{r.enabled ? "启用" : "停用"}</Tag>
<Tag tone={r.multimodal ? "accent" : "neutral"}>{r.multimodal ? "多模态" : "文本"}</Tag>
<Button size="sm" variant="ghost" disabled={busy || !r.baseUrl} onClick={() => test(r)}>
测试
</Button>
</span>
</div>
<ProviderRow key={r.id} p={r} busy={busy} onTest={test} onEdit={setEditing} />
))}
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">新增模型(保存到 models.json,即刻热更新)</h3>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">名称 / ID(留空自动生成)</span>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="deepseek" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">协议 Provider</span>
<select value={provider} onChange={(e) => setProvider(e.target.value)} className={inputCls}>
<option value="openai">openai(OpenAI 兼容)</option>
<option value="anthropic">anthropic(Anthropic Messages)</option>
</select>
</label>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">模型</span>
<input value={model} onChange={(e) => setModel(e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">baseURL</span>
<input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.example.com/v1" className={inputCls} />
</label>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">API key(写入 models.json,不回显)</span>
<input value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-…" className={inputCls} />
</label>
<div className="flex items-end gap-2 pb-[2px]">
<Switch checked={multimodal} onChange={setMultimodal} label="多模态" />
</div>
</div>
<p className="text-xs text-[var(--mu-text-lo)]">保存后写入 models.json(本地、已 gitignore),并热更新运行中的 LLM provider,无需重启进程。API key 不在列表/响应中回显。</p>
</Surface>
<LlmProviderForm
key={editing?.id ?? "new"}
initial={editing}
busy={busy}
setBusy={setBusy}
onResult={setResult}
onError={setFail}
onSaved={reload}
onCancel={() => setEditing(null)}
/>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">按角色路由 + 降级链</h3>
@@ -152,10 +118,7 @@ export function LlmModule(): React.ReactNode {
))}
</Surface>
<Notice>per-role 路由 + 降级逻辑由后端 loop 实现;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1)。.env 为兜底。</Notice>
<Notice>per-role 路由 + 降级逻辑由后端 loop 实现;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1)。.env 为兜底。每组 maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。</Notice>
</div>
);
}
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)]";
+63 -68
View File
@@ -1,71 +1,21 @@
import { useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminListPlugins, adminSetPluginPolicy, adminPluginInstall, adminPluginUninstall, getStoredRole } from "../../lib/api";
import { adminListPlugins, adminSetPluginPolicy, adminPluginUninstall, getStoredRole } from "../../lib/api";
import type { AdminPluginRecord } from "../../lib/types";
import { useAutoReload } from "../hooks";
import { useAuditStore } from "../audit";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
import { Button, Surface, Switch, Tag } from "../../components/primitives";
import { InstalledPluginsPanel } from "./plugins/InstalledPlugins";
export function PluginsModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListPlugins, []);
useAutoReload(reload, 10000);
const isOwner = getStoredRole() === "owner";
const log = useAuditStore((s) => s.log);
const [busy, setBusy] = useState<string | null>(null);
const [fail, setFail] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const install = () => {
setBusy("__install__");
setFail(null);
setMsg(null);
adminPluginInstall({ name: "sample-plugin", kind: "tool", description: "手工安装示例" })
.then(() => {
log("plugin.install", "ok");
setMsg("插件安装成功");
reload();
})
.catch((err: unknown) => {
log("plugin.install", "error");
setFail(err instanceof Error ? err.message : "插件安装失败");
})
.finally(() => setBusy(null));
};
const uninstall = (name: string) => {
setBusy(name);
setFail(null);
setMsg(null);
adminPluginUninstall({ name })
.then(() => {
log("plugin.uninstall", "ok");
setMsg(`已卸载 ${name}`);
reload();
})
.catch((err: unknown) => {
log("plugin.uninstall", "error");
setFail(err instanceof Error ? err.message : "卸载失败");
})
.finally(() => setBusy(null));
};
const setPolicy = (name: string, allowInGroup: boolean, allowInStranger: boolean) => {
setBusy(name);
setFail(null);
adminSetPluginPolicy(name, allowInGroup, allowInStranger)
.then(() => {
log(`policy.set:${name}`, "ok");
reload();
})
.catch((err: unknown) => {
log(`policy.set:${name}`, "error");
setFail(err instanceof Error ? err.message : "策略更新失败");
})
.finally(() => setBusy(null));
};
const columns: AdminColumn<AdminPluginRecord>[] = [
/** Columns for the registered-capability table (GET /admin/plugins). */
function registeredColumns(
isOwner: boolean,
busy: string | null,
setPolicy: (name: string, allowInGroup: boolean, allowInStranger: boolean) => void,
uninstall: (name: string) => void,
): AdminColumn<AdminPluginRecord>[] {
return [
{ key: "name", title: "名称", render: (p) => <span className="font-medium">{p.name}</span> },
{ key: "kind", title: "类型", render: (p) => <Tag tone="accent">{p.kind}</Tag> },
{ key: "desc", title: "描述", render: (p) => <span className="block max-w-sm truncate text-[var(--mu-text-mid)]">{p.description || "—"}</span> },
@@ -104,24 +54,69 @@ export function PluginsModule(): React.ReactNode {
),
},
];
}
export function PluginsModule(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListPlugins, []);
useAutoReload(reload, 10000);
const isOwner = getStoredRole() === "owner";
const log = useAuditStore((s) => s.log);
const [busy, setBusy] = useState<string | null>(null);
const [fail, setFail] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const uninstall = (name: string) => {
setBusy(name);
setFail(null);
setMsg(null);
adminPluginUninstall(name)
.then(() => {
log("plugin.uninstall", "ok");
setMsg(`已卸载 ${name}`);
reload();
})
.catch((err: unknown) => {
log("plugin.uninstall", "error");
setFail(err instanceof Error ? err.message : "卸载失败");
})
.finally(() => setBusy(null));
};
const setPolicy = (name: string, allowInGroup: boolean, allowInStranger: boolean) => {
setBusy(name);
setFail(null);
adminSetPluginPolicy(name, allowInGroup, allowInStranger)
.then(() => {
log(`policy.set:${name}`, "ok");
reload();
})
.catch((err: unknown) => {
log(`policy.set:${name}`, "error");
setFail(err instanceof Error ? err.message : "策略更新失败");
})
.finally(() => setBusy(null));
};
const columns = registeredColumns(isOwner, busy, setPolicy, uninstall);
return (
<div className="space-y-5">
<PageHeader
title="插件与能力"
subtitle="GET /api/v1/admin/plugins · 启停/安装/卸载/MCP 连接 · POST /api/v1/admin/plugins/policy (owner-only)"
actions={
<Button variant="primary" disabled={busy !== null} onClick={install}>
安装插件
</Button>
}
subtitle="GET /api/v1/admin/plugins · 已注册能力 + 运行时 MCP 安装/卸载 · POST /api/v1/admin/plugins/policy (owner-only)"
/>
{!isOwner ? <Notice tone="warn">当前角色为 {getStoredRole()},工具策略仅 owner 可写(读取/查看可见)。</Notice> : null}
{!isOwner ? <Notice tone="warn">当前角色为 {getStoredRole()},工具策略/插件安装仅 owner 可写(读取/查看可见)。</Notice> : null}
{fail ? <Notice tone="err">{fail}</Notice> : null}
{msg ? <Notice tone="ok">{msg}</Notice> : null}
<InstalledPluginsPanel />
<Surface className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">已注册能力</h3>
<span className="text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/plugins</span>
</div>
<AdminTable
columns={columns}
rows={loading ? null : data}
@@ -132,7 +127,7 @@ export function PluginsModule(): React.ReactNode {
/>
</Surface>
<Notice>工具 schema / 技能 / MCP 服务器列表为后端 M10+ 提供;此页展示已注册能力及其环境可见性标记(群聊 / 陌生人默认关闭)。</Notice>
<Notice>工具 schema / 技能为后端 M10+ 提供;此页展示已注册能力及其环境可见性标记(群聊 / 陌生人默认关闭)。</Notice>
</div>
);
}
@@ -0,0 +1,172 @@
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";
/** 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
* backend merges the request over the stored group, so blank optional fields
* (apiKey / maxTokens) are simply omitted and keep their stored value. */
interface FormState {
name: string;
provider: string;
model: string;
baseUrl: string;
apiKey: string;
multimodal: boolean;
maxTokens: string;
}
type FieldSetter = <K extends keyof FormState>(key: K, value: FormState[K]) => void;
function initState(initial: AdminLlmProvider | null): FormState {
return {
name: initial?.id ?? "",
provider: initial?.provider || "openai",
model: initial?.model ?? "",
baseUrl: initial?.baseUrl ?? "",
apiKey: "",
multimodal: initial?.multimodal ?? false,
maxTokens: initial?.maxTokens ? String(initial.maxTokens) : "",
};
}
/** The form fields; state lives in the form component below. */
function ProviderFields({ form, set, editing }: { form: FormState; set: FieldSetter; editing: boolean }): React.ReactNode {
return (
<>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">名称 / ID(留空自动生成)</span>
<input
value={form.name}
onChange={(e) => set("name", e.target.value)}
placeholder="deepseek"
disabled={editing}
className={inputCls}
/>
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">协议 Provider</span>
<select value={form.provider} onChange={(e) => set("provider", e.target.value)} className={inputCls}>
<option value="openai">openai(OpenAI 兼容)</option>
<option value="anthropic">anthropic(Anthropic Messages)</option>
</select>
</label>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">模型</span>
<input value={form.model} onChange={(e) => set("model", e.target.value)} className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">baseURL</span>
<input value={form.baseUrl} onChange={(e) => set("baseUrl", e.target.value)} placeholder="https://api.example.com/v1" className={inputCls} />
</label>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">
API key({editing ? "留空 = 保留原 key" : "写入 models.json,不回显"})
</span>
<input value={form.apiKey} onChange={(e) => set("apiKey", e.target.value)} placeholder="sk-…" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">maxTokens(该组预算,留空 = 保持/默认)</span>
<input
value={form.maxTokens}
onChange={(e) => set("maxTokens", e.target.value)}
inputMode="numeric"
placeholder="8192"
className={inputCls}
/>
</label>
</div>
<div className="flex items-end gap-2 pb-[2px]">
<Switch checked={form.multimodal} onChange={(v) => set("multimodal", v)} label="多模态" />
</div>
</>
);
}
interface LlmProviderFormProps {
initial: AdminLlmProvider | null;
busy: boolean;
setBusy: (v: boolean) => void;
onResult: (msg: string) => void;
onError: (msg: string | null) => void;
onSaved: () => void;
onCancel: () => void;
}
/** Owns the field state and the save round-trip; the module supplies callbacks. */
export function LlmProviderForm({
initial,
busy,
setBusy,
onResult,
onError,
onSaved,
onCancel,
}: 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 budget = form.maxTokens.trim();
if (budget !== "" && !/^\d+$/.test(budget)) {
onError("maxTokens 必须为非负整数(留空 = 保持/默认)");
return;
}
setBusy(true);
onError(null);
const req: LlmProviderRequest = { provider: form.provider, baseUrl: form.baseUrl, model: form.model, multimodal: form.multimodal };
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);
adminAddLlmProvider(req)
.then((r) => {
log("llm.addProvider", "ok");
const budgetNote = r.provider.maxTokens ? ` · maxTokens ${r.provider.maxTokens}` : "";
onResult(`已保存到 models.json 并热更新:${r.provider.name} · ${r.provider.model}${budgetNote}${r.path ? `(${r.path})` : ""}`);
onSaved();
if (editing) onCancel();
else setForm(initState(null));
})
.catch((e: unknown) => {
log("llm.addProvider", "error");
onError(e instanceof Error ? e.message : "保存模型失败");
})
.finally(() => setBusy(false));
};
return (
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">{editing ? `编辑模型组 · ${initial?.id}` : "新增模型(保存到 models.json,即刻热更新)"}</h3>
{editing ? (
<Button size="sm" variant="ghost" className="ml-auto" onClick={onCancel}>
取消编辑
</Button>
) : null}
</div>
<ProviderFields form={form} set={set} editing={editing} />
<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"}
</span>
</div>
</Surface>
);
}
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,306 @@
import { useState } from "react";
import { useAsyncData } from "../../../lib/useAsyncData";
import {
adminListInstalledPlugins,
adminPluginInstall,
adminPluginUninstall,
getStoredRole,
} from "../../../lib/api";
import type { InstalledPlugin, PluginInstallConfig } from "../../../lib/types";
import { useAutoReload } from "../../hooks";
import { useAuditStore } from "../../audit";
import { AdminTable, Notice, type AdminColumn } from "../../components";
import { Button, Surface, Tag } from "../../../components/primitives";
/** Runtime-installed plugin (MCP server) section: the live list plus the install
* form. Kept out of Plugins.tsx so the module stays under the 500-line bound.
* Installing an MCP server hot-registers its tools; the list shows live/toolCount. */
const TRANSPORTS: ReadonlyArray<{ value: string; label: string }> = [
{ value: "stdio", label: "stdio(本地子进程)" },
{ value: "http", label: "http(远程 endpoint)" },
{ value: "sse", label: "sse(远程 endpoint)" },
];
interface FormState {
kind: string;
name: string;
transport: string;
endpoint: string;
command: string;
args: string;
apiKey: string;
env: string;
}
const EMPTY_FORM: FormState = {
kind: "mcp",
name: "",
transport: "stdio",
endpoint: "",
command: "",
args: "",
apiKey: "",
env: "",
};
/** Parses a multiline `KEY=VALUE` block into an env map, skipping blanks. */
function parseEnv(raw: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of raw.split("\n")) {
const eq = line.indexOf("=");
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
if (key) out[key] = line.slice(eq + 1).trim();
}
return out;
}
/** Parses whitespace-separated args into the stdio child argv. */
function parseArgs(raw: string): string[] {
return raw.split(/\s+/).map((s) => s.trim()).filter(Boolean);
}
/** Builds the install config for the selected transport, omitting blank fields. */
function buildConfig(f: FormState): PluginInstallConfig {
const config: PluginInstallConfig = { transport: f.transport };
if (f.transport === "stdio") {
config.command = f.command.trim();
const args = parseArgs(f.args);
if (args.length > 0) config.args = args;
} else {
config.endpoint = f.endpoint.trim();
}
if (f.apiKey.trim()) config.apiKey = f.apiKey.trim();
const env = parseEnv(f.env);
if (Object.keys(env).length > 0) config.env = env;
return config;
}
/** Client-side guard mirroring the backend's required-field checks. */
function formError(f: FormState): string | null {
if (!f.name.trim()) return "请填写插件名称";
if (f.transport === "stdio" && !f.command.trim()) return "stdio 传输必须填写 command";
if (f.transport !== "stdio" && !f.endpoint.trim()) return "http/sse 传输必须填写 endpoint";
return null;
}
type FieldSetter = <K extends keyof FormState>(key: K, value: FormState[K]) => void;
/** Presentational install-form fields; the form component owns the state. */
function InstallFormFields({ form, set }: { form: FormState; set: FieldSetter }): React.ReactNode {
const isStdio = form.transport === "stdio";
return (
<>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">类型(v1 仅 MCP server)</span>
<select value={form.kind} onChange={(e) => set("kind", e.target.value)} className={inputCls}>
<option value="mcp">mcp</option>
</select>
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">名称(同名重复安装 = 刷新)</span>
<input value={form.name} onChange={(e) => set("name", e.target.value)} placeholder="filesystem" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">传输</span>
<select value={form.transport} onChange={(e) => set("transport", e.target.value)} className={inputCls}>
{TRANSPORTS.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</label>
</div>
{isStdio ? (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">command</span>
<input value={form.command} onChange={(e) => set("command", e.target.value)} placeholder="npx" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">args(空格分隔)</span>
<input value={form.args} onChange={(e) => set("args", e.target.value)} placeholder="-y @modelcontextprotocol/server-filesystem /tmp" className={inputCls} />
</label>
</div>
) : (
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">endpoint</span>
<input value={form.endpoint} onChange={(e) => set("endpoint", e.target.value)} placeholder="https://mcp.example.com/sse" className={inputCls} />
</label>
)}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">API key(http/sse 鉴权,不回显)</span>
<input value={form.apiKey} onChange={(e) => set("apiKey", e.target.value)} placeholder="sk-…" className={inputCls} />
</label>
<label className="block space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">env(每行 KEY=VALUE,stdio 子进程环境)</span>
<textarea value={form.env} onChange={(e) => set("env", e.target.value)} rows={2} className={inputCls} />
</label>
</div>
</>
);
}
interface InstallFormProps {
busy: boolean;
owner: boolean;
onDone: (msg: string) => void;
onError: (msg: string | null) => void;
}
/** The MCP install form: owns field state, validates, then installs + refreshes. */
function PluginInstallForm({ busy, owner, onDone, onError }: InstallFormProps): React.ReactNode {
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [submitting, setSubmitting] = useState(false);
const log = useAuditStore((s) => s.log);
const set: FieldSetter = (key, value) => setForm((prev) => ({ ...prev, [key]: value }));
const submit = () => {
const invalid = formError(form);
if (invalid) {
onError(invalid);
return;
}
setSubmitting(true);
onError(null);
adminPluginInstall({ kind: form.kind, name: form.name.trim(), config: buildConfig(form) })
.then((res) => {
log("plugin.install", "ok");
onDone(`已安装 ${res.name}(${res.transport})· 注册工具 ${res.toolCount} 个`);
setForm(EMPTY_FORM);
})
.catch((err: unknown) => {
log("plugin.install", "error");
onError(err instanceof Error ? err.message : "插件安装失败");
})
.finally(() => setSubmitting(false));
};
return (
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">安装 MCP 插件</h3>
<InstallFormFields form={form} set={set} />
<div className="flex items-center gap-2">
<Button variant="primary" disabled={busy || submitting || !owner} onClick={submit}>
{submitting ? "安装中…" : "安装 / 刷新"}
</Button>
{!owner ? <span className="text-xs text-[var(--mu-text-lo)]">仅 owner 可安装</span> : null}
</div>
</Surface>
);
}
/** Table columns for the installed-plugin list. */
function installedColumns(
isOwner: boolean,
busy: boolean,
uninstall: (name: string) => void,
): AdminColumn<InstalledPlugin>[] {
return [
{
key: "name",
title: "名称",
render: (p) => (
<span className="flex items-center gap-2">
<span className="font-medium">{p.name}</span>
<Tag tone="accent">{p.kind}</Tag>
</span>
),
},
{ key: "transport", title: "传输", render: (p) => <Tag tone="neutral">{p.transport || "—"}</Tag> },
{
key: "target",
title: "连接目标",
render: (p) => (
<span className="block max-w-xs truncate font-mono text-xs text-[var(--mu-text-mid)]">
{p.endpoint || [p.command, ...(p.args ?? [])].filter(Boolean).join(" ") || "—"}
</span>
),
},
{ key: "tools", title: "工具数", render: (p) => <span className="tabular-nums">{p.toolCount}</span> },
{
key: "live",
title: "状态",
render: (p) => <Tag tone={p.live ? "ok" : "warn"}>{p.live ? "已连接" : "未连接"}</Tag>,
},
{
key: "creds",
title: "凭据(掩码)",
render: (p) => (
<span className="block text-xs text-[var(--mu-text-lo)]">
{p.apiKey ? <span className="font-mono">{p.apiKey}</span> : null}
{p.env && Object.keys(p.env).length > 0 ? <span className="ml-2">env: {Object.keys(p.env).join(", ")}</span> : null}
{!p.apiKey && (!p.env || Object.keys(p.env).length === 0) ? "—" : null}
</span>
),
},
{
key: "actions",
title: "操作",
render: (p) => (
<Button size="sm" variant="ghost" disabled={!isOwner || busy} onClick={() => uninstall(p.name)}>
卸载
</Button>
),
},
];
}
/** Installed-plugin list + install form, with its own refresh/feedback state. */
export function InstalledPluginsPanel(): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(adminListInstalledPlugins, []);
useAutoReload(reload, 10000);
const isOwner = getStoredRole() === "owner";
const log = useAuditStore((s) => s.log);
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const uninstall = (name: string) => {
setBusy(true);
setFail(null);
setMsg(null);
adminPluginUninstall(name)
.then(() => {
log("plugin.uninstall", "ok");
setMsg(`已卸载 ${name}`);
reload();
})
.catch((err: unknown) => {
log("plugin.uninstall", "error");
setFail(err instanceof Error ? err.message : "卸载失败");
})
.finally(() => setBusy(false));
};
return (
<div className="space-y-5">
<Surface className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">已装插件(运行时 MCP)</h3>
<span className="text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/plugins/installed</span>
<Button size="sm" variant="ghost" className="ml-auto" disabled={loading} onClick={reload}>
刷新
</Button>
</div>
{fail ? <Notice tone="err">{fail}</Notice> : null}
{msg ? <Notice tone="ok">{msg}</Notice> : null}
<AdminTable
columns={installedColumns(isOwner, busy, uninstall)}
rows={loading ? null : data}
loading={loading}
error={error}
emptyTitle="尚未安装任何运行时插件"
rowKey={(p) => p.id || p.name}
/>
</Surface>
<PluginInstallForm busy={busy} owner={isOwner} onDone={setMsg} onError={setFail} />
</div>
);
}
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)]";
+18 -7
View File
@@ -20,6 +20,7 @@ import type {
ChannelTestResult,
ChatMessage,
ChatSyncResult,
InstalledPlugin,
LlmProviderRequest,
LlmTestRequest,
LlmTestResult,
@@ -32,6 +33,8 @@ import type {
Mode,
PersonaRecord,
PersonaUpsertRequest,
PluginInstallRequest,
PluginInstallResult,
KookFriendList,
KookMessageTestResult,
KookViewResponse,
@@ -391,6 +394,11 @@ export function adminListMemories(): Promise<MemoryRecord[]> {
export function adminListPlugins(): Promise<AdminPluginRecord[]> {
return request<AdminPluginRecord[]>("/admin/plugins");
}
/** GET /api/v1/admin/plugins/installed — runtime-installed plugins (v0.28), with
* the backend masking apiKey/sensitive env and reporting live/toolCount. */
export function adminListInstalledPlugins(): Promise<InstalledPlugin[]> {
return request<InstalledPlugin[]>("/admin/plugins/installed");
}
/** POST /api/v1/admin/plugins/policy — owner-only, audited. */
export function adminSetPluginPolicy(
name: string,
@@ -677,15 +685,18 @@ export function adminWeixinLoginStatus(
`/admin/channels/${encodeURIComponent(platform)}/login-status${q}`,
);
}
/** POST /api/v1/admin/plugins/install — honest 501 (needs a plugin source). */
export function adminPluginInstall(req: { name: string; kind: string; description: string }): Promise<AdminPluginRecord> {
return request<AdminPluginRecord>("/admin/plugins/install", { method: "POST", body: JSON.stringify(req) });
/** POST /api/v1/admin/plugins/install — installs (or refreshes) an MCP server
* plugin (v0.28): connects, discovers its tools and hot-registers them. The
* backend answers 502 and persists nothing when connect/discover fails. */
export function adminPluginInstall(req: PluginInstallRequest): Promise<PluginInstallResult> {
return request<PluginInstallResult>("/admin/plugins/install", { method: "POST", body: JSON.stringify(req) });
}
/** POST /api/v1/admin/plugins/uninstall — removes a capability from the registry. */
export function adminPluginUninstall(req: { name: string }): Promise<{ name: string; uninstalled: boolean }> {
return request<{ name: string; uninstalled: boolean }>("/admin/plugins/uninstall", {
/** POST /api/v1/admin/plugins/uninstall — removes an installed MCP server (tools
* unregistered + config deleted) or a plain capability from the registry. */
export function adminPluginUninstall(name: string): Promise<{ name: string; uninstalled: boolean; kind?: string }> {
return request<{ name: string; uninstalled: boolean; kind?: string }>("/admin/plugins/uninstall", {
method: "POST",
body: JSON.stringify(req),
body: JSON.stringify({ name }),
});
}
/** POST /api/v1/admin/memories/search — real retrieval over the memory store. */
+55 -1
View File
@@ -261,6 +261,25 @@ export interface AdminPluginRecord {
allowInStranger: boolean;
}
/** One runtime-installed plugin (GET /api/v1/admin/plugins/installed, v0.28).
* A v1 plugin is an external MCP server; `live` = connected and its tools are
* registered, `toolCount` = tools it contributed. The backend masks apiKey and
* sensitive env values before they reach the client. */
export interface InstalledPlugin {
id: string;
name: string;
kind: string;
transport: string;
endpoint?: string;
command?: string;
args?: string[];
apiKey?: string;
env?: Record<string, string>;
createdAt?: string;
live: boolean;
toolCount: number;
}
/** A channel adapter as returned by GET /api/v1/admin/channels. */
export interface AdminChannelRecord {
name: string;
@@ -355,6 +374,9 @@ export interface AdminLlmProvider {
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;
}
export interface AdminLlmConfig {
providers: AdminLlmProvider[];
@@ -508,7 +530,9 @@ export interface AutomationRunResult {
}
/** POST /api/v1/admin/llm body — saves a provider group to models.json and
* hot-reloads the running LLM stack (persisted + active). */
* hot-reloads the running LLM stack (persisted + active). The backend merges
* the request over the stored group (read-modify-write): an omitted field keeps
* its stored value, so a partial edit never wipes a budget or key. */
export interface LlmProviderRequest {
id?: string;
name?: string;
@@ -518,6 +542,8 @@ export interface LlmProviderRequest {
model: string;
multimodal?: boolean;
fallback?: string;
/** Absent = keep the stored budget (or leave unset for a new group). */
maxTokens?: number;
}
/** POST /api/v1/admin/llm/test body. */
@@ -538,6 +564,34 @@ export interface LlmTestResult {
tokens: number;
}
/** MCP server connection block for POST /api/v1/admin/plugins/install (v0.28).
* `endpoint` is for http/sse, `command` (+ `args`) for stdio. apiKey is sent
* once and persisted but never echoed back; env is the stdio child environment. */
export interface PluginInstallConfig {
transport: string;
endpoint?: string;
command?: string;
args?: string[];
apiKey?: string;
env?: Record<string, string>;
}
/** POST /api/v1/admin/plugins/install body. v1 installs MCP servers only. */
export interface PluginInstallRequest {
kind: string;
name: string;
config: PluginInstallConfig;
}
/** POST /api/v1/admin/plugins/install result — the live server's registered tool count. */
export interface PluginInstallResult {
installed: boolean;
name: string;
kind: string;
transport: string;
toolCount: number;
}
/** POST /api/v1/admin/channels/test result (structural identity verdict). */
export interface ChannelTestResult {
role: string;
+15
View File
@@ -184,6 +184,21 @@ func GroupFromEnv(env config.LLMConfig) []Group {
}}
}
// Find returns the group with the given id, if present. It is the read half of
// the admin read-modify-write merge, so a partial edit starts from the stored
// group instead of zero values.
func Find(groups []Group, id string) (Group, bool) {
if strings.TrimSpace(id) == "" {
return Group{}, false
}
for _, g := range groups {
if g.ID == id {
return g, true
}
}
return Group{}, false
}
// Upsert returns a copy of groups with g inserted or replaced in place by ID,
// preserving order. A blank ID is assigned a generated id so a panel save
// without an explicit id still produces a stable, addressable group.
@@ -1,9 +1,10 @@
package gateway
import (
"errors"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
@@ -36,74 +37,55 @@ const (
)
// llmProviderRequest is the POST /api/v1/admin/llm body for adding/updating a
// provider group. Name is a client-side display label (not persisted — the
// models.json schema carries id/provider/baseUrl/apiKey/model/fallback/
// multimodal/maxTokens).
// provider group. Every field is a pointer so the handler can tell "absent"
// (preserve the stored value) from "explicitly zero" (overwrite): a partial edit
// from the panel must never wipe a field it did not touch. Name is a client-side
// display label (not persisted — the models.json schema carries id/provider/
// baseUrl/apiKey/model/fallback/multimodal/maxTokens).
type llmProviderRequest struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Model string `json:"model"`
Multimodal bool `json:"multimodal"`
Fallback string `json:"fallback"`
// MaxTokens is the group's own token budget. Zero means unset (the runtime
// falls back to LLM_FALLBACK_MAX_TOKENS on the last fallback step, then the
// adapter default). Negative is rejected.
MaxTokens int `json:"maxTokens"`
ID *string `json:"id"`
Name *string `json:"name"`
Provider *string `json:"provider"`
BaseURL *string `json:"baseUrl"`
APIKey *string `json:"apiKey"`
Model *string `json:"model"`
Multimodal *bool `json:"multimodal"`
Fallback *string `json:"fallback"`
// MaxTokens is the group's own token budget. Absent preserves the stored
// budget (0 = unset for a new group). Negative is rejected.
MaxTokens *int `json:"maxTokens"`
}
// adminAddLlmProvider saves a provider group to models.json (read-modify-write,
// promoting it to the active group) and hot-reloads the running LLM stack so the
// new provider/model takes effect without a restart (design/04 §3.1). The API key
// is persisted to the gitignored file but never returned or logged.
// new provider/model takes effect without a restart (design/04 §3.1). A field the
// client omits keeps its stored value, so a partial edit never resets a budget or
// key. The API key is persisted to the gitignored file but never returned/logged.
func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
var req llmProviderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Model) == "" || strings.TrimSpace(req.BaseURL) == "" {
s.auditClaims(r, "llm.addProvider", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "baseUrl and model are required"))
return
}
if req.MaxTokens < 0 {
if req.MaxTokens != nil && *req.MaxTokens < 0 {
s.auditClaims(r, "llm.addProvider", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "maxTokens must be >= 0"))
return
}
provider := req.Provider
if provider == "" {
provider = model.ProviderOpenAI
}
if provider != model.ProviderOpenAI && provider != model.ProviderAnthropic {
s.auditClaims(r, "llm.addProvider", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid provider"))
return
}
if s.llm == nil {
s.auditClaims(r, "llm.addProvider", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_unavailable", "LLM 配置服务未注入"))
return
}
id := req.ID
if id == "" {
id = runtimeconfig.NewID()
group, err := mergeLlmProvider(s.llm.Groups(), req)
if err != nil {
s.auditClaims(r, "llm.addProvider", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%v", err))
return
}
// Promote the saved group to the active (index 0) slot, then persist and
// hot-reload so Current() reflects the new provider/model immediately.
groups := runtimeconfig.Activate(s.llm.Groups(), runtimeconfig.Group{
ID: id,
Provider: provider,
BaseURL: req.BaseURL,
APIKey: req.APIKey,
Model: req.Model,
Fallback: req.Fallback,
Multimodal: req.Multimodal,
MaxTokens: req.MaxTokens,
})
groups := runtimeconfig.Activate(s.llm.Groups(), group)
if err := runtimeconfig.SaveModels(s.llm.Path(), groups); err != nil {
s.auditClaims(r, "llm.addProvider", "error")
s.log.Error("llm save models", "path", s.llm.Path(), "error", err)
@@ -117,15 +99,15 @@ func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
return
}
saved := AdminLlmProvider{
ID: id,
Name: providerDisplayName(runtimeconfig.Group{Provider: provider}),
Provider: provider,
BaseURL: req.BaseURL,
Model: req.Model,
Multimodal: req.Multimodal,
Enabled: id == s.llm.Group().ID,
Fallback: req.Fallback,
MaxTokens: req.MaxTokens,
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,
}
s.auditClaims(r, "llm.addProvider", "ok")
writeJSON(w, http.StatusCreated, map[string]any{
@@ -135,6 +117,55 @@ func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
})
}
// mergeLlmProvider applies a partial provider patch over the stored groups: a
// field present in the request wins, an absent field keeps the stored value (or
// its default for a new group). It returns a validated group.
func mergeLlmProvider(groups []runtimeconfig.Group, req llmProviderRequest) (runtimeconfig.Group, error) {
id := ""
if req.ID != nil {
id = strings.TrimSpace(*req.ID)
}
g, found := runtimeconfig.Find(groups, id)
if !found {
g = runtimeconfig.Group{}
}
g.ID = id
if g.ID == "" {
g.ID = runtimeconfig.NewID()
}
if req.Provider != nil {
g.Provider = strings.TrimSpace(*req.Provider)
}
if g.Provider == "" {
g.Provider = model.ProviderOpenAI
}
if g.Provider != model.ProviderOpenAI && g.Provider != model.ProviderAnthropic {
return runtimeconfig.Group{}, fmt.Errorf("provider must be openai or anthropic")
}
if req.BaseURL != nil {
g.BaseURL = strings.TrimSpace(*req.BaseURL)
}
if req.APIKey != nil {
g.APIKey = *req.APIKey
}
if req.Model != nil {
g.Model = strings.TrimSpace(*req.Model)
}
if req.Fallback != nil {
g.Fallback = strings.TrimSpace(*req.Fallback)
}
if req.Multimodal != nil {
g.Multimodal = *req.Multimodal
}
if req.MaxTokens != nil {
g.MaxTokens = *req.MaxTokens
}
if g.BaseURL == "" || g.Model == "" {
return runtimeconfig.Group{}, fmt.Errorf("baseUrl and model are required")
}
return g, nil
}
// llmTestRequest is the POST /api/v1/admin/llm/test body.
type llmTestRequest struct {
Provider string `json:"provider"`
@@ -301,6 +301,50 @@ func TestAdminAddLlmProviderSwitchProvider(t *testing.T) {
}
}
func TestAdminAddLlmProviderPartialEditPreservesFields(t *testing.T) {
t.Parallel()
modelsPath := filepath.Join(t.TempDir(), "models.json")
env := config.LLMConfig{Provider: "openai", BaseURL: "https://api.openai.com/v1", APIKey: "sk-env", Model: "gpt-4o"}
runtime := newLlmRuntimeForGateway(t, modelsPath, env)
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// Seed a fully-specified group.
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
map[string]any{"id": "deepseek", "provider": "openai", "baseUrl": "https://api.deepseek.com/v1", "apiKey": "sk-ds", "model": "deepseek-chat", "multimodal": true, "fallback": "deepseek-lite", "maxTokens": 1234}, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("seed status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
// Partial edit: only the model is given. Every omitted field must keep its
// stored value — in particular maxTokens (a full replace reset it to 0) and
// apiKey (a full replace wiped it).
resp2, body2 := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
map[string]any{"id": "deepseek", "model": "deepseek-chat-v2"}, tok)
if resp2.StatusCode != http.StatusCreated {
t.Fatalf("edit status = %d, want 201 (body=%s)", resp2.StatusCode, body2)
}
var res struct {
Provider AdminLlmProvider `json:"provider"`
}
if err := json.Unmarshal(decodeBody(t, body2)["data"], &res); err != nil {
t.Fatalf("decode edit: %v", err)
}
p := res.Provider
if p.Model != "deepseek-chat-v2" || p.MaxTokens != 1234 || p.Fallback != "deepseek-lite" ||
!p.Multimodal || p.Provider != "openai" || p.BaseURL != "https://api.deepseek.com/v1" {
t.Fatalf("edit provider = %+v, want model updated and every omitted field preserved", p)
}
groups, warn := runtimeconfig.LoadModels(modelsPath, env)
if warn != nil {
t.Fatalf("load models warn = %v", warn)
}
if len(groups) == 0 || groups[0].APIKey != "sk-ds" || groups[0].MaxTokens != 1234 || groups[0].Model != "deepseek-chat-v2" {
t.Fatalf("file groups = %+v, want apiKey/maxTokens preserved and model updated", groups)
}
}
func TestAdminAddLlmProviderRejectsBadInput(t *testing.T) {
t.Parallel()
modelsPath := filepath.Join(t.TempDir(), "models.json")