feat(config): 运行时配置体系①——LLM 多厂商独立组落 models.json + 面板落盘 + 热更新
- 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:
1 parent
8cde0162e6
commit
2d7151f979
16 files changed
+1079
-108
No files matched your search
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user