feat(config): 运行时配置体系①——LLM 多厂商独立组落 models.json + 面板落盘 + 热更新
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- runtimeconfig/models.go: models.json 承载 [{id,provider,baseUrl,apiKey,model,fallback?,multimodal?}] 多厂商独立组(首项=生效)
- LoadModels 优先文件/回退env(GroupFromEnv), SaveModels(0600+tmp原子写), Activate/Upsert/MaskedKey脱敏
- 面板 POST /admin/llm 落盘 models.json(取代temporary内存) + GET 列组(脱敏)
- runtime/model/runtime.go: RuntimeLLM(adapter+router) atomic快照可热替换, Reload 重建不中断进行中请求; per-role路由用接口
- wire 用 NewRuntimeLLM(adapter+router), 保留 a.modelAdapter(=runtime stack, 审查模型随热更新)
- .env 退兜底(.env.example注明); apiKey不打印/Config()脱敏/热更新不中断
- 全仓 go build/vet/test 绿(54包)+交叉编译+前端build绿(79模块); api-client v0.15已同步
- 遗留: vision ModelInfo 仍读静态env(后续纳入); 保存组=生效组(第一版语义)
This commit is contained in:
Aska committed 2026-09-07 13:07:58 +08:00
1 parent 8cde0162e6
commit 2d7151f979
16 files changed
+1079 -108

No files matched your search

