diff --git a/.env.example b/.env.example index 1c8cf12..9d35056 100644 --- a/.env.example +++ b/.env.example @@ -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(默认 /models.json,已 gitignore), +# 只要 models.json 存在且有效,运行中的 LLM 就优先用它的多厂商多模型组,并支持面板实时保存→热更新(无需重启)。 +# 仅当 models.json 不存在/无效时才回退到下面的 .env。 # Provider = openai:OpenAI 兼容 /chat/completions(含 OpenAI 兼容网关,也可经它承载 Anthropic 模型,但失去原生 tool input 对象优势) LLM_PROVIDER=openai diff --git a/Docs/api-client.md b/Docs/api-client.md index a428ff7..dd80db5 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -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::<随机短id>`,发送时自动带上,聊天气泡默认小字标注来源(仅当 `deviceId` 存在才显示);**IM 端**按 `:<本地配置唯一id>:<用户平台唯一uid>` 构造(当前 `UnifiedMessage` 尚无 deviceId 概念,channelbridge 以 `::` 占位,`<本地配置唯一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/.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/.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`(定向回请求连接,含合成音频 + 识别文本 + 回复文本)。 diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index 9f6d34b..d1f4ec4 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -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), diff --git a/frontend/web/src/admin/modules/Llm.tsx b/frontend/web/src/admin/modules/Llm.tsx index 1bee5a8..73224bd 100644 --- a/frontend/web/src/admin/modules/Llm.tsx +++ b/frontend/web/src/admin/modules/Llm.tsx @@ -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(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={ - } /> @@ -102,12 +105,21 @@ export function LlmModule(): React.ReactNode { -

新增模型(进程生命周期,未绑定路由)

-
+

新增模型(保存到 models.json,即刻热更新)

+
+ +
+
-
- - API key 不入库、不进 wire、不泄露;测试端点每次用请求内的 key 直接调用。 +
+ +
+ +
+

保存后写入 models.json(本地、已 gitignore),并热更新运行中的 LLM provider,无需重启进程。API key 不在列表/响应中回显。

@@ -134,7 +152,7 @@ export function LlmModule(): React.ReactNode { ))} - per-role 路由 + 降级逻辑由后端 loop 实现;新增模型记录为进程生命周期临时(不持久化/config 不变)。 + per-role 路由 + 降级逻辑由后端 loop 实现;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1)。.env 为兜底。
); } diff --git a/frontend/web/src/lib/api.ts b/frontend/web/src/lib/api.ts index 4228d7d..6b0db11 100644 --- a/frontend/web/src/lib/api.ts +++ b/frontend/web/src/lib/api.ts @@ -18,6 +18,7 @@ import type { ChannelTestResult, ChatMessage, ChatSyncResult, + LlmProviderRequest, LlmTestRequest, LlmTestResult, LoginResult, @@ -452,16 +453,14 @@ export function adminRunAutomation(id: string): Promise { 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), }); diff --git a/frontend/web/src/lib/types.ts b/frontend/web/src/lib/types.ts index 9fc22e6..2e31d8f 100644 --- a/frontend/web/src/lib/types.ts +++ b/frontend/web/src/lib/types.ts @@ -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; diff --git a/internal/config/runtimeconfig/models.go b/internal/config/runtimeconfig/models.go new file mode 100644 index 0000000..7da1716 --- /dev/null +++ b/internal/config/runtimeconfig/models.go @@ -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() +} diff --git a/internal/config/runtimeconfig/models_test.go b/internal/config/runtimeconfig/models_test.go new file mode 100644 index 0000000..a62d7de --- /dev/null +++ b/internal/config/runtimeconfig/models_test.go @@ -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") + } +} diff --git a/internal/runtime/agent/agent.go b/internal/runtime/agent/agent.go index 6748ed3..025c54a 100644 --- a/internal/runtime/agent/agent.go +++ b/internal/runtime/agent/agent.go @@ -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. diff --git a/internal/runtime/agent/options.go b/internal/runtime/agent/options.go index f21a48b..dae270c 100644 --- a/internal/runtime/agent/options.go +++ b/internal/runtime/agent/options.go @@ -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 diff --git a/internal/runtime/model/runtime.go b/internal/runtime/model/runtime.go new file mode 100644 index 0000000..a31f223 --- /dev/null +++ b/internal/runtime/model/runtime.go @@ -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 +} diff --git a/internal/runtime/model/runtime_test.go b/internal/runtime/model/runtime_test.go new file mode 100644 index 0000000..da04df0 --- /dev/null +++ b/internal/runtime/model/runtime_test.go @@ -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() +} diff --git a/internal/services/gateway/admin.go b/internal/services/gateway/admin.go index de2bfff..27c55e3 100644 --- a/internal/services/gateway/admin.go +++ b/internal/services/gateway/admin.go @@ -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) diff --git a/internal/services/gateway/admin_write_llm_channels.go b/internal/services/gateway/admin_write_llm_channels.go index 5a4982e..8c5fdb9 100644 --- a/internal/services/gateway/admin_write_llm_channels.go +++ b/internal/services/gateway/admin_write_llm_channels.go @@ -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) -} diff --git a/internal/services/gateway/admin_write_test.go b/internal/services/gateway/admin_write_test.go index b1558c6..441a66c 100644 --- a/internal/services/gateway/admin_write_test.go +++ b/internal/services/gateway/admin_write_test.go @@ -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) diff --git a/internal/services/gateway/server.go b/internal/services/gateway/server.go index 14d39c6..d4605c4 100644 --- a/internal/services/gateway/server.go +++ b/internal/services/gateway/server.go @@ -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), }