+3
View File
@@ -31,6 +31,9 @@ DB_SQLITE_PATH=./data/cyrene.db
# DB_PG_URL=postgres://cyrene:cyrene_pass@localhost:5432/cyrene_ai?sslmode=disable
# ---- LLM 多供应商(LLM_PROVIDER 显式声明协议:openai / anthropic,默认 openai) ----
# ⚠️ 本节是【兜底】:管理面板「LLM 模型」页保存会写入本地 models.json(默认 <DATA_DIR>/models.json,已 gitignore),
# 只要 models.json 存在且有效,运行中的 LLM 就优先用它的多厂商多模型组,并支持面板实时保存→热更新(无需重启)。
# 仅当 models.json 不存在/无效时才回退到下面的 .env。
# Provider = openai:OpenAI 兼容 /chat/completions(含 OpenAI 兼容网关,也可经它承载 Anthropic 模型,但失去原生 tool input 对象优势)
LLM_PROVIDER=openai
+4 -3
View File
@@ -42,7 +42,7 @@
| 6 | `/api/v1/admin/channels` | GET | ✅ | 渠道列表/健康 |
| 7 | `/api/v1/admin/iot` | GET | ✅ | IoT 设备列表 |
| 8 | `/api/v1/admin/users` | GET/POST | ✅(GET) | 用户/角色/API key(GET 已实现;POST 创建用户/签发 key 随 M10 落地) |
| 9 | `/api/v1/admin/llm` | GET/PUT | ✅(GET) | LLM 模型/路由/降级(GET 已实现,APIKey 脱敏;PUT 热重载 M10) |
| 9 | `/api/v1/admin/llm` | GET/POST | ✅ | LLM 多厂商多模型(models.json):GET 列表(APIKey 脱敏,含 `provider` 协议);POST 保存组→落盘 `models.json`→**热更新**运行中的 LLM stack(design/04 §3.1,无需重启) |
| 10 | `/api/v1/admin/automations` | GET/POST | ✅(GET) | 自动化与提醒(GET 已实现;POST 创建 M10) |
| 11 | `/api/v1/admin/backthink` | GET | ✅ | 后台思考记录 |
| 12 | `/api/v1/admin/rag` | GET | ✅ | 知识库文档列表(分块/嵌入状态);带 `?query=` 时并入检索结果 |
@@ -67,7 +67,7 @@
- `POST /admin/automations` body `{name,kind:"cron|event",expression}` → `201 {data:{id,name,kind}}`(真实注册规则);无效 cron/规则回 `400 invalid_cron|invalid_rule`。
- `POST /admin/automations/{id}/run` → `200 {data:{id,output}}`(手动触发);`404`。
- `DELETE /admin/automations/{id}` → `200 {data:{id,deleted:true}}`;`404`。
- `POST /admin/llm` body `{name,model,baseUrl?,multimodal?,fallback?}` → `201 {data:{provider,temporary:true}}`。**临时**,进程生命周期,不入 config、不绑定模型路由。
- `POST /admin/llm` body `{id?,provider?,baseUrl,apiKey?,model,multimodal?,fallback?}` → `201 {data:{provider:{id,name,provider,baseUrl,model,multimodal,enabled,fallback?}, persisted:true, path}}`。**真实持久化**:读-改-写 `models.json`(本地、gitignore),保存组提升为**生效组**(index 0),并**热更新**运行中的 LLM provider(`path` 为落盘文件路径);API key 写入文件但**永不回显/不落日志**。`provider` 为空默认 `openai`;缺 `baseUrl`/`model` 回 `400`;未注入 LLM stack 回 `500 llm_unavailable`、写盘失败 `500 llm_persist_failed`、热重载失败 `500 llm_reload_failed`。`.env` 为兜底。
- `POST /admin/llm/test` body `{provider?,baseUrl,model,apiKey?,prompt?}` → `200 {data:{ok,provider,model,reply,tokens}}`(**真实模型调用**);无效/失败回 `400 invalid_provider` / `500 llm_test_failed`。API key 仅本次请求内使用,不落库、不进 wire。
- `POST /admin/channels/open`、`POST /admin/channels/close` body `{name}` → `200 {data:{name,connected}}`(真实 Connect/Disconnect);`404`(未注册渠道)/`500`(凭据缺失等连接失败)。
- `POST /admin/channels/test` body `{platform?,accountId,isGroup}` → `200 {data:{role,owner,isGroup,env,verdict}}`。**结构性判定**:channel 层 actor 元数据 × `configs/users/owner.yaml` 白名单,绝不靠提示词。
@@ -159,13 +159,14 @@
- 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。
## 5. 变更记录
- `v0.15`(运行时配置体系:LLM 多厂商多模型独立组落盘 + 热更新,design/04 §3.1):`GET /api/v1/admin/llm` 返回 **models.json 的全部组**(`provider` 协议字段新增,index 0 = 生效组;APIKey 仍脱敏不回显);`POST /api/v1/admin/llm` 由「临时记录」改为**真实持久化 + 热更新**(body `{id?,provider,baseUrl,apiKey?,model,multimodal?,fallback?}` → `201 {provider,persisted:true,path}`,读-改-写 `models.json`、保存组提升为生效组并热重载运行中的 LLM stack,无需重启)。`.env` 退为兜底(无 `models.json` 时按 `LLM_PROVIDER`/`LLM_*`/`ANTHROPIC_*` 合成单组)。
- `v0.14`(IM 多会话隔离 + 第三方身份 + 黑白名单,design/16 §4.6):**`chat_messages` 增 `sender_name` 列**(迁移 0006,默认 `''`);消息 DTO(REST 消息、WS `history` 回放、WS `message_persisted`)均带 **`senderName`**(第三方发送者昵称,主人私聊为空 `omitempty`)。**第三方身份边界**:IM 群聊/陌生人会话在上下文构建时对第三方消息注入 `[第三方 · <昵称>]` 前缀(**不改源消息正文**,落库保留原文)+ 前置系统提示词"每个 IM 的非管理员账户 = 第三方人员,和主人无关"——身份判定基于 channel 层 actor 元数据 + 白名单(`MemWritable=false` 即群聊/陌生人),**绝不靠 LLM 猜**。**会话隔离**:群聊/陌生人消息**落独立隔离会话**(`sess-xxx`,带 `platform`+`senderName`,**不**并入主会话 `web`、**不**走 `message_persisted` 广播),供 **`GET /api/v1/admin/channels/{platform}/messages`**(owner/admin,management 面板渠道日志区)以消息流显示;**前端主会话**只显示各平台管理员(owner)私聊;群聊/陌生人记忆只读不写主会话(`memory_gating` 已按 `MemWritable` 门控)。**多账号**:`CHANNELS_JSON` 支持同一平台多个接入配置(每个 account 独立 session/账号,见 §2 渠道契约)。**黑白名单**:每平台每账号可配 `allowGroups/denyGroups/allowUsers/denyUsers`,channelbridge **入站即拦截**(黑名单/不在白名单 → 丢弃,`audit` 可查,**不进 LLM**);白名单按 `platform+account`(群 id / 用户 uid)。
- `v0.13`(每条消息带 deviceID/client 标识,design/16 §4.5):**`chat_messages` 增 `device_id` 列**(迁移 0005,默认 `''`);`POST /api/v1/chat` 请求体新增**可选 `deviceId`**(客户端生成的端标识,如 `web:chrome:a1b2c3`);消息 DTO(REST 消息、WS `history` 回放、WS `message_persisted`)均带 **`deviceId`**。`platform`(平台)与 `deviceId`(具体端)互补:同一平台多个端 → 相同 `platform`、不同 `deviceId`。**Web 端**由 `lib/client.ts` 用 `localStorage` 持久化每个浏览器一个的 `web:<browser>:<随机短id>`,发送时自动带上,聊天气泡默认小字标注来源(仅当 `deviceId` 存在才显示);**IM 端**按 `<im平台名>:<本地配置唯一id>:<用户平台唯一uid>` 构造(当前 `UnifiedMessage` 尚无 deviceId 概念,channelbridge 以 `<platform>:<channelID>:<senderID>` 占位,`<本地配置唯一id>` 待适配器提供后替换);Web 端聊天不回流 IM。
- `v0.12`(IM 渠道并入主会话 + 平台标记,design/16 §4.4):**`chat_messages` 增 `platform` 列**(迁移 0004,默认 `'web'`);`GET /api/v1/sessions/{id}/messages` 支持可选 **`?platform=`** 过滤(Web 端不传=全量共享主会话;IM 适配器传本平台只拉本渠道);消息 DTO(REST 与 WS `message_persisted`)均带 `platform`。**IM 渠道主人私聊**(`!IsGroup && Owner`)经 channelbridge 落**主会话 `web`**(`platform=<渠道>`)并广播 `message_persisted` → Web 端免刷新镜像;**群聊/陌生人**保持独立隔离上下文、不落主会话。Web 端聊天(`platform=web`)不回流 IM。
- `v0.11`(跨端实时同步,design/16 §4.2):WS 下行新增 **`message_persisted`** 事件——每条 user/assistant 消息落库后广播 `{type, sessionId, data:{message:{id,sessionId,role,content,mode?,mood?,image?,createdAt}}}`(`id` 为后端生成,`thinking` 已剥离)。客户端按 `sessionId === activeSessionId` 过滤 + 按 `id` upsert(含本端乐观气泡 `uid`→后端 `id` 归一)去重,实现「A 发消息、A 与 B 都无需刷新且各出现一次」。后端 `history.Store` 新增 `AppendFull` 供编排层取含 `id` 的落库消息。
- `v0.10`(会话消息持久化 — WS 契约,design/16,后端-2):WS 下行帧新增可选 `sessionId` 字段(能确定会话的事件带上);新增 **`history` 回放事件**——WS 握手成功即回放主会话最新 50 条(`{type:"history", sessionId:"web", data:{messages:[...]}}`,`thinking` 已剥离)。`stream_end`/`stream_chunk`/会话编排状态帧(`session_busy` 等)在能确定 session 时带 `sessionId`;`message.delta` 等结构化流帧因 `model.StreamEvent` 无 session 上下文不带(即"若知道就带上"边界)。
- `v0.9`(会话消息持久化 + 主/次级会话,design/16,后端-1):新增 `POST /api/v1/sessions`(新建次级会话)、`GET /api/v1/sessions/{id}/messages?limit=&before=`(游标热分页拉历史,时间倒序)、`DELETE /api/v1/sessions/{id}`(删次级会话及其消息);`GET /api/v1/sessions` 在注入 `HistoryStore` 后改返回持久化会话元数据数组(主 + 次级,`type` 区分),未注入时保持原渠道会话列表向后兼容。读消息/写会话为 owner/admin(用户侧私有历史,thinking 不达 viewer);会话元数据列表保持任意认证角色可读。
- `v0.8`(管理面板写操作 CRUD):补齐管理面板**写端点**(见 §2「管理面板写操作契约」,均 owner-only + 审计)——`POST/PUT /admin/personas`(真实注册 + 持久化 `configs/personas/<id>.yaml`)、`POST /admin/users/apikey` + `DELETE /admin/users/apikey/{id}`(真实签发/撤销)、`POST /admin/iot/{id}/control`(真实设备控制)、`POST /admin/automations` + `/run` + `DELETE`(真实注册/触发/移除规则)、`POST /admin/llm`(临时记录)+ `/admin/llm/test`(真实模型调用)、`POST /admin/channels/{open,close,test}`(真实连接/断开 + 结构性身份识别)、`POST /admin/plugins/uninstall`(真实 registry 移除)、`POST /admin/memories/search`(真实检索)、`GET /admin/sessions/{id}/export`。无存储的写(新增用户 / IoT 场景·规则 / 插件安装)如实返回 `501 not_implemented`。
- `v0.8`(管理面板写操作 CRUD):补齐管理面板**写端点**(见 §2「管理面板写操作契约」,均 owner-only + 审计)——`POST/PUT /admin/personas`(真实注册 + 持久化 `configs/personas/<id>.yaml`)、`POST /admin/users/apikey` + `DELETE /admin/users/apikey/{id}`(真实签发/撤销)、`POST /admin/iot/{id}/control`(真实设备控制)、`POST /admin/automations` + `/run` + `DELETE`(真实注册/触发/移除规则)、`POST /admin/llm`(持久化 `models.json` + 热更新,v0.15 起)+ `/admin/llm/test`(真实模型调用)、`POST /admin/channels/{open,close,test}`(真实连接/断开 + 结构性身份识别)、`POST /admin/plugins/uninstall`(真实 registry 移除)、`POST /admin/memories/search`(真实检索)、`GET /admin/sessions/{id}/export`。无存储的写(新增用户 / IoT 场景·规则 / 插件安装)如实返回 `501 not_implemented`。
- `v0.7`(会话消息编排 design/15):`POST /api/v1/chat` 非流式且会话「正忙」时改回 `202 {data:{queued:true,queueDepth}}`(原同步 `{data:{reply}}` 仍为空闲时返回);pending 满回 `409 chat_busy`。WS 上行新增 `interrupt`(`{sessionId}`,中断当前任务+清 pending);WS 下行新增会话编排状态事件 `session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)。既有 `stream_chunk`/`stream_end` 与结构化流事件不变,契约向后兼容。
- `v0.6`(可观测性):新增 `GET /api/v1/admin/observability/{metrics,trace,audit}`(owner/admin)——metrics 返回网关实时请求计数/QPS/延迟/方法·路径·状态分布 + LLM/子代理 span 计数;trace 返回 gateway 环形缓冲里的最近 span(经 TraceSink 对接 runtime tracer,映射为 `AdminTraceSpan`);audit 返回持久化审计(SQLite `audit_logs`,未注入时退化为内存)。`llmCalls`/`subagentEvents` 由实际观测 span 累计;Token/成本后端不统计、不返回(不伪造指标)。
- `v0.5`(批次B·视觉真推理 + 语音端):`POST /api/v1/chat` 增 `image` 字段(base64 data: URI 或 URL),入 loop 视觉路由(vision/OCR 工具或主模型多模态,design/06 §0「先看图再对话」);WS 上行 `voice_input` 接 STT→聊天→TTS 管道,下行新增 `voice_output`(定向回请求连接,含合成音频 + 识别文本 + 回复文本)。
+11 -11
View File
@@ -18,6 +18,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
@@ -77,6 +78,7 @@ type app struct {
db *sql.DB
bus bus.Bus
modelAdapter model.Adapter
llm *model.RuntimeLLM
personas *personareg.Registry
caps *registry.Registry
dispatcher *tool.Dispatcher
@@ -259,16 +261,13 @@ func (a *app) wireMemory(ctx context.Context) error {
// wireRuntime builds the model adapter, tool dispatcher, guardrails, the agent
// and its loop, tracing, persistence and the orchestration scheduler.
func (a *app) wireRuntime() error {
adapter, aerr := model.NewChatChain(a.cfg.LLM)
if aerr != nil {
a.log.Warn("llm adapter unavailable", "error", aerr)
adapter = nil
}
// Keep the model adapter for cross-function use (e.g. the channel-bridge
// review model that prunes low-value messages, design/12 §6 策略四).
a.modelAdapter = adapter
router := model.NewRouter(model.DefaultModelForProvider(a.cfg.LLM),
model.WithVisionModel(a.cfg.LLM.VisionModel))
// The LLM provider stack is hot-swappable: it loads models.json (or falls
// back to .env) and the panel save endpoint reloads it without a restart
// (design/04 §3.1). a.modelAdapter stays an Adapter (the runtime stack
// implements it) for the channel-bridge review model (design/12 §6 策略四).
runtimeStack := model.NewRuntimeLLM(runtimeconfig.DefaultPath(resolveDataDir(a.cfg)), a.cfg.LLM, a.log)
a.modelAdapter = runtimeStack
a.llm = runtimeStack
dispatcher, derr := tool.New(a.caps, tool.WithDefaultTimeout(defaultToolTimeout))
if derr != nil {
@@ -293,7 +292,7 @@ func (a *app) wireRuntime() error {
guards := guardrail.New()
instructions := a.defaultInstructions()
ag, aerr := agent.New(instructions, a.dispatcher, guards,
agent.WithModel(adapter, router),
agent.WithModel(runtimeStack, runtimeStack),
agent.WithOptions(agent.Options{MaxRounds: defaultMaxRounds, Purpose: "chat"}))
if aerr != nil {
return fmt.Errorf("agent: %w", aerr)
@@ -549,6 +548,7 @@ func (a *app) wireFrontend() error {
Users: apiKeys,
Backthink: a.backthink,
RAG: ragStore,
LLM: a.llm,
AuditStore: auditStore,
Streaming: true,
Frontend: resolveFrontendFS(a.log),
+34 -16
View File
@@ -8,8 +8,8 @@ import { Button, Surface, Switch, Tag } from "../../components/primitives";
const sample: AdminLlmConfig = {
providers: [
{ id: "p1", name: "主模型", baseUrl: "https://api.example.com/v1", model: "example-ultra", multimodal: true, enabled: true },
{ id: "p2", name: "备选模型", baseUrl: "https://api.backup.example.com/v1", model: "example-fast", multimodal: false, enabled: true },
{ id: "p1", name: "主模型", provider: "openai", baseUrl: "https://api.example.com/v1", model: "example-ultra", multimodal: true, enabled: true },
{ id: "p2", name: "备选模型", provider: "openai", baseUrl: "https://api.backup.example.com/v1", model: "example-fast", multimodal: false, enabled: true },
],
};
@@ -20,8 +20,10 @@ export function LlmModule(): React.ReactNode {
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 [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
@@ -31,13 +33,14 @@ export function LlmModule(): React.ReactNode {
setBusy(true);
setFail(null);
setResult(null);
adminAddLlmProvider({ name, model, baseUrl, multimodal })
.then(({ provider }) => {
adminAddLlmProvider({ id: name || undefined, provider, baseUrl, apiKey, model, multimodal })
.then((r) => {
log("llm.addProvider", "ok");
setResult(`已记录 ${provider.name}(进程生命周期,未绑定模型路由)`);
setResult(`已保存到 models.json 并热更新:${r.provider.name} · ${r.provider.model}${r.path ? `(${r.path})` : ""}`);
setName("");
setModel("");
setBaseUrl("");
setApiKey("");
setMultimodal(false);
reload();
})
@@ -52,7 +55,7 @@ export function LlmModule(): React.ReactNode {
setBusy(true);
setFail(null);
setResult(null);
const providerId = p.id === "anthropic" ? "anthropic" : "openai";
const providerId = p.provider || "openai";
adminTestLlm({ provider: providerId, baseUrl: p.baseUrl, model: p.model })
.then((r) => {
log("llm.test", "ok");
@@ -71,8 +74,8 @@ export function LlmModule(): React.ReactNode {
title="LLM 模型"
subtitle="供应商 + 模型配置(base/key/多模态检测) + 测试(真实调用) + fallback + 按角色路由 + 用量"
actions={
<Button variant="primary" disabled={busy || !name || !model} onClick={addProvider}>
新增模型
<Button variant="primary" disabled={busy || !model || !baseUrl} onClick={addProvider}>
保存模型
</Button>
}
/>
@@ -102,12 +105,21 @@ export function LlmModule(): React.ReactNode {
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">新增模型(进程生命周期,未绑定路由)</h3>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-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)]">名称</span>
<input value={name} onChange={(e) => setName(e.target.value)} className={inputCls} />
<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} />
@@ -117,10 +129,16 @@ export function LlmModule(): React.ReactNode {
<input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.example.com/v1" className={inputCls} />
</label>
</div>
<div className="flex items-center gap-2">
<Switch checked={multimodal} onChange={setMultimodal} label="多模态" />
<span className="text-xs text-[var(--mu-text-lo)]">API key 不入库、不进 wire、不泄露;测试端点每次用请求内的 key 直接调用。</span>
<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>
<Surface className="space-y-3">
@@ -134,7 +152,7 @@ export function LlmModule(): React.ReactNode {
))}
</Surface>
<Notice>per-role 路由 + 降级逻辑由后端 loop 实现;新增模型记录为进程生命周期临时(不持久化/config 不变)。</Notice>
<Notice>per-role 路由 + 降级逻辑由后端 loop 实现;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1)。.env 为兜底。</Notice>
</div>
);
}
+9 -10
View File
@@ -18,6 +18,7 @@ import type {
ChannelTestResult,
ChatMessage,
ChatSyncResult,
LlmProviderRequest,
LlmTestRequest,
LlmTestResult,
LoginResult,
@@ -452,16 +453,14 @@ export function adminRunAutomation(id: string): Promise<AutomationRunResult> {
export function adminDeleteAutomation(id: string): Promise<{ id: string; deleted: boolean }> {
return request<{ id: string; deleted: boolean }>(`/admin/automations/${encodeURIComponent(id)}`, { method: "DELETE" });
}
/** POST /api/v1/admin/llm — records a provider for the process lifetime (temporary). */
export function adminAddLlmProvider(req: {
id?: string;
name: string;
baseUrl?: string;
model: string;
multimodal?: boolean;
fallback?: string;
}): Promise<{ provider: AdminLlmProvider; temporary: boolean }> {
return request<{ provider: AdminLlmProvider; temporary: boolean }>("/admin/llm", {
/** POST /api/v1/admin/llm — saves a provider group to models.json and hot-reloads
* the running LLM stack (design/04 §3.1). The provider is the wire protocol. */
export function adminAddLlmProvider(req: LlmProviderRequest): Promise<{
provider: AdminLlmProvider;
persisted: boolean;
path?: string;
}> {
return request<{ provider: AdminLlmProvider; persisted: boolean; path?: string }>("/admin/llm", {
method: "POST",
body: JSON.stringify(req),
});
+9 -4
View File
@@ -272,10 +272,12 @@ export interface AdminUserRecord {
createdAt?: string;
}
/** LLM provider/model configuration (GET/PUT /api/v1/admin/llm, planned M10). */
/** 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 {
id: string;
name: string;
provider: string;
baseUrl: string;
model: string;
multimodal: boolean;
@@ -433,11 +435,14 @@ export interface AutomationRunResult {
output: unknown;
}
/** POST /api/v1/admin/llm body. */
/** POST /api/v1/admin/llm body — saves a provider group to models.json and
* hot-reloads the running LLM stack (persisted + active). */
export interface LlmProviderRequest {
id?: string;
name: string;
baseUrl?: string;
name?: string;
provider?: string;
baseUrl: string;
apiKey?: string;
model: string;
multimodal?: boolean;
fallback?: string;
+263
View File
@@ -0,0 +1,263 @@
// Package runtimeconfig loads and persists the runtime-editable provider
// configuration: the independent LLM vendor groups in models.json (design/04
// §3.1). The .env LLM block is the final fallback; a present-and-valid
// models.json takes precedence and is hot-reloaded at runtime without a restart.
//
// models.json is gitignored (local runtime config) and holds API keys, so this
// package never logs a key and never exposes one un-masked in a Config().
package runtimeconfig
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// Provider names recognized in models.json. They mirror the model adapter
// factory's protocol names so a group maps directly to a wire protocol.
const (
ProviderOpenAI = "openai"
ProviderAnthropic = "anthropic"
// modelsFileName is the models.json basename stored under the data dir.
modelsFileName = "models.json"
)
// Group is one independent multi-supplier LLM configuration. Each vendor's
// base/key/model/fallback is a self-contained unit, so multiple vendors coexist
// and Provider selects the active wire protocol.
type Group struct {
ID string `json:"id"`
Provider string `json:"provider"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Model string `json:"model"`
Fallback string `json:"fallback,omitempty"`
Multimodal bool `json:"multimodal,omitempty"`
}
// ErrInvalidGroup is returned when a group fails schema validation.
var ErrInvalidGroup = errors.New("runtimeconfig: invalid model group")
// DefaultPath returns the models.json path under dataDir.
func DefaultPath(dataDir string) string {
return filepath.Join(dataDir, modelsFileName)
}
// Validate checks the group's required fields. It fails loudly so a bad group
// never silently produces a half-configured adapter.
func (g Group) Validate() error {
if strings.TrimSpace(g.ID) == "" {
return fmt.Errorf("id is required")
}
if g.Provider != ProviderOpenAI && g.Provider != ProviderAnthropic {
return fmt.Errorf("provider %q must be openai or anthropic", g.Provider)
}
if strings.TrimSpace(g.BaseURL) == "" {
return fmt.Errorf("baseUrl is required")
}
if strings.TrimSpace(g.Model) == "" {
return fmt.Errorf("model is required")
}
return nil
}
// ToLLMConfig maps a group to the flat LLMConfig consumed by the adapter
// factory. The group's provider selects which config block is populated.
func (g Group) ToLLMConfig() config.LLMConfig {
if g.Provider == ProviderAnthropic {
return config.LLMConfig{
Provider: ProviderAnthropic,
AnthropicBaseURL: g.BaseURL,
AnthropicAPIKey: g.APIKey,
AnthropicModel: g.Model,
AnthropicFallback: g.Fallback,
}
}
return config.LLMConfig{
Provider: ProviderOpenAI,
BaseURL: g.BaseURL,
APIKey: g.APIKey,
Model: g.Model,
Fallback: g.Fallback,
}
}
// MaskedKey returns a redacted form of the API key for display/logging. It never
// reveals more than the trailing 4 characters, so a key never lands verbatim in
// a log line or a wire response.
func (g Group) MaskedKey() string {
k := strings.TrimSpace(g.APIKey)
if k == "" {
return ""
}
if len(k) <= 4 {
return "****"
}
return "****" + k[len(k)-4:]
}
// LoadModels reads path and returns the effective LLM groups. When the file is
// absent it synthesizes a single group from env (the .env fallback). A
// present-but-invalid file falls back to env and returns a non-nil warning so
// the caller can log it; the returned groups are always non-empty.
func LoadModels(path string, env config.LLMConfig) ([]Group, error) {
files, readErr := loadFile(path)
if readErr == nil {
return files, nil
}
// A missing file is the normal case (gitignored runtime config); only a
// present-but-invalid file warrants a warning to the caller.
var warn error
if !os.IsNotExist(readErr) {
warn = fmt.Errorf("runtimeconfig: %s: %w", path, readErr)
}
return GroupFromEnv(env), warn
}
// loadFile decodes and validates the models.json array. A nil/blank file, a
// JSON decode error or an empty array all return an error so the caller falls
// back to env.
func loadFile(path string) ([]Group, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if len(strings.TrimSpace(string(raw))) == 0 {
return nil, fmt.Errorf("%w: empty file", ErrInvalidGroup)
}
var groups []Group
dec := json.NewDecoder(strings.NewReader(string(raw)))
if err := dec.Decode(&groups); err != nil {
return nil, fmt.Errorf("%w: decode: %v", ErrInvalidGroup, err)
}
if len(groups) == 0 {
return nil, fmt.Errorf("%w: no groups", ErrInvalidGroup)
}
for i, g := range groups {
if err := g.Validate(); err != nil {
return nil, fmt.Errorf("%w: group %d: %v", ErrInvalidGroup, i, err)
}
}
return groups, nil
}
// GroupFromEnv synthesizes a single group from the active env LLM block: the
// anthropic block when LLM_PROVIDER=anthropic, otherwise the flat OpenAI-compatible
// block. It is the .env fallback used when models.json is absent or invalid.
func GroupFromEnv(env config.LLMConfig) []Group {
if env.Provider == ProviderAnthropic {
return []Group{{
ID: ProviderAnthropic,
Provider: ProviderAnthropic,
BaseURL: env.AnthropicBaseURL,
APIKey: env.AnthropicAPIKey,
Model: env.AnthropicModel,
Fallback: env.AnthropicFallback,
Multimodal: env.VisionModel != "",
}}
}
return []Group{{
ID: ProviderOpenAI,
Provider: ProviderOpenAI,
BaseURL: env.BaseURL,
APIKey: env.APIKey,
Model: env.Model,
Fallback: env.Fallback,
Multimodal: env.VisionModel != "",
}}
}
// 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.
func Upsert(groups []Group, g Group) []Group {
if strings.TrimSpace(g.ID) == "" {
g.ID = NewID()
}
out := make([]Group, 0, len(groups)+1)
replaced := false
for _, cur := range groups {
if cur.ID == g.ID {
out = append(out, g)
replaced = true
continue
}
out = append(out, cur)
}
if !replaced {
out = append(out, g)
}
return out
}
// Activate returns groups with g promoted to the front (index 0 = the group a
// new request uses by default). An existing group with the same id is replaced
// and re-prepended, so a panel save both updates the group and makes it the live
// provider. A blank ID is assigned a generated id.
func Activate(groups []Group, g Group) []Group {
if strings.TrimSpace(g.ID) == "" {
g.ID = NewID()
}
out := make([]Group, 0, len(groups)+1)
out = append(out, g)
for _, cur := range groups {
if cur.ID == g.ID {
continue
}
out = append(out, cur)
}
return out
}
// SaveModels writes groups as a pretty-printed JSON array to path with mode
// 0600 (the file holds API keys). It writes to a temp file then renames so a
// concurrent reader never sees a partial file. Every group is validated first.
func SaveModels(path string, groups []Group) error {
dir := filepath.Dir(path)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("runtimeconfig: mkdir: %w", err)
}
}
for _, g := range groups {
if err := g.Validate(); err != nil {
return fmt.Errorf("runtimeconfig: save: %w", err)
}
}
raw, err := json.MarshalIndent(groups, "", " ")
if err != nil {
return fmt.Errorf("runtimeconfig: marshal: %w", err)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return fmt.Errorf("runtimeconfig: write %s: %w", tmp, err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("runtimeconfig: rename: %w", err)
}
return nil
}
// NewID returns a 16-byte hex id, used for a group saved without an explicit id.
func NewID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("id-%d", newTimeUnixNano())
}
return hex.EncodeToString(b)
}
// newTimeUnixNano returns the current unix-nano as an integer, as a fallback id
// source when the crypto RNG fails.
func newTimeUnixNano() int64 {
return time.Now().UnixNano()
}
@@ -0,0 +1,175 @@
package runtimeconfig
import (
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
func envCfg() config.LLMConfig {
return config.LLMConfig{
Provider: ProviderOpenAI,
BaseURL: "https://api.openai.com/v1",
APIKey: "sk-env",
Model: "gpt-4o",
Fallback: "gpt-4o-mini",
}
}
func writeModelsFile(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "models.json")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write models.json: %v", err)
}
return path
}
func TestLoadModels_FileValid(t *testing.T) {
path := writeModelsFile(t, `[
{"id":"openai","provider":"openai","baseUrl":"https://api.openai.com/v1","apiKey":"sk-1","model":"gpt-4o","fallback":"gpt-4o-mini","multimodal":true}
]`)
groups, warn := LoadModels(path, envCfg())
if warn != nil {
t.Fatalf("warn = %v, want nil", warn)
}
if len(groups) != 1 {
t.Fatalf("len(groups) = %d, want 1", len(groups))
}
g := groups[0]
if g.ID != "openai" || g.Provider != ProviderOpenAI || g.Model != "gpt-4o" || !g.Multimodal {
t.Fatalf("group = %+v, want openai/gpt-4o/multimodal", g)
}
}
func TestLoadModels_MissingFileFallsBackEnv(t *testing.T) {
path := filepath.Join(t.TempDir(), "nonexistent.json")
groups, warn := LoadModels(path, envCfg())
if warn != nil {
t.Fatalf("warn = %v, want nil (missing file is the normal case)", warn)
}
if len(groups) != 1 || groups[0].Model != "gpt-4o" || groups[0].APIKey != "sk-env" {
t.Fatalf("groups = %+v, want env fallback gpt-4o/sk-env", groups)
}
}
func TestLoadModels_InvalidFallsBackEnvWithWarn(t *testing.T) {
path := writeModelsFile(t, `{ not json`)
groups, warn := LoadModels(path, envCfg())
if warn == nil {
t.Fatal("warn = nil, want a warning for an invalid file")
}
if len(groups) != 1 || groups[0].APIKey != "sk-env" {
t.Fatalf("groups = %+v, want env fallback", groups)
}
}
func TestLoadModels_EmptyArrayFallsBackEnv(t *testing.T) {
path := writeModelsFile(t, `[]`)
groups, warn := LoadModels(path, envCfg())
if warn == nil {
t.Fatal("warn = nil, want a warning for an empty file")
}
if len(groups) != 1 {
t.Fatalf("groups = %+v, want env fallback single group", groups)
}
}
func TestValidate(t *testing.T) {
valid := Group{ID: "a", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "m"}
if err := valid.Validate(); err != nil {
t.Fatalf("valid group: %v", err)
}
cases := []Group{
{ID: "", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "m"},
{ID: "a", Provider: "bogus", BaseURL: "https://x", Model: "m"},
{ID: "a", Provider: ProviderOpenAI, BaseURL: "", Model: "m"},
{ID: "a", Provider: ProviderOpenAI, BaseURL: "https://x", Model: ""},
}
for i, c := range cases {
if err := c.Validate(); err == nil {
t.Fatalf("case %d failed to validate: %+v", i, c)
}
}
}
func TestToLLMConfig(t *testing.T) {
g := Group{ID: "a", Provider: ProviderOpenAI, BaseURL: "https://x", APIKey: "sk", Model: "m", Fallback: "f"}
cfg := g.ToLLMConfig()
if cfg.Provider != ProviderOpenAI || cfg.BaseURL != "https://x" || cfg.APIKey != "sk" || cfg.Model != "m" || cfg.Fallback != "f" {
t.Fatalf("ToLLMConfig = %+v", cfg)
}
ag := Group{ID: "b", Provider: ProviderAnthropic, BaseURL: "https://y", APIKey: "sk2", Model: "claude"}
acfg := ag.ToLLMConfig()
if acfg.Provider != ProviderAnthropic || acfg.AnthropicBaseURL != "https://y" || acfg.AnthropicModel != "claude" {
t.Fatalf("anthropic ToLLMConfig = %+v", acfg)
}
}
func TestMaskedKey(t *testing.T) {
empty := Group{APIKey: ""}.MaskedKey()
if empty != "" {
t.Fatalf("empty key masked = %q, want empty", empty)
}
short := Group{APIKey: "abc"}.MaskedKey()
if short != "****" {
t.Fatalf("short key masked = %q, want ****", short)
}
long := Group{APIKey: "sk-abcdef1234"}.MaskedKey()
if long != "****1234" || strings.Contains(long, "sk-") {
t.Fatalf("masked = %q, want ****1234 without the prefix", long)
}
}
func TestActivate(t *testing.T) {
base := []Group{
{ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://a", Model: "gpt-4o"},
{ID: "anthropic", Provider: ProviderAnthropic, BaseURL: "https://b", Model: "claude"},
}
// New group is prepended (becomes the active index 0).
out := Activate(base, Group{ID: "deepseek", Provider: ProviderOpenAI, BaseURL: "https://c", Model: "ds"})
if len(out) != 3 || out[0].ID != "deepseek" || out[1].ID != "openai" || out[2].ID != "anthropic" {
t.Fatalf("Activate new = %+v", out)
}
// An existing group is replaced and promoted to the front.
out2 := Activate(out, Group{ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://a2", Model: "gpt-4o-new"})
if len(out2) != 3 || out2[0].ID != "openai" || out2[0].Model != "gpt-4o-new" || out2[1].ID != "deepseek" {
t.Fatalf("Activate existing = %+v", out2)
}
}
func TestSaveLoadRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "models.json")
in := []Group{
{ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://a", APIKey: "sk-secret", Model: "gpt-4o", Multimodal: true},
{ID: "anthropic", Provider: ProviderAnthropic, BaseURL: "https://b", APIKey: "sk-ant", Model: "claude"},
}
if err := SaveModels(path, in); err != nil {
t.Fatalf("SaveModels: %v", err)
}
groups, warn := LoadModels(path, envCfg())
if warn != nil {
t.Fatalf("warn = %v", warn)
}
if len(groups) != 2 || groups[0].APIKey != "sk-secret" || groups[1].APIKey != "sk-ant" {
t.Fatalf("round trip = %+v", groups)
}
// The key stays in the file (it is the runtime config), but never leaks into
// the masked view.
if got := groups[0].MaskedKey(); got != "****cret" {
t.Fatalf("masked key = %q, want ****cret", got)
}
}
func TestSaveModels_RejectsInvalid(t *testing.T) {
path := filepath.Join(t.TempDir(), "models.json")
if err := SaveModels(path, []Group{{ID: "", Provider: "x", BaseURL: "https://a", Model: "m"}}); err == nil {
t.Fatal("expected error saving an invalid group")
}
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
t.Fatalf("file should not be written when validation fails")
}
}
+4 -3
View File
@@ -38,7 +38,7 @@ type Agent struct {
tools *tool.Dispatcher
guards *guardrail.Pipeline
adapter model.Adapter
router *model.Router
router model.ModelRouter
recorder model.Recorder
handoff *Handoff
opts Options
@@ -86,8 +86,9 @@ func (a *Agent) Guardrails() *guardrail.Pipeline { return a.guards }
// Adapter returns the injected model adapter.
func (a *Agent) Adapter() model.Adapter { return a.adapter }
// Router returns the injected per-role router, or nil.
func (a *Agent) Router() *model.Router { return a.router }
// Router returns the injected per-role router, or nil. The concrete implementation
// may be a *model.Router or the hot-swappable model.RuntimeLLM.
func (a *Agent) Router() model.ModelRouter { return a.router }
// Model returns the configured model name. Empty means the injected Router or
// the adapter's own default decides.
+4 -2
View File
@@ -40,8 +40,10 @@ type Option func(*Agent)
// optional per-role Router. When router is non-nil it overrides the Request's
// Model from the request contents — which is how vision requests are routed to
// the vision model — otherwise the agent's configured Model or the adapter
// default applies.
func WithModel(adapter model.Adapter, router *model.Router) Option {
// default applies. router is a model.ModelRouter (implemented by *model.Router
// and by the hot-swappable model.RuntimeLLM) so a reloaded provider can swap it
// without rebuilding the agent.
func WithModel(adapter model.Adapter, router model.ModelRouter) Option {
return func(a *Agent) {
a.adapter = adapter
a.router = router
+177
View File
@@ -0,0 +1,177 @@
package model
import (
"context"
"errors"
"io"
"log/slog"
"sync/atomic"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// ModelRouter is the routing contract the agent (and loop) depend on. *Router
// implements it; RuntimeLLM implements it too, so a hot-swappable provider can
// serve as both the adapter and the router without a separate router reference
// to keep in sync.
type ModelRouter interface {
RouteRequest(req Request) (model string, needsVision bool)
}
// runtimeChainSnapshot is an immutable, self-contained LLM stack snapshot. It is
// stored and swapped atomically: a request that already captured a snapshot
// keeps using that exact chain+router for its whole call, so a reload never
// interrupts an in-flight request.
type runtimeChainSnapshot struct {
chain *Chain
router *Router
group runtimeconfig.Group
groups []runtimeconfig.Group
}
// RuntimeLLM is the hot-swappable LLM provider stack for the runtime. It
// implements Adapter (delegating to the current chain) and ModelRouter
// (delegating to the current router). A panel save reloads models.json, rebuilds
// a fresh chain+router and atomically swaps it in: new calls use the new stack
// while calls already in flight finish on the old one.
type RuntimeLLM struct {
path string
env config.LLMConfig
log *slog.Logger
current atomic.Value // *runtimeChainSnapshot
}
// NewRuntimeLLM builds the provider stack from models.json at path, falling back
// to env. It loads the initial snapshot so the runtime is usable immediately.
// log may be nil (a discard logger is used).
func NewRuntimeLLM(path string, env config.LLMConfig, log *slog.Logger) *RuntimeLLM {
if log == nil {
log = slog.New(slog.NewTextHandler(io.Discard, nil))
}
m := &RuntimeLLM{path: path, env: env, log: logger.WithSource(log, "model")}
m.loadInitial()
return m
}
// Path returns the models.json path this manager reads and reloads from.
func (m *RuntimeLLM) Path() string { return m.path }
// Groups returns a copy of the current loaded groups; the active group (the one
// a new request uses by default) is index 0.
func (m *RuntimeLLM) Groups() []runtimeconfig.Group {
src := m.load().groups
out := make([]runtimeconfig.Group, len(src))
copy(out, src)
return out
}
// Group returns the active group (index 0).
func (m *RuntimeLLM) Group() runtimeconfig.Group { return m.load().group }
// Current returns the current chain and router. The returned chain stays valid
// for calls already in flight; new calls pick up the latest swap.
func (m *RuntimeLLM) Current() (*Chain, *Router) {
snap := m.load()
return snap.chain, snap.router
}
// Reload re-reads models.json (falling back to env) and atomically rebuilds the
// in-use chain+router. In-flight calls keep the previous snapshot.
func (m *RuntimeLLM) Reload() error {
groups, warn := runtimeconfig.LoadModels(m.path, m.env)
if warn != nil {
m.log.Warn("models.json reload fell back to env", "error", warn)
}
return m.swap(groups)
}
// Provider returns the active group's provider protocol.
func (m *RuntimeLLM) Provider() string { return m.load().group.Provider }
// Model returns the active group's primary model name.
func (m *RuntimeLLM) Model() string { return m.load().group.Model }
// Name implements Adapter, returning the active group id.
func (m *RuntimeLLM) Name() string { return m.load().group.ID }
// Chat implements Adapter, delegating to the current chain. A request that began
// before a reload completes on the snapshot it captured. When no model is
// configured (nil chain) it returns ErrNoModel rather than panicking.
func (m *RuntimeLLM) Chat(ctx context.Context, req Request, opts Options) (Response, error) {
chain := m.load().chain
if chain == nil {
return Response{}, ErrNoModel
}
return chain.Chat(ctx, req, opts)
}
// ChatStream implements Adapter.
func (m *RuntimeLLM) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) {
chain := m.load().chain
if chain == nil {
return Response{}, ErrNoModel
}
return chain.ChatStream(ctx, req, onEvent)
}
// RouteRequest implements ModelRouter, delegating to the current router.
func (m *RuntimeLLM) RouteRequest(req Request) (string, bool) {
r := m.load().router
if r == nil {
return "", false
}
return r.RouteRequest(req)
}
// loadInitial builds the first snapshot; a build failure leaves a nil chain so
// calls report a clear no-model error instead of panicking.
func (m *RuntimeLLM) loadInitial() {
groups, warn := runtimeconfig.LoadModels(m.path, m.env)
if warn != nil {
m.log.Warn("models.json load fell back to env", "error", warn)
}
_ = m.swap(groups)
}
// swap rebuilds and atomically stores a snapshot for groups. A blank slice falls
// back to the env-synthesized group so there is always at least one.
func (m *RuntimeLLM) swap(groups []runtimeconfig.Group) error {
if len(groups) == 0 {
groups = runtimeconfig.GroupFromEnv(m.env)
}
snap, err := buildSnapshot(groups, m.env)
if err != nil {
return err
}
m.current.Store(snap)
return nil
}
// load returns the current snapshot, or an empty (nil chain) one when the
// manager was never initialized.
func (m *RuntimeLLM) load() *runtimeChainSnapshot {
if v := m.current.Load(); v != nil {
return v.(*runtimeChainSnapshot)
}
return &runtimeChainSnapshot{}
}
// buildSnapshot turns the active group (index 0) into a chain+router. A group
// without a base URL/model yields a nil chain (no model configured), surfaced as
// ErrNoModel by the adapter methods. The Router keeps the env vision model so a
// dedicated vision model survives a reload that only changes the vendor group.
func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig) (*runtimeChainSnapshot, error) {
active := groups[0]
cfg := active.ToLLMConfig()
chain, err := NewChatChain(cfg)
if err != nil && !errors.Is(err, ErrNoModel) {
return nil, err
}
router := NewRouter(DefaultModelForProvider(cfg),
WithVisionModel(env.VisionModel),
WithDefaultProvider(cfg.Provider),
)
return &runtimeChainSnapshot{chain: chain, router: router, group: active, groups: groups}, nil
}
+148
View File
@@ -0,0 +1,148 @@
package model
import (
"context"
"path/filepath"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
func rtEnv() config.LLMConfig {
// Env fallback matches "openai default" (provider not anthropic).
return config.LLMConfig{
Provider: ProviderOpenAI,
BaseURL: "https://api.openai.com/v1",
APIKey: "sk-env",
Model: "gpt-4o",
}
}
func writeRTFile(t *testing.T, groups []runtimeconfig.Group) string {
t.Helper()
path := filepath.Join(t.TempDir(), "models.json")
if err := runtimeconfig.SaveModels(path, groups); err != nil {
t.Fatalf("SaveModels: %v", err)
}
return path
}
func TestRuntimeLLM_EnvFallback(t *testing.T) {
// Missing file → env fallback provider/model.
m := NewRuntimeLLM(filepath.Join(t.TempDir(), "nope.json"), rtEnv(), nil)
if m.Provider() != ProviderOpenAI || m.Model() != "gpt-4o" {
t.Fatalf("fallback Provider=%q Model=%q, want openai/gpt-4o", m.Provider(), m.Model())
}
chain, router := m.Current()
if router == nil {
t.Fatal("router is nil after env fallback")
}
if chain == nil {
// No real endpoint is fine; the adapter should report ErrNoModel, not panic.
if _, err := m.Chat(context.Background(), Request{Messages: nil}, Options{}); err != ErrNoModel {
t.Fatalf("Chat on empty config err = %v, want ErrNoModel", err)
}
}
}
func TestRuntimeLLM_FileLoad(t *testing.T) {
path := writeRTFile(t, []runtimeconfig.Group{
{ID: "deepseek", Provider: ProviderOpenAI, BaseURL: "https://api.deepseek.com/v1", APIKey: "sk-ds", Model: "deepseek-chat"},
})
m := NewRuntimeLLM(path, rtEnv(), nil)
if m.Provider() != ProviderOpenAI || m.Model() != "deepseek-chat" {
t.Fatalf("Provider=%q Model=%q, want openai/deepseek-chat", m.Provider(), m.Model())
}
if m.Group().ID != "deepseek" {
t.Fatalf("Group().ID = %q, want deepseek", m.Group().ID)
}
}
func TestRuntimeLLM_ReloadReflectsNewProviderModel(t *testing.T) {
path := writeRTFile(t, []runtimeconfig.Group{
{ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://api.openai.com/v1", APIKey: "sk-1", Model: "gpt-4o"},
})
m := NewRuntimeLLM(path, rtEnv(), nil)
if m.Model() != "gpt-4o" {
t.Fatalf("before reload Model = %q, want gpt-4o", m.Model())
}
// Rewrite the file with an anthropic provider/model, then reload.
if err := runtimeconfig.SaveModels(path, []runtimeconfig.Group{
{ID: "claude", Provider: ProviderAnthropic, BaseURL: "https://api.anthropic.com", APIKey: "sk-ant", Model: "claude-sonnet-4"},
}); err != nil {
t.Fatalf("SaveModels: %v", err)
}
if err := m.Reload(); err != nil {
t.Fatalf("Reload: %v", err)
}
if m.Provider() != ProviderAnthropic || m.Model() != "claude-sonnet-4" {
t.Fatalf("after reload Provider=%q Model=%q, want anthropic/claude-sonnet-4", m.Provider(), m.Model())
}
chain, router := m.Current()
if router == nil || chain == nil {
t.Fatal("after reload Current() returned nil chain/router")
}
}
func TestRuntimeLLM_ImplementsAdapterAndRouter(t *testing.T) {
path := writeRTFile(t, []runtimeconfig.Group{
{ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://x", APIKey: "sk", Model: "m"},
})
m := NewRuntimeLLM(path, rtEnv(), nil)
var _ Adapter = m
var _ ModelRouter = m
if m.Name() != "openai" {
t.Fatalf("Name = %q, want openai", m.Name())
}
modelName, _ := m.RouteRequest(Request{})
if modelName != "m" {
t.Fatalf("RouteRequest = %q, want the active model m", modelName)
}
}
func TestRuntimeLLM_ConcurrentReadDuringReloadNoPanic(t *testing.T) {
path := writeRTFile(t, []runtimeconfig.Group{
{ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://api.openai.com/v1", APIKey: "sk-1", Model: "gpt-4o"},
})
m := NewRuntimeLLM(path, rtEnv(), nil)
const readers = 8
var wg sync.WaitGroup
stop := make(chan struct{})
for i := 0; i < readers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
_ = m.Provider()
_ = m.Model()
chain, router := m.Current()
if router == nil {
t.Error("router is nil during concurrent read")
return
}
if chain == nil {
// A nil chain means the config had no base/model; our test file has one,
// so a swap must always produce a chain. Guard anyway.
t.Error("chain is nil during concurrent read")
return
}
}
}()
}
// Reload a number of times while the readers spin.
for i := 0; i < 200; i++ {
if err := m.Reload(); err != nil {
t.Fatalf("reload %d: %v", i, err)
}
}
close(stop)
wg.Wait()
}
+40 -28
View File
@@ -11,6 +11,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
@@ -47,6 +48,7 @@ type policyRequest struct {
type AdminLlmProvider struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
BaseURL string `json:"baseUrl"`
Model string `json:"model"`
Multimodal bool `json:"multimodal"`
@@ -164,39 +166,49 @@ func (s *Server) adminListIoT(w http.ResponseWriter, r *http.Request) {
}
// adminGetLlm returns the LLM provider/model configuration read from the live
// config. API keys are never returned (the wire shape carries no key field), so
// the handler leaks nothing sensitive.
// provider stack (models.json, falling back to .env). API keys are never
// returned — the wire shape carries no key field — so the handler leaks nothing
// sensitive.
func (s *Server) adminGetLlm(w http.ResponseWriter, r *http.Request) {
cfg := s.cfg.LLM
providers := []AdminLlmProvider{
{
ID: "openai",
Name: "OpenAI",
BaseURL: cfg.BaseURL,
Model: cfg.Model,
Multimodal: cfg.VisionModel != "",
Enabled: cfg.Provider != "anthropic",
Fallback: cfg.Fallback,
},
{
ID: "anthropic",
Name: "Anthropic",
BaseURL: cfg.AnthropicBaseURL,
Model: cfg.AnthropicModel,
Multimodal: false,
Enabled: cfg.Provider == "anthropic",
Fallback: cfg.AnthropicFallback,
},
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,
})
}
// Fold in providers added at runtime via the admin "add provider" write.
// They are process-lifetime and not bound to the model router, so the panel
// reflects them but the response marks nothing else.
s.llmMu.RLock()
providers = append(providers, s.llmExtra...)
s.llmMu.RUnlock()
writeJSON(w, http.StatusOK, AdminLlmConfig{Providers: providers})
}
// currentLlmGroups returns the provider groups to display: the live stack's
// groups when one is wired, otherwise the env-synthesized single group.
func (s *Server) currentLlmGroups() []runtimeconfig.Group {
if s.llm != nil {
return s.llm.Groups()
}
return runtimeconfig.GroupFromEnv(s.cfg.LLM)
}
// providerDisplayName returns a friendly label for a provider group, using the
// vendor name for the two known protocols and the group id otherwise.
func providerDisplayName(g runtimeconfig.Group) string {
switch g.Provider {
case runtimeconfig.ProviderOpenAI:
return "OpenAI"
case runtimeconfig.ProviderAnthropic:
return "Anthropic"
default:
return g.ID
}
}
// adminListAutomations lists automations and reminders (admin read).
func (s *Server) adminListAutomations(w http.ResponseWriter, r *http.Request) {
out := make([]AdminAutomationRecord, 0)
@@ -2,10 +2,7 @@ package gateway
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
@@ -14,6 +11,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
@@ -35,40 +33,92 @@ const (
llmProbePrompt = "Reply with the single word: OK"
)
// llmProviderRequest is the POST /api/v1/admin/llm body for adding a provider.
// 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).
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"`
}
// adminAddLlmProvider records a provider for the process lifetime, reflected in
// the GET /admin/llm listing. It is not bound to the model router (config is
// immutable after startup), so it is reported as temporary in the response.
// 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.
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.Name) == "" || strings.TrimSpace(req.Model) == "" {
if strings.TrimSpace(req.Model) == "" || strings.TrimSpace(req.BaseURL) == "" {
s.auditClaims(r, "llm.addProvider", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name and model are required"))
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "baseUrl and model are required"))
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 = newIDHex()
id = runtimeconfig.NewID()
}
// 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,
})
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)
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_persist_failed", "failed to save models.json"))
return
}
if err := s.llm.Reload(); err != nil {
s.auditClaims(r, "llm.addProvider", "error")
s.log.Error("llm reload", "error", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_reload_failed", "saved but failed to hot-reload"))
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,
}
provider := AdminLlmProvider{ID: id, Name: req.Name, BaseURL: req.BaseURL, Model: req.Model, Multimodal: req.Multimodal, Fallback: req.Fallback}
s.llmMu.Lock()
s.llmExtra = append(s.llmExtra, provider)
s.llmMu.Unlock()
s.auditClaims(r, "llm.addProvider", "ok")
writeJSON(w, http.StatusCreated, map[string]any{"provider": provider, "temporary": true})
writeJSON(w, http.StatusCreated, map[string]any{
"provider": saved,
"persisted": true,
"path": s.llm.Path(),
})
}
// llmTestRequest is the POST /api/v1/admin/llm/test body.
@@ -267,12 +317,3 @@ func describeIdentity(a adapter.Actor, _ *user.OwnerProfile) string {
return "未命中主人白名单 → 按陌生人处理(default deny,记忆只读不写)"
}
}
// newIDHex returns a 16-byte hex id.
func newIDHex() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("id-%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
@@ -6,14 +6,19 @@ import (
"io"
"log/slog"
"net/http"
"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"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
func gwTestLogger() *slog.Logger {
@@ -209,6 +214,125 @@ func TestAdminIndexRagUnwired501(t *testing.T) {
}
}
// newLlmRuntimeForGateway builds a hot-swappable RuntimeLLM pointing at a fresh
// temp models.json, seeded by the env config. It is the real stack the admin
// write endpoint persists to and reloads.
func newLlmRuntimeForGateway(t *testing.T, modelsPath string, env config.LLMConfig) *model.RuntimeLLM {
t.Helper()
return model.NewRuntimeLLM(modelsPath, env, gwTestLogger())
}
func TestAdminAddLlmProviderPersistsAndReloads(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)
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}, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
var res struct {
Provider AdminLlmProvider `json:"provider"`
Persisted bool `json:"persisted"`
Path string `json:"path"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
t.Fatalf("decode add: %v", err)
}
if !res.Persisted || res.Path != modelsPath || res.Provider.Model != "deepseek-chat" {
t.Fatalf("add res = %+v", res)
}
// models.json now holds the saved group (promoted to index 0 = active).
groups, warn := runtimeconfig.LoadModels(modelsPath, env)
if warn != nil {
t.Fatalf("load models warn = %v", warn)
}
if len(groups) == 0 || groups[0].ID != "deepseek" || groups[0].APIKey != "sk-ds" {
t.Fatalf("file groups = %+v, want deepseek first with sk-ds", groups)
}
// The running stack reflects the new active provider/model (hot reload).
if runtime.Provider() != "openai" || runtime.Model() != "deepseek-chat" {
t.Fatalf("runtime Provider=%q Model=%q, want openai/deepseek-chat", runtime.Provider(), runtime.Model())
}
// GET /admin/llm reflects the saved group and never leaks any API key.
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", gResp.StatusCode)
}
var list AdminLlmConfig
if err := json.Unmarshal(decodeBody(t, gBody)["data"], &list); err != nil {
t.Fatalf("decode list: %v", err)
}
if len(list.Providers) == 0 || list.Providers[0].Model != "deepseek-chat" {
t.Fatalf("list = %+v, want deepseek-chat first", list.Providers)
}
if strings.Contains(string(gBody), "sk-ds") || strings.Contains(string(gBody), "sk-env") {
t.Fatalf("API key leaked in GET /admin/llm: %s", gBody)
}
}
func TestAdminAddLlmProviderSwitchProvider(t *testing.T) {
t.Parallel()
modelsPath := filepath.Join(t.TempDir(), "models.json")
env := config.LLMConfig{Provider: "openai", BaseURL: "https://x", APIKey: "sk", Model: "m"}
runtime := newLlmRuntimeForGateway(t, modelsPath, env)
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// Saving an anthropic group makes it the active provider/model.
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
map[string]any{"provider": "anthropic", "baseUrl": "https://api.anthropic.com", "apiKey": "sk-ant", "model": "claude-sonnet-4"}, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
if runtime.Provider() != "anthropic" || runtime.Model() != "claude-sonnet-4" {
t.Fatalf("runtime Provider=%q Model=%q, want anthropic/claude-sonnet-4", runtime.Provider(), runtime.Model())
}
// The saved group is active (index 0) and anthropic.
if runtime.Group().Provider != "anthropic" {
t.Fatalf("active group = %+v, want anthropic", runtime.Group())
}
}
func TestAdminAddLlmProviderRejectsBadInput(t *testing.T) {
t.Parallel()
modelsPath := filepath.Join(t.TempDir(), "models.json")
runtime := newLlmRuntimeForGateway(t, modelsPath, config.LLMConfig{})
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// Missing model.
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
map[string]any{"baseUrl": "https://x"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("missing model status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
// Invalid provider.
resp2, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
map[string]any{"provider": "bogus", "baseUrl": "https://x", "model": "m"}, tok)
if resp2.StatusCode != http.StatusBadRequest {
t.Fatalf("invalid provider status = %d, want 400", resp2.StatusCode)
}
}
func TestAdminAddLlmProviderUnwired501(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no LLM stack wired
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
map[string]any{"baseUrl": "https://x", "model": "m"}, tok)
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("unwired status = %d, want 500", resp.StatusCode)
}
}
func TestAdminListSessionsHistoryFirst(t *testing.T) {
t.Parallel()
h := newHistoryStoreForGateway(t)
+9 -7
View File
@@ -25,7 +25,6 @@ import (
"net"
"net/http"
"strconv"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
@@ -219,6 +218,10 @@ type Deps struct {
// RAG backs the admin knowledge-base RAG module. Optional; list/search
// return empty when unset, and write endpoints report a clear 501.
RAG RagStore
// LLM is the hot-swappable provider stack (models.json, design/04 §3.1).
// Optional: when nil the admin /llm endpoints fall back to the env config so
// pre-existing gateway tests keep working.
LLM *model.RuntimeLLM
// AuditStore persists the admin audit trail (observability → audit). When nil
// the gateway degrades to an in-memory store so the endpoint still returns
// data; the composition root injects NewSQLiteAuditStore over a.db for real
@@ -263,12 +266,10 @@ type Server struct {
voice VoiceService
streaming bool
frontend fs.FS
// llmMu guards llmExtra, the process-lifetime providers added at runtime via
// the admin "add provider" write. They are reflected in GET /admin/llm but are
// not bound to the model router (config is immutable after startup), so they
// are reported as temporary.
llmMu sync.RWMutex
llmExtra []AdminLlmProvider
// llm is the hot-swappable provider stack (models.json). When nil the
// admin /llm endpoints fall back to the env config. It is optional so tests
// that do not wire it keep working.
llm *model.RuntimeLLM
webhooks map[string]http.Handler
log *slog.Logger
hub *hub
@@ -304,6 +305,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
obs: newObservability(deps),
streaming: deps.Streaming,
frontend: deps.Frontend,
llm: deps.LLM,
webhooks: deps.Webhooks,
log: sourcedLog(deps.Log),
}