diff --git a/.env.example b/.env.example index cd1b9fe..a58752c 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,17 @@ CHAT_DOCUMENT_MAX_BYTES=20971520 DOCUMENT_GC_INTERVAL=1h DOCUMENT_RETENTION=168h +# ---- LLM 用量样本保留期(llm_usage 表;切片 U1,主人裁决「2.4 落」) ---- +# 每次成功模型调用都会把一条**非敏感**用量样本(token 计数 / 缓存两档 / 模型名 / +# 用途 / 耗时)异步落进 llm_usage 表,供 GET /api/v1/admin/llm/usage 与面板的 +# 用量小卡聚合。**凭据与消息正文绝不入库、绝不进日志**。 +# 写入是有界队列 + 单写者 + 批量事务:队列满就丢弃并限流 WARN,**绝不阻塞模型调用**。 +# LLM_USAGE_RETENTION:保留期(Go duration),默认 720h(30 天)。 +# 启动时清理一次 + 此后每小时清理一次(清扫间隔无 env 键:保留期才是旋钮)。 +# ⚠️ 写 0 或负数 = **关闭清理**(样本全留,日志会写明「llm usage retention disabled」)。 +# `LLM_USAGE_RETENTION=0` 不是「立即清空」——那种解释会让一次笔误把成本视图清空。 +LLM_USAGE_RETENTION=720h + # ---- 认证 / 管理员 ---- # JWT_SECRET 必填,长度 >= 32;缺失或过短会在启动时 panic。 JWT_SECRET=change-me-to-a-random-string-at-least-32-characters diff --git a/Docs/api-client.md b/Docs/api-client.md index 65f19e2..019c108 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -212,6 +212,8 @@ > **发现的不一致(如实登记,2026-10-04 复核)**:design/24 §9.2 与 §9.13 写「白名单 **22** 项——原文的 23 项是笔误,以代码为准」,但 **B1(commit `f82c76d`)纳入 `.pdf` 后代码已是 23 项**(`internal/runtime/documents/store.go` 的 `ExtAllowed` = 22 个文本类 + `.pdf`;`internal/services/gateway/documents.go` 的名字形态校验经它判定)——**以代码的 23 项为准**;design/24 §9.2/§9.13/§9.4 第 1 条(「不引解析库」)**均已滞后于 B1 实现**(PDF 文本层已用纯 Go 解析库落地;扫描件与 Office 仍诚实拒绝),本次只改 Docs、**未改 DevDocs**。另:design/24 §6.2 的 `document_unreadable`(400) **在代码里没有对应 REST 错误码**,它只以 `document-read` 的工具错误形态存在(见本节 ⑤),客户端可在 DTO 里看到该文档的 `documents[].status = "unreadable"`。 +> **契约版本 `v0.64`**(**LLM 用量落库 + 只读汇总端点 + 面板用量小卡**,依据主人 2026-10-04 夜裁决「**2.4 落**」(在「usage 落库」「只看日志」之间选前者)与 T1(commit `72b95df`,已在每次模型调用后记结构化用量日志但**只落日志**)。**一句话**:把那条日志里的**非敏感**样本落进新表 `llm_usage`(迁移 `0017`),并新增一个只读汇总端点与一张面板小卡 —— 从此看得见趋势与按用途/模型的成本,而**写入绝不阻塞模型调用**。**三档标注**:**纯增量** = 新端点 `GET /api/v1/admin/llm/usage`、新迁移 `0017_llm_usage`、新 env 键 `LLM_USAGE_RETENTION`(默认 `720h`,**≤0 = 关闭清理**)、面板 LLM 页新小卡、前端新 API `adminLlmUsage()`;**行为变更** = 无(既有端点 / 帧 / 字段形状一字未动;`model` 层新增的是**可选** sink,**不注入时行为逐字不变**——既有 model 用例零改动即全绿);**破坏性** = 无。**A. 写入(关键取舍)**:`model` 层**不直接开库**,只在既有的 `logUsage` 处多一个**可选**接口 `model.UsageSink`(`nil` = 只记日志,逐字等于引入本版之前);组合根注入 `model.UsageWriter` —— **有界队列(1024)+ 单写者 + 批量事务(128/批,2s 兜底)**,队列满**丢弃并限流 WARN(30s 一条)**,**绝不阻塞、绝不 panic、绝不无界增长**;整批落库失败只记一条带来源的 WARN。sink 挂在 `RuntimeLLM` 上(而不是每个适配器上),因此面板每次热重载 models.json 重建适配器时,落库**不会静默断一次**;**每一条链**(主/备/本地第三级/按角色跨协议)都带上它。**B. 表与索引**:`llm_usage`(`id/session_id/purpose/adapter/model/prompt_tokens/completion_tokens/cache_hit_tokens/cache_miss_tokens/reasoning_tokens/total_tokens/took_ms/created_at`)+ `idx_llm_usage_created_at`(汇总与清理的依赖)+ `idx_llm_usage_session_id`。**`session_id` 当前恒为 `NULL`**(模型层无会话概念;要填需 loop 在请求上打标,属本轮并行切片在途文件,故留列后接)。**C. 读取**:`GET /api/v1/admin/llm/usage?since=&until=&purpose=`(**owner|admin 读**,与同页 `/admin/llm` 同门;**不审计**——它是查询不是动作)→ `{data:{window:{since,until,purpose},totals:{calls,prompt,completion,cacheHit,cacheMiss,hitRate},byPurpose:[…],byModel:[…]}}`;缺省窗口 = **近 24h**,跨度上限 **90 天**(超过 → `400`,**不静默裁剪**);`since`/`until` 接受 **RFC3339 或 Unix 毫秒**;**`hitRate` = cacheHit/(cacheHit+cacheMiss),分母 0 ⇒ `0`**(口径已写进文档与前端展示规则:分母 0 显示 `—`,不显示 `0%`);分解按**调用次数降序、同次数按键升序**;**绝不含凭据与消息正文**。**D. 保留期**:`LLM_USAGE_RETENTION`(默认 `720h` = 30 天;**≤0 = 关闭清理**,日志写明)→ 启动期清一次 + 每小时清一次(复用文档 GC 的既有模式;清扫间隔不做成 env 键)。**E. 面板**:LLM 页新增「用量(近 24h)」小卡(`frontend/web/src/admin/modules/llm/LlmUsageCard.tsx`)——调用数 / 输入 token / 缓存命中率;取数与其它卡片同一条 `useAsyncData` 加载/错误态(**读不到显示错误,绝不显示 0**)。**F. 失败不伪装**:汇总出口未接线或查库失败 → `500 usage_unavailable`,**不回全 0 假汇总**。详见 §2「LLM 用量汇总契约(v0.64)」。 + > **契约版本 `v0.63`**(**客户端登记两条落地:`documents[]` 增可选 `reason` + `GET /sessions/{id}/messages` 对不存在的会话回 `404 session_not_found`**,依据 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的「登记(客户端会话,2026-10-05 凌晨 · 跟上 v0.61 的 `.pdf` 之后的两条)」与「登记(… · 一条 API 语义建议:**已删除的会话「看起来像空会话」**)」两条,以及后端「回执九」(2026-10-05 凌晨)的选择与承诺,2026-10-05 落实)。**三档标注**:**纯增量** = ① `documents[]` 元素增**可选** `reason`(`omitempty`,只在 `status:"unreadable"` 时下发;不读该键的老客户端行为**逐字不变**);**行为变更** = ② `GET /api/v1/sessions/{id}/messages` 对**不存在 / 已删除**的会话由 `200 {"data":[]}` 改为 **`404 {"error":{"code":"session_not_found"}}`**(**客户端需处理 404** —— 回执九已告知,客户端此前先用自建兜底);**破坏性** = 无(既有字段形状、既有成功响应一字未动)。详见 §1 行 6、§1.2 ④ 与 §5 变更记录。 > **契约版本 `v0.62`**(**面板「重置为 env 默认」动作落地:materialize 的反方向显式动作 + 一个新的 owner-only 管理写端点**,依据任务书 `DevDocs/_dispatch/R1-面板重置为env默认.md`、design/04 §3.1(`.env` 为默认/播种层、面板文件为一等来源)与 `DevDocs/开发时间线.md` 的欠账项(「Materialize 已导出、未接端点」),2026-10-04 落实)。**一句话**:materialize 是「面板缺该键才用 `.env` 补」,本版新增的 `POST /api/v1/admin/llm/reset-from-env` 是**反方向的显式动作**——部署者要求「把面板里 `.env` 声明过的键退回 env 值」。**A. 语义(与补齐表同源)**:只覆盖 `.env` 声明了非空值的键(`provider`/`baseUrl`/`apiKey`/`model`/`contextWindow`)、**env 未声明的键一律保留面板值**(`fallback`/`multimodal`/`maxTokens`/`reasoning`/`personaModels` 永不被改)、**组集合不增减**(不复活被删的组、不删新增的组,顺序也不动)、面板值已与 env 一致的键不算「改回」、**无键可改时不写盘**(幂等);真的改了盘就**热重载**运行中的 LLM 栈(`contextWindow` 的预算推导仍只在启动时算一次)。**B. 护栏 / 响应 / 审计**:**owner-only**(与同页两条 LLM 写端点同门;破坏性写不比同族更宽),body 必须 `{"confirm":true}`(缺失 / `false` / 非法 body ⇒ `400 bad_request` 且**一个字节都不动**)→ `200 {data:{reset:[{group,keys:[…]}], skipped:[{group,reason}], path}}`;**只回键名与中文原因,凭据值(尤其 `apiKey`)绝不回显、绝不进日志**;审计动作 `llm.resetFromEnv`(`ok`/`error`)。**C. 基准与失败**:对照基准 = 请求时刻按**既有解析入口**读到的 `.env` 默认层(新增 `config.LoadLLMDefaults`,路径经 `Deps.EnvPath` 注入,**网关绝不 fopen `.env`**);`.env` 源不可读 → `500 env_defaults_unreadable`、面板配置不存在/不可解析 → `500 models_unreadable`、写盘失败 → `500 llm_persist_failed`、已写盘但热重载失败 → `500 llm_reload_failed`、LLM 栈未注入 → `500 llm_unavailable`——**都不假装成功**。**D. 面板**:LLM 页新增「重置为 env 默认」卡片(`admin/modules/llm/LlmResetFromEnv.tsx`,行上标 `owner 专属`,**中文二次确认**后才提交)。**E. 兼容性**:**纯增量**(新端点 + 新前端卡片 + 新审计动作 + 2 个新错误码 `env_defaults_unreadable` / `models_unreadable`,另复用既有 `llm_persist_failed` / `llm_reload_failed` / `llm_unavailable`),既有端点 / WS 帧 / 字段形状**一字未动**;管理侧权限矩阵因此为 12(viewer 只读)+ 10(owner/admin 读)+ **38**(owner-only)= **60** 条路由。详见 §2「重置为 env 默认契约(v0.62)」。**上一版 `v0.61`**(**PDF 文本层抽取落地:白名单第 23 项 `.pdf` + 类型裁决由「嗅探只放行 `text/plain`」改为 `(扩展名, 嗅探)` 成对裁决**,依据 design/24 §7 的 **S6 第一段**(PDF/Office 的裁决:PDF 文本层引库,扫描件与 Office 仍诚实拒绝)与 commit `f82c76d`,2026-10-04 落实)。**三档标注**:**行为变更** = ① **`.pdf` 由「一律 `415`」变为「可上传 + 可读取 + 文本层可抽取进上下文」**(把「`.pdf` 必被拒」写死的客户端必须改);② **类型裁决收紧为成对判定**(`documents.ContentAllowed`)——**方向是收紧/细化,不是放宽**:HTML/纯文本改名成 `.pdf` ⇒ `415`(嗅探不是 PDF),**PDF 字节改名成 `.txt` ⇒ `415`**(PDF 字节也只收在 `.pdf` 名下);**纯增量** = 扩展名白名单 **22 → 23 项**(唯一新增 `.pdf`),其余 22 个文本类的判定与既有响应**一字未变**。**A. 上传(`POST /api/v1/documents`,owner|admin)**——白名单加 `.pdf`;裁决 = **字节嗅探是唯一裁决权 ∧ 扩展名白名单**,自本版起**按对判定**(纯文本类必须 `text/plain`、`.pdf` 必须 `application/pdf`;落点 `internal/runtime/documents/store.go` 的 `ExtAllowed`(23 项)/ `ContentAllowed`);`mime` 仍是**嗅探基类型**(真 PDF → `application/pdf`);`415` 文案同时点明两条口径。**B. 读取(`GET /api/v1/documents/{name}`,必带 Bearer owner|admin)**——**真 PDF 如实下发 `Content-Type: application/pdf`**(读时按同一对裁决重嗅探,判不定者仍退 `application/octet-stream`),`Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` **一字未变** ⇒ **PDF 永不被内联渲染**(与图片读侧刻意相反)。**C. 抽取语义(DTO `status`)**——**文本层 PDF 抽正文**(`Tj`/`TJ`/`'`/`"` 文本算子,页间以换行相接)⇒ `status:"extracted"`,与纯文本类走同一条 sidecar/注入路径;**扫描件/图片型 PDF ⇒ 诚实拒绝 `status:"unreadable"`**,原因含「这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」,**绝不假装成功、也不返回空正文**;**加密**与**损坏/截断**是**可区分**的两类失败;安全阀 = 单份最多 5000 页 + sidecar 正文 ≤8 MiB。**D. 客户端可见的一次性重算**——抽取器版本内部升到 **`documents/v3`** ⇒ **旧 sidecar 一次性失效重抽**:同一份旧附件的 `chars`/`truncated` 结论会在**某次回合之后**重新计算(旧 PDF 若是文本层 PDF,将从 `unreadable` 变为 `extracted`)。**E. 未做(诚实边界)**——**不做 OCR / 扫描件栅格化**(纯 Go 无可靠实现,会撞「单二进制 + 交叉编译」「零跨进程 IPC」两条红线)、**不做 PDF 写/生成**(只读)、**不做 `.docx`/`.xlsx` 等 Office**(仍诚实拒绝);**无新端点、无新 WS 帧、无鉴权变更、无 `.env` 新增键**。**实现落点**:`internal/runtime/documents/{extract_pdf.go,extract.go,store.go,sidecar.go}` + 依赖 `github.com/ledongthuc/pdf`(纯 Go、无 cgo)。**上一版 `v0.60`**(**管理侧只读「存储状态」端点 + 面板存储卡接真数据**,依据主人 2026-10-04 实测反馈(面板首页「存储」卡一直是占位「SQLite」)与 design/10 §3 模块 14(系统设置)/ design/03 §3(pgvector 可选增强),2026-10-04 落实):**A. 新增 `GET /api/v1/admin/system/storage`**(**owner-only**、只读、**无审计动作**)→ `200 {data:{main,documents,vector}}`——① `main` = `{kind:"sqlite",path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes}`(`path` 与备份/恢复端点同源;`walBytes`/`shmBytes` 边车不存在时如实为 `0`;`freeBytes = pageSize × freelistPages`);② `documents` = `{count,bytes}`(读 `documents` 表,**不 walk 目录**;空表 `bytes` 为 `0` 而非 `null`);③ `vector` = `{kind:"pgvector"|"none",enabled,ready}`——`kind` 说「配没配 `DB_PG_URL`」,`ready` 说「现在真的在跑什么」(配了但连接/schema 准备失败 ⇒ `enabled:true` + `ready:false`,客户端显示「已配置但未就绪(检索已降级)」,**别把 `enabled` 当生效**)。**B. 两条纪律**:① **不含凭据**——DSN / 用户名 / 密码 / `postgres://` 全文**一个字都不回**(`vector` 段只有三个键;有用例断言响应体里搜不到这些串);② **只读**——只有 `SELECT`/`PRAGMA` + `os.Stat`,复用组合根注入的**活连接**(不新开连接池、不写、不迁移、不备份,调用前后 `documents` 行数与数据目录文件集合均不变)。**C. `vector` 的真源**:组合根 `cmd/cyrene/wire_frontend.go` 的 `app.vectorState()` —— `Configured` 取 `DB_PG_URL` 非空、`Live` 取活索引的具体类型(`*vector.PGVectorIndex`);网关**只读这份注入值**,不读环境变量、不新开 PG 连接、不自行探测(避免面板与运行时给出两个答案)。**D. 失败不伪装**:未接活连接 / 路径未配置 / 读不到页级统计或 `documents` 表(例如库没跑迁移)⇒ `500 {error:{code:"storage_unavailable",message}}`,**绝不回一个全 0 的假快照**。**E. 面板**:`frontend/web` 的存储卡改为真数据(`value` = `SQLite 12.3 MiB`;`hint` = WAL / 页数 / 空闲页;详情两行 = `documents N 件 · …` 与 `PG 增强 …`),取数走与其它已接线卡片**同一条**加载/错误态(数据没到就显示「端点报错:…」,不回退成 `0`)。**F. 兼容性**:**纯新增端点 + 纯新增前端行为**,既有端点 / WS 帧 / 字段形状**一字未动**(前端 `MiniMetricSpec` 新增的 `extra?: string[]` 为可选补充详情行,不传时渲染与旧版逐字等价)。**上一版 `v0.59`**:(**文档进对话(`F1/F2`)+ 上下文预算按窗口推导(`T1`)+ 语音回合进编排(`G5/C1/C3`)+ `thinking` 可达性口径**,依据 design/24 §3/§4/§6(切片 S1–S5)、design/04 §1.3 + 附录 A.3(主人裁决 2026-10-04)与 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 G5/C1/C3,commit `a8851dc` / `a4789ec` / `20c1edb` / `5b2b385` / `72b95df` / `3a01b4e`,2026-10-04 落实):**A. 文档存储与摄取(两个新端点 + 一个可选请求字段 = 纯增量;DTO 形状变更是破坏性,但单数形状从未发布,故无兼容期)**——① **新增 `POST /api/v1/documents`**(owner|admin,`multipart/form-data`,字段 `file`)→ 首次 `201 {data:{name,url,mime,size,sha256,deduped:false}}`;**同字节同扩展名重复上传 → `200` + 同一 `name` + `deduped:true`**(客户端可当成功处理);单文件上限 **`CHAT_DOCUMENT_MAX_BYTES`(默认 20 MiB = 20971520)**,`≤0` 视为用默认;它与图片上限 `CHAT_IMAGE_MAX_BYTES` **刻意解耦**(改文档上限不会静默改掉图片行为)。② **新增 `GET /api/v1/documents/{name}`**:**必须带 Bearer(owner|admin)**——与图片读侧(免鉴权的能力 URL)**刻意相反**(文档摄取在服务端完成,不需要被 ``/provider 直接消费),响应带 `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`(浏览器永不内联渲染)。③ **类型裁决(重要)**:**字节嗅探是唯一裁决者**(`http.DetectContentType`;**v0.59 时**放行的嗅探基类型只有 `text/plain`,**v0.61 起改为 `(扩展名, 嗅探)` 成对裁决**、`.pdf` 走 `application/pdf`),**客户端声明的 `Content-Type` 与原始文件名只作参考、可被嗅探否决**;**扩展名白名单 deny-by-default,实际 **22** 个(⚠️ 历史值:**v0.61 起为 23 项**,纳入 `.pdf`)**:`.txt .md .markdown .csv .tsv .json .jsonl .yaml .yml .log .ini .cfg .toml .go .ts .tsx .java .c .h .cpp .rs .sql`(⚠️ design/24 §9.2 写「23 项」但**当时**所列清单与代码均为 **22** 项,**以代码 22 为准**、并已在 v0.61 变为 23 项,见 §1.2「发现的不一致」);**明令拒绝** `text/html`、SVG/XML、zip 及其封装(docx/xlsx)、可执行/脚本(exe/dll/bat/cmd/ps1/js/mjs/sh/py)——即便 `.js`/`.py` 嗅探成 `text/plain` 也拒(判定 = 扩展名 ∧ 嗅探的**合取**)。④ **边界(勿与图片混用)**:`url` 是**相对路径**(`/api/v1/documents/<32hex>.`;图片侧是绝对 URL);`mime` 是**嗅探所得基类型**(故 `data.json` 返回 `text/plain`,不是 `application/json`);落盘名 = `sha256(bytes)[:32] + ext`(**内容寻址**,原始文件名永不参与路径 ⇒ 同字节同扩展名天然同名去重)。⑤ **4 个错误码**:`unsupported_media_type`(415) / `document_too_large`(413) / `document_not_found`(404,含名字形态非法与路径穿越) / `too_many_documents`(400)。⑥ **`chat` 请求新增可选 `"documents":[{"name":"<32hex>."}]`**(REST 同步 / REST `stream:true` / WS `chat` **三处同形**):**≤5 条**(超出 `too_many_documents` 400,且**先于任何文件系统访问**);**只收服务端存储名、绝不收 URL**(不重开 F3 那种「服务端代取 URL」的 SSRF 面);客户端多带的 `mime/url/size` **一律被服务端覆盖**(MIME/大小取自落盘字节);名非法/穿越/不存在 → `document_not_found`(404);**任一条不合规都让整个请求被拒**,绝不「丢掉这条附件但照样起回合」(WS 侧经 `error` 帧带 `data.code` 下发并中止本回合,REST 侧回对应状态码)。⑦ **响应 / 历史 DTO 由 `document`(单对象)改为 `documents`(数组,≤5)**——元素形状 `{"name","mime","size","chars","truncated","status"}`,`status ∈ extracted|listed|unreadable`;**0 个附件时该键省略**;同一 DTO 供 `GET /sessions/{id}/messages`、`message_persisted`、`message_edited`、WS 握手回放 **四处同时生效**(同一个 `historyMessage` 结构)。⚠️ **原单数形状从未发布过**(不在任何已发布版本号里)⇒ **无兼容期**,直接换数组(design/24 §6.1 那句 `"document":{…}` 已随之作废)。⚠️ **`chars`/`truncated` 改为「本回合注入侧」语义**:`chars` = **本回合注入**模型上下文的字符数、`truncated` = **注入**是否被截断(**不是**「原文件是否被截断」);**全文长度**只有 `document-read` 的 `totalChars` 能看到。⑧ **内置工具 `document-read`**(模型侧工具,客户端一般只需知道它存在):`{name, offset?, limit?}` → `{name,status,offset,limit,totalChars,truncated,nextOffset,text,extractTruncated?}`;`limit` 默认 **8000** 字符、上限 **64K**;`offset` 单位是**字符(rune)**、0 基、起点 = sidecar 正文(含 CSV/TSV 摘要行)第 0 个字符;`offset<0` 或 `>totalChars` → 错误(`==totalChars` 合法,读到末尾空片段);`extractTruncated=true` **专指**「撞上 8 MiB sidecar 安全上限」(≠ 本回合注入被截断);策略默认 **owner-only**(群聊/陌生人天然读不到主人的文件)。**B. 配置面(纯增量 + 一处语义变更)**——① **`models.json` 组级新增 `contextWindow`**(正整数 = 该组模型的上下文窗口;`0`/省略 = 未声明 ⇒ 后端按 **131072** 保守估算并记**恰好一条** WARN;**负数 = 组校验失败**,写端点回 `400`)。 ② **`.env` 新增/变更 4 键**:`LLM_CONTEXT_WINDOW`(**per-group 播种**,等价于 `models.json` 的 `contextWindow`)、`CONTEXT_BUDGET_RATIO`(默认 **2/3**,接受 `2/3` 或 `0.6667`,非法值记 WARN 后回落)、`DOCUMENT_MAX_INJECT_CHARS`(单份附件注入上下文的字符上限;未设 = 按推导出的预算自动算)、**`CONTEXT_MAX_TOKENS` 语义变更(关键)**:**未设置 ⇒ 按窗口推导**(`budget = min(窗口 × 比例, 窗口 − 输出保留)`,输出保留 = `max(32768, 该组 maxTokens)` 且**封顶 `窗口/2`**;**1M 窗口 ⇒ 699050 tokens**);`>0` = 显式覆盖(原样使用);`0`/负 = **关闭压缩**。⇒ **`.env` 里若仍写着 `CONTEXT_MAX_TOKENS=32768`,它就是显式覆盖、推导不生效**(这是设计语义,**不是 bug**;本次已把 `.env.example` 里该行改成注释形式)。③ **`.env` → 面板配置的 materialize 语义(欠账,本次补登记)**:进程**每次启动**跑一次;**面板配置非空 ⇒ 组集合完全由面板决定**(env 不新增、不复活被面板删掉的组);**键级只在面板缺该键时用 env 补**,面板已有的键(含**显式空值 / 显式 `false` / 显式 `null`**)**绝不覆盖**;面板配置缺失/为空时才由 env 播种出合成组;**「面板改一次、重启被打回」是硬约束**(有专门用例钉住);每次启动记「补了哪些 / 跳了哪些」,**凭据绝不进日志**;范围目前**仅 `models.json` 一族**(`channels.json` / `plugins.json` 未纳入)。④ **面板读写面**:`GET /api/v1/admin/llm` 的组 DTO **含 `contextWindow`**(`omitempty`,与 `maxTokens` 同形:未声明时该键省略),`POST /api/v1/admin/llm` **接受可选 `contextWindow`**——**缺失 = 保留已存窗口**、**显式 `0` 与缺失同义**(都 = 未声明)、**负值 `400 bad_request` 且不落盘**;面板 LLM 页据此显示/编辑该键。**C. 语音(有行为变更,见 §3.15)**——语音轮次**现在走与文本同一条路径**:会**落库**(用户消息 = 转写文本,助手消息 = 回复)、会发 `message_persisted` 与流式帧、**跨端同步**、忙时按编排器既有规则**排队**(`pending_ack`/`pending_drain`/`session_busy` 语义零变化,只是多了一个消费者);`deviceId` 照旧落库、`platform` 走存储默认 `web`;`voice_input` 上行增可选 `clientMsgId`(与 `chat` 帧同形 ⇒ 语音也有幂等;重复到达回既有 `chat_duplicate`,**不产生新回合、不发音频**);`voice_output`(定向)载荷增 `sessionId`(恒有)与 `deviceId`(非空才有);**空正文**与 **TTS 失败/降级**都走既有 `provider` 降级信号(客户端据此提示「没生成声音」;**不再**把 `stopReason` 当语音内容);**时点**:`voice_output` 在**该回合结算时**才到(可能晚于若干状态帧,甚至由续跑回合或被 steer 注入的那一回合产生);定向目标是**发起 `voice_input` 的那条连接本身**(服务端**不按 deviceId 路由**);连接断开后**不补投**,客户端重连后带上同一 `clientMsgId` 可安全重试。**D. `G5`(纯增量:广播帧 +2 个可选键;新增一条定向错误帧,见 §3.2)**——① **`pending_full` 广播载荷**增 `clientMsgId?` / `messageId?`:前者 = **被拒那条消息**的幂等键(未携带则**整个键缺席**);后者**仅当该消息此前已落库/已认领时**才回填——**服务端绝不在此处新铸 id**(队列满的拒绝发生在幂等键认领之前,代铸会让客户端重试被判成「重复到达」而永远进不来)。② **新增定向帧(此前无文档登记)**:`{type:"error", data:{code:"chat_busy", message, sessionId, clientMsgId?, messageId?}}`,**只发发送者那条连接**、不广播;`code` 与 REST `409 chat_busy` **同码**(客户端一个 code 覆盖两条入口)。**E. `E3`:`thinking` 可达性口径(不改行为,只是把话说清)**——消息体的 **`thinking` 字段**在**实时帧**(`message_persisted` / `channel_persisted` / `message_edited`)与**握手回放**中**一律为空**(隐私与带宽的**有意**默认;实时思维链仍由 §3.1 的 `thinking.*` 流帧按 owner/admin 档下发);**历史消息的思维链**只有 `GET /sessions/{id}/messages`(owner|admin)返回。**上一版 `v0.58`**:(**新增 `.env` 键 `LLM_MULTIMODAL`(`bool`,默认 `false`):让 env-only 部署能声明「主模型自身能否收图」**,依据裁决 14.4.1 / design/06 §0(commit `4adfc9a`),2026-10-04 落实):**A. 新键语义(见 §2「模型配置契约」表)**——**`LLM_MULTIMODAL` 是 `models.json` 组 `multimodal` 的 env 等价物**,**只作用于 `.env` 合成组**(**无有效 `models.json` 时才生效**;有组文件时以该组的 `multimodal` 为准,与 `LLM_REASONING_KIND` 同口径):`true` = 图片**直进主模型上下文**(省掉工具那一轮模型调用、也不做有损文本摘要),**留空 / `false`(默认)= 把图交给 vision/OCR 工具换成文本描述**;**绝不凭 base URL / 模型名猜测**端点是否收图(声称端点不具备的能力,比把图交给工具更糟——工具至少回一段文本,design/06 §0 首条)。**B. 与 `LLM_VISION_MODEL` / `LLM_OCR_MODEL` 的关系**——后两者配的是**视觉/OCR 工具调用的模型**,**不代表主模型能收图**,与本键互不代表(见 §1 行 3)。**C. 兼容性(默认值即旧行为)**——默认 `false` ⇒ **老部署行为逐字不变**(引入本键之前,env-only 部署没有任何键能声明该能力,图片恒走 vision 工具);**非 bool 值(如 `maybe`)在配置解码阶段直接报错、启动失败**(fail-fast,**不静默回退成 `false`**——静默的「配了当没配」正是本键要消灭的)。**`LLM_MULTIMODAL=true` 后图片走主模型直收,属部署者显式选择**(形状由部署者声明,后端不替它猜)。**D. 实现落点**——`internal/config/config.go`(`LLMConfig.Multimodal`,默认 `false`)+ `internal/config/runtimeconfig/models.go`(`GroupFromEnv` 把两 provider 分叉都透传进合成组)+ `internal/config/runtimeconfig/multimodal_env_test.go`(默认 false / 显式 true 抵达合成组 / 进程 env 端到端 / 组文件优先 四组测试)。**无端点、无帧、无字段形状变更**(纯新增 env 键)。**上一版 `v0.57`**:(**13.4.5 目标态 steer 落地登记:新下行帧 `steer_ack`**,依据 design/15 §2.0/§2.1/§2.4/§3.x/§5(裁决 13.4.5 目标态,commit `3d8822c`),2026-10-04 落实:**A. 新下行帧 `steer_ack`(见 §3.14)**——`{type:"steer_ack", sessionId, data:{messageId, atStep}}`:忙时会话里新到的用户消息**被在飞回合吃进同一回合**时下发(`messageId` = 那条用户消息的**权威 id**,`atStep` = 吃进它的**模型步序号,1 基**);语义=「**该消息已被本回合吃进(已从 pending 移除)、尚未被正面回答**」⇒ 客户端应把对应气泡渲染为「已读、未答」的**灰态**,直到该回合的 `message.*` / `turn.end` 到达(**不需要第二个确认帧**)。**角色门**:它**携带具体消息身份**(客户端能定位到某一条气泡,而不只是会话忙闲),因此**不登记**进 `frameReachesAll` 的 7 个状态机信号白名单,落 fail-closed 默认档——**只发 `owner` + `admin`**(`viewer` 收不到);经编排器状态通道**由 hub 直接广播、不经 bus**,帧壳带 `sessionId`、**不带** `turnId`/`seq`。**触发时机**:只在**回合内边界**(loop 每一步开头:上一次模型步与它那一批工具调用**都已结束**、下一次模型调用**尚未开始**,此刻没有工具在飞)产生,且**只有该边界真的匹配到 pending 里、且已落库的消息时才发**——**不是每个 step 都发**(没可注入消息的边界一帧都不发,这是常态)。**B. 转向的三条不变量(design/15 §2.0)**——**I1(同会话在途回合数恒为 1)**:转向只注入、不取消,**绝不新开第二个回合**(loop 侧结构性地没有任何「起新回合」的分支,注入只是把消息并进**当前回合**下一次模型调用的视图);**I2(消息恰好被处理一次)**:注入=已消费——被注入的消息立刻从该会话 pending **移除**(按 pending 序号认领),**绝不被第二个回合再答一遍**,去重键=后端 `message.id`,且候选必须**同时**满足「在该会话转录里(最近 20 条窗口内)」与「在本编排器的 pending 里」;**I3(转向不取消已发起的工具)**:注入点只在回合内边界,此刻没有任何工具在飞,正在执行的工具**永远跑完**。**C. 注入的范围(口径)**:注入**只影响本轮视图**(每一步边界重新并入),**不写回运行窗口** ⇒ 既不进该回合的 **checkpoint 快照**、也不进**后台记忆提取的输入面**;被注入的消息**本身早在到达时已落库**(pending 路径即时持久化),注入**不新增、也不改写任何行**。**D. 失败一律让路**:未接转录存储 / 读转录失败 / 该消息尚未落库 / 本回合世代已被打断或被更新的回合取代(认领 `pending` 失败)时,**不发 `steer_ack` 也不注入**——该消息**留在 pending**,照旧作为自己的回合被回答,**绝不静默消失**。**E.** 另含一处**链路图纯增量键**(注入时该轮图的 input 节点带 `props.steered:true`,图仍只有一条主干——不是被画成第二轮),以及**纯新增帧**的兼容性口径(老客户端按未知帧忽略即可)。**上一版 `v0.56`**:(**协同项 A2 / G1 / G2 / G3 / G4 / F3 落地登记:`interrupt` 支持 `scope` + REST `stream:true` 不再静默失败 + `chat.image` 上限 + `clientMsgId` 幂等 + 视觉链路 URL 取回与类型裁决 + 既有错误陈述更正**,依据 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 A2 / G1 / G2 / G3 / G4 / F3(commit `7512d59` 等,2026-10-04 落实):**A. 上行 `interrupt` 新增可选 `scope`(A2)**——`{sessionId}` → `{sessionId, scope?}`,取值域 `{"", "all", "current"}`(**省略 / 空串 / `"all"` 三者同义**):`all` = 取消当前任务 + 清空待处理队列(**逐字不变的现状语义**);**`"current"` = 只取消当前任务、保留待处理队列**(队列消息按编排器正常续跑规则继续执行、带上会话尾部上下文);**其余取值 → 只回发送者** `{type:"error",data:{code:"bad_request"}}`(**定向**:不广播、不打断任何会话,绝不静默按默认执行);命中空闲会话为 no-op。下行 `session_interrupted` 帧形状**零变化**(`{sessionId, mode, dropped}`),但 `dropped` 恒等于**真的被丢弃**的待处理条数——`scope:"current"` 时为 `0`,且 `mode` 是该会话当时的**忙碌通道**(`thinking|executing|subagent`,**不是 `idle`**);`scope:"all"` 时 `mode` 为 `idle`、`dropped` 为清空的 pending 条数。**B. REST `stream:true` 不再静默失败(G2,行为变更)**——该分支改为与 WS `chat` 帧**同一条直达编排器的路径**(design/15 §2.1「会话消息不得被 drop」),**响应面**:受理成功后才回 `200 {data:{stream:true}}` / 正忙已续接排队 `202 {data:{stream:true,queued:true,queueDepth:n}}` / 队列满 `409 {error:{code:"chat_busy"}}` / 幂等键重复 `200 {data:{duplicate:true,messageId,sessionId}}`;**未接 loop → `500 {error:{code:"chat_unavailable"}}`**(旧文写 `200 {stream:true}`,现与同步路径同口径);仅**未接编排器**才回退总线派发,其失败回 `503 {error:{code:"chat_dispatch_failed"}}`(此前 `Publish` 的错误被吞掉,且传的是**请求作用域** ctx)。**`202`/`409` 从此真的会出现**(修复前因提前 `return 200` 永不出现)。派发用的回合上下文与请求作用域脱钩(`context.WithoutCancel` + 30 分钟上限),回合不会在客户端刚收到 200 时被连带取消。**向后兼容**:`stream:false` 与既有同步响应形状一字未动;依赖「任何情况都拿到 200」的客户端必须按上表分支,别再假设 `stream:true` 恒 `200`。**C. `chat.image` 上限(G3)**——`POST /api/v1/chat` 的 `image` 新增硬上限:**解码后 5 MiB**(与 `POST /api/v1/upload` 同口径;base64 字符串上限 = `ceil(n/3)*4 + 64` 字符,覆盖 data: URI 前缀),超限 → `400 {error:{code:"image_too_large", message}}`(文案指路 `POST /api/v1/upload`),在**任何解码 / 落库 / 入模型之前**拦下(超大 base64 因此不进历史、也不会被重连回放重传)。**事实(勿误读为「WS 也有这条 400」)**:WS `chat` 帧**今天已被传输层关死**——WS 读上限 `wsReadLimit = 4096` 字节/帧,超限直接断连,**到不了**这条判定 ⇒ **WS 上发真实图片本就必须「上传 + URL」**。**D. `clientMsgId` 幂等(G4)**——上行**新增可选** `clientMsgId`(REST `chat` 体与 WS `chat` 帧都支持;**省略 = 老行为逐字不变,老客户端零影响**):同一会话内同一个非空值只承认第一条。**重复到达**:REST → `200 {data:{duplicate:true,messageId,sessionId}}`;WS → 新下行帧 **`chat_duplicate`**(`{type:"chat_duplicate", sessionId, data:{sessionId, clientMsgId, messageId}}`,见 §3.13);重复**不落库、不起回合、不发任何内容 / 流式新帧**。**判重由 SQLite 部分唯一索引原子裁决**(`(session_id, client_msg_id) WHERE client_msg_id != ''`),并发的两次重发只有一个赢家,不存在「先查再插」的竞态窗口。`clientMsgId` 另**回填进消息 DTO** ⇒ `message_persisted` / `channel_persisted` / 握手 `history` 回放与 `GET /sessions/{id}/messages` 等读接口可见(空则省略),客户端据此把本地乐观气泡对齐到服务端真相(替代「靠文本相等」的脆弱匹配)。**迁移**:新增 `0013_chat_client_msg_id`(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:先删索引再删列;全新库由 `history.NewStore` 幂等前滚)。**E. 视觉链路事实修正(F3)**——`chat.image` 的 http(s) URL(含 `/api/v1/files/`)现在由**服务端自己取回并内联为 `data:` URI** 再交 provider,**不再原样转给上游**;同源 upload 产物优先走**本地文件读取**。**类型裁决**:**字节嗅探是唯一裁决权**(声明的 Content-Type / 扩展名只作参考且可被否决),嗅探为文本(HTML / PROXY 错误页 / SVG)或判不定且来源未声明 `image/*` → **明确报错**(`vision.ErrNotImage`),**绝不静默当 PNG**。**对客户端无端点 / 帧 / 字段变更**。**F. 修正既有错误陈述(G1)**——`Docs/api-reference.md` §6.2 帧壳表与 §6.6 曾声称结构化流帧只带 `seq`、没有轮次标识,**是错的**:**v0.36 起**结构化流帧(`message.*` / `thinking.*` / `tool_call.*`)就带 `sessionId` + `turnId`(`broadcastStream` 从 `model.StreamEvent` 取,loop 在回合边界注入),v0.50 起另带 `streamId`;已按实现更正(与本文档 v0.36 条目一致)。**G. 环境变量登记**——`.env.example` 新增 **`OWNER_IDENTITY_INJECTION`**(`owner_only` 默认 / `always` / `off`;**非法值回退 `owner_only` 并记 WARN**),并登记 **`CONTEXT_MAX_TOKENS`**(默认 `32768`;`0` / 负 = 关闭压缩)与 **`GUARDRAIL_KEYWORDS`**(逗号分隔;留空 = 不挂关键词护栏),三键见 §2「安全 / 上下文环境变量(v0.56)」。**无新端点、无帧形状破坏**(唯一新增帧 `chat_duplicate` 为**纯新增**;REST `stream:true` 的响应面属**行为变更**,兼容点已在 B 写明)。**上一版 `v0.55`**:(**阶段 7 落地项登记:路径式安全入口 + 数据目录 `platform` + OneBot v11 四端 + 可观测实时推送**,依据裁决 1.6 / 16.4.7 / 12.4.2(commit `a398dc9`)、1.4 / 12.4.1(commit `9253fef`)、05.4.3 + 16.4.3-WS(commit `0e1f163`),以及 13.4.5 面内三条(commit `74b12d8`)、MCP 官方 SDK(commit `a1083a3`),2026-10-04 落实):**A. 安全入口 `ADMIN_PATH`(裁决 1.6 / 16.4.7,前端路由面)**——① env 新增 **`ADMIN_PATH`**(默认 `admin`):面板**前端**路由前缀 = `/` + 值(`admin` → `/admin`,`xxx/admin` → `/xxx/admin`;**非法值回退 `/admin`**——容忍首尾斜杠与空白,拒绝 `.`/`..` 段与 `?`/`#`)。② HTTP 行为:`/` 与其深链返回**注入了 `` 与 `window.__CYRENE_ADMIN_ENTRY__={"prefix":"..."}` 的 index.html**(`Cache-Control: no-store`);**形如面板入口(路径里出现 `admin` 段)但前缀不符的路径 → `404`**(不回落用户端聊天壳、不泄露正确前缀);`/` 若恰好命中 `dist` 真实文件则仍走文件服务器。③ **API 不藏**:`/api/v1/*`、`/api/v1/admin/*`、`/ws/chat`、webhook 一律原样,**无新端点、无帧变更**。④ **口径**:用户端 HTML **永不携带该前缀串**(后端只在命中前缀时注入)⇒ 用户端既无管理入口链接、也解析不出面板路由(design/07 §0.2;前端路由改**路径式**:`frontend/web/src/admin/router.ts` 只读注入、无硬编兜底、移除 hash 入口)。**B. 数据目录(同 commit,裁决 12.4.2)**——`DATA_DIR` 新增可接受值 **`platform`**(大小写不敏感)→ 平台标准目录;默认 `./data` 与显式路径行为不变;**显式空值行为不变**(仍走原兜底)。**C. OneBot v11 四端(`9253fef`,裁决 1.4 / 12.4.1)**——① 配置面(channels.json 账号 `config` + 适配器 `Config()` 快照)新增 `transport`(`ws`|`http`,默认 `ws`,未知值回落 `ws`)/ `api_base_url` / `report_path`(默认 `/`);快照 camelCase 为 `transport`/`apiBaseURL`/`reportPath`;`remoteURL` 快照值 = `remote_url`、为空时回落 `api_base_url`(语义即「出站远端地址」),**既有键与值不变**。② env 兜底 `CHANNEL_QQ_OBV11_TRANSPORT`(默认 `ws`)/ `CHANNEL_QQ_OBV11_API_BASE_URL` / `CHANNEL_QQ_OBV11_REPORT_PATH`。③ 四端语义:`ws+client` Cyrene 拨号、收发同一 WS;`ws+server` OneBot 反连、收发同一 WS;`http+client` Cyrene→OneBot(`POST {api_base_url}/{action}`,纯出站);`http+server` OneBot→Cyrene(POST 到 `listen_addr`+`report_path`,纯入站,fail-closed 鉴权、10MiB 体上限)。④ 取舍:`mode` 决定**哪一半必需**(server 必需 `listen_addr`、client 必需 `api_base_url`),另一半给齐字段就一并启用 ⇒ 单个适配器即可「收上报 + 回消息」。⑤ 端口例外:`http+server` 用 `listen_addr`,属 `AGENTS.md` §9 已登记的**同一豁免**(design/13 §0 原文即写「WS server / HTTP server」);`listen_addr` 为空则报错、**绝不开端口**。**D. 可观测实时推送(`0e1f163`,裁决 05.4.3 + 16.4.3-WS)——新增一节 §3.12**:三个**下行新帧** `observability_log`(`{entries:[{time,level,source,msg}…]}`,最新在前,上限 200 条)/ `observability_trace`(`{spans:[…]}`,上限 100 条)/ `observability_metrics`(`AdminMetrics` 本体、扁平),均**纯新增**(既有帧名/形状零改动)、帧壳**不带** `sessionId`/`turnId`/`seq`;三者**不在** `frameReachesAll` 白名单 ⇒ fail-closed **只发 owner+admin**(viewer 继续走 REST 只读档);订阅 = owner/admin 凭据**读取** `GET /admin/observability/{logs,metrics,trace}` 任一个(该次读取照常返回完整引导数据),该凭据 WS 连接全断即退订并停循环(**不是轮询、无常驻 goroutine**);**REST 端点完全不变**,`?level=` 分级过滤改由**客户端本地**做(帧广播给所有 `owner`/`admin` 连接,订阅只决定循环跑不跑)。**E. 其余(各一句)**:① **13.4.5 面内三条**(`74b12d8`)——续接窗口 `Recent` 在同毫秒并列时改为取**最新** n 条且按插入序(**无 API 变化**,但客户端可观察到的「握手回放 / 续接上下文」顺序更正确);上行 `chat` 不再经**可丢弃**的 bus(**帧序列不变**);被**取消**回合的 checkpoint 不再顶掉下一条消息,新增内部常量 `loop.StopReasonCanceled`(`checkpoints.state.stop_reason` 可能出现该值,**不经 REST/WS 暴露**)。② **MCP**(`a1083a3`)——协议层换成**官方 Model Context Protocol Go SDK v1.8.0**,**REST/WS/env/数据目录零变更**(`MCP_SERVERS_JSON`、`MCP_*`、`transport` 枚举 `stdio|http|streamable|sse` 全不变)。**未做(诚实边界)**:DashScope 形状的 `auto` 推断、Qwen 的 `/think` 前导消息软开关、`get_group_list` 枚举、上游显式退订帧(当前退订只发生在订阅凭据的连接全断时)、审计 tab 仍 5s REST 轮询(未做 `observability_audit` 帧)。**上一版 `v0.54`**:(**渠道配置写路径改 patch 语义(修 P0 级凭据静默丢失) + 阶段 6 前端批的纯前端行为登记**,依据 design/04 §3.1、design/22 §2 方案 b(与 `personaId` 同构的裁决延续,commit `4ae7148`)与裁决 16.4.1 / 16.4.2 / 16.4.3 / 16.4.4 / 16.4.5 / 16.4.6(commit `f454273` / `b480b04` / `c1a98d7` / `8d70f6d`)):① **`PUT /api/v1/admin/channels/{platform}/config` 的 `config` 由「整条替换」改为「按 id 打补丁」**(`runtimeconfig.PatchChannel`,**与 `personaId` 同构的三态**)——**键省略(该键不在 `config` 里,或 `config` 整体缺席 / 为 `null`)= 保留该账号已存值**、**给出非空值 = 覆盖该键**、**显式 `null` 或 `""` = 清除该键**(`0` / `false` 是真实取值,**不算空**);顶层便捷字段(`token` / `baseURL` / …)是普通 `string`,**只在非空时**并入补丁 ⇒ **要清除 `bot_token` 必须走 `config` 的显式 `null` / `""`**;`allowGroups` / `denyGroups` / `allowUsers` / `denyUsers` 与三个限流键**仍是整值替换**(省略 = 清空)。② **破坏性提示**:按旧文实现、依赖「省略某 `config` 键 = 删掉它」的客户端,必须改为**显式传 `null` / `""`** 才能清除该键。③ **修掉的数据安全问题**:面板只发送用户本次碰过的平台字段,后端却整条替换 ⇒ 保存**微信(iLink)**账号卡会抹掉 `bot_token` / `bot_id` / `owner_id` / `get_updates_buf`(**iLink token 是长期凭据**)、**Telegram** 只填 `webhook_url` 而不重填 token 会让 `cleanAccounts` **丢掉整个账号**。④ **未改**:token 要求型平台**显式**清 token 仍按 `cleanAccounts` 丢账号;「本次一个 `config` 键都没给出」在**非 `weixin-ilink`** 平台仍回 `400`。⑤ **无新端点 / 新字段 / 响应形状变更**。**同版另含阶段 6 前端批(纯前端行为,无 API 变化)**:① **16.4.1 静默/活动态与预约切换**——唯一入口 `frontend/web/src/components/modeSwitch.ts`:她活动时用户切换只登记「已预约」、静默时落地;② **16.4.5 会话级消息缓存**——帧按自己的 `sessionId` 落进该会话的消息桶(切会话不清空),**只有单槽忙态**仍按激活会话判定;`graph_update` 按 `data.sessionId` 入**图桶**,管理端 Flow 页取激活会话、Backthink 页取 `sess-backthink`(裁决 16.4.2);③ **16.4.3 / 16.4.6 虚拟滚动与专属页只读卡**——长列表超阈值窗口化、专属页只读展示该平台完整字段;④ **16.4.4 双档 UI 模式**——性能档 = 现状(默认)、华丽档落 `:root.ui-mode-fancy`,持久化键 `cyrene.ui-mode`(纯前端,对应 `design/07 §0.1`,不改后端契约)。**上一版 `v0.53`**:(**阶段 5 落地:模型目录端点 + per-persona 路由 / 本地三级 / capacity L3 / 视觉·OCR 独立模型 / `image-crop` 工具 / 按 provider 形状下发推理开关**,依据 design/04 §3/§3.1/§3.2(裁决 03.4.1 / 03.4.2 / 03.4.3 / 08.4.1)、design/06 §0/§3(裁决 14.4.1 / 14.4.5)、design/14 §2.2/§3/§4(裁决 14.4.2 / 14.4.3 / 14.4.4),2026-10-04 落实):① **新增管理只读端点 `GET /api/v1/admin/llm/catalog`**(**owner|admin** 读,`?refresh=1` 强制在线刷新)→ `200 {data:{source:"cache"|"models.dev", fetchedAt?, stale?, warning?, providers:[{id,name,api?,doc?,env?,npm?,models:[{id,name,attachment,reasoning,toolCall,contextLimit?,outputLimit?}]}]}}`;**既无本地快照又抓不到** → `503 {error:{code:"catalog_unavailable", message}}`;客户端带 `Accept-Encoding: gzip` 时响应带 `Content-Encoding: gzip`(**信封形状不变**)。**副作用只有一处**:落盘快照 `/llm_catalog.json`(**不含任何凭据**、`data/` 已 gitignore、TTL 24h),**不改 `models.json`**;**抓取只发生在本端点内,不进运行时热路径**(模型请求链路从不读外网目录)。详见 §2「模型配置契约(v0.53)」。② **`models.json` 组级新增键**(载体是组,**可热更新**,未声明时该键不出现):`reasoning: {kind: "auto"|"deepseek"|"openai"|"vllm"|"dashscope"}` 决定该组用哪种**提供商开关形状**下发推理指令(**非法值被 `Group.Validate` 直接拒绝**;anthropic 组上声明 openai 系形状同样拒绝)、`personaModels: {personaId: modelId}` 与 `personaProviders: {personaId: "openai"|"anthropic"}` 为**角色级模型/提供商路由**(**非法 provider 值同样拒绝**;未绑定/未知角色回落该组默认)。③ **语义修正(重要)**:**`multimodal` = 主模型自身能否收图**;`LLM_VISION_MODEL` / `LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**——两者互不代表(见 §1 行 3)。④ **`.env` 新增 9 键**(见 §2「模型配置契约(v0.53)」表与 `.env.example`):`LLM_REASONING_KIND`(默认空 = `auto`)、`LLM_OCR_MODEL`、`LLM_LOCAL_ENABLED`(默认 `false`)/`LLM_LOCAL_PROVIDER`/`LLM_LOCAL_BASE_URL`/`LLM_LOCAL_API_KEY`/`LLM_LOCAL_MODEL`、`CAPACITY_L3_ENABLED`(默认 `false`)/`CAPACITY_L3_CACHE_TTL`(默认 `5m`)。⑤ **内置工具新增 `image-crop`**(按区域裁切图片:`x`/`y`/`width`/`height` → PNG data URI + 裁切尺寸;供大图分区精读;**策略默认 owner-only**)。⑥ **推理开关按形状下发(旧陈述修正)**:v0.41 的「Qwen 一类只 WARN、请求一字不改」**已作废**——形状由 `reasoning.kind` 声明后**真下发**(→ 字段名映射表见 changelog v0.53 条),**只有形状未知/有歧义时才 WARN 且一个字段都不发**;`reasoning_effort` 的非法值由「降级到最近一档」改为**丢弃并记 WARN(不降档)**,`effort:none` 一律翻成**显式关思考**。**未做**:DashScope 形状的 `auto` 推断(只能在显式声明 `reasoning.kind=dashscope` 时下发,绝不从模型名/BaseURL 猜)、Qwen 的 `/think` 前导消息软开关(与 design 口径冲突,未实现)。**上一版 `v0.52`**:(**工具策略落盘 + 主代理代审小字 + 流程图节点参数摘要**,依据 design/11 §3/§4/§8(裁决 1.3 / 10.4.2 / 13.4.3)与 design/19 §1/§1.1(裁决 11.4.2 / 11.4.3 / 13.4.1),2026-10-04 落实):① **工具策略(`allowInGroup`/`allowInStranger`)落盘 SQLite `tool_policies`(迁移 `0012_tool_policy`),重启保留**——`registry.SetPolicy` **先落库后改内存**,落盘失败则内存保持旧值并返回错误(见 §2 行 5);② 新增下行帧 **`approval_notice`**(§3.11):敏感操作经主代理代审后推一行小字(`explain` 由**后端产出**、客户端原样渲染),审计口径 `who`=角色名 / `what`=`approval.<工具名>` / `result`=`granted|denied`,**仅 `owner` + `admin`**(fail-closed 默认);③ `graph_update` 节点 `props` 新增四类**纯增量可选**键(tool `args_keys`/`args_summary`、guardrail `action`/`allow`/`elapsed_ms`、memory `action`/`scope`/`hits`/`bytes`/`count`/`failed`、orchestration `subagent`/`persona`),`memory`/`guardrail`/`orchestration` 旁路节点 span **首次真进图**,值在发布前已脱敏——**帧名、事件名、顶层字段集一字未动**(见 §3.5)。**上一版 `v0.51`**:(**后台子代理结果登 WS 帧 + 回填父会话**,依据 design/05 §9「子代理完成 → 结果回填主会话并提示用户」,裁决 02.4.3,2026-10-04 落实):新增下行帧 **`subagent_result`**(`{type, sessionId, data:{sessionId, status:"done", summary, agents:[{id, goal, role?, mode?, status, summary?, text?, steps?}]}}`,见 §3.10)——`subagent_spawn` 的批次转后台后结算时,编排层发布在 bus 主题 `subagent.result` 上的合并结果,此前**没有任何消费者**(中继既非 `ws.*` 也不认该 4 段以外的 subject)⇒ 结果上不了前台、只剩进度帧;现在该 subject 被登记为中继并成形为这一帧,**帧壳 `sessionId` = 派生该批次的父会话**,结果因此落回**父会话的实时流**。**角色门**:新帧属内容帧 → **只发 `owner` + `admin`**(fail-closed 默认,未扩 7 个状态机信号白名单)。**向后兼容**:纯新增帧,老客户端按未知帧忽略即可,既有帧/端点/鉴权一律不变。**诚实边界**:结果**不落库**(父会话转录里没有新消息、重连回放不补发)、**未**经 design/15 会话消息编排器续接、**前端尚无该帧 handler**(今天的效果是「帧到达 owner/admin 连接」)。**同版另含管理面(D 组,2026-10-04 落实)**:① **`X-API-Key` 真正接线**(裁决 15.4.2):管理/用户侧已认证端点同时接受该头,命中签发表即通过且**一律 `owner` 级**(裁决 15.4.3 取消 role 分级),带该头时只走 key 分支、未知/已吊销/空值回 `401` 且不回落 Bearer(fail-closed),WS 握手仍只认 Bearer;② **新增主人白名单档案读写** `GET`/`PUT /api/v1/admin/owner-profile`(裁决 10.4.5,**owner-only**,写带审计 `owner.profile.update`)——面板的「保存」从此是**真保存**(落盘 `configs/users/owner.yaml` 0600 原子写 + 热替换活档案,渠道 actor 判定立即生效),不再是只改本地 state 的假成功;③ **persona `identity.systemPrompt` 改必填**(裁决 08.4.2):缺失/空白回 `400 bad_request`,**不再派生默认说明串**,写出的 YAML 必能被载入期同一规则重新载入。见下方「管理面板写操作契约」。**上一版 `v0.50`**:(**事件批:流式帧的结构化补齐**,依据 design/04 §1.1,裁决 01.4.1 / 01.4.2 / 01.4.3 / 03.4.4,2026-10-04 落实):① **`tool_execution` 拆三事件**——旧单帧 `tool_execution.update {id,index,delta}`(phase 与工具名拼在 `delta` 字符串里)被 **`tool_execution.start`**(进入执行,`phase:"running"`)/ **`tool_execution.update`**(运行中进度/局部输出,可多帧)/ **`tool_execution.end`**(完成或失败,`phase:"done"|"error"`)替换,载荷改为结构化 `{tool, phase, id?}`(`tool` = 工具名;`phase` = 细粒度执行阶段,与 §3.5 流程图节点 status 同一套 `running/done/error` 枚举;`id` = provider 工具调用 id,可与 `tool_call.*` 帧关联)——**phase 不再塞进 `delta`**;loop 现已真实发出 start/end(update 的 wire 映射已实现并验证,但工具分发器尚无中途进度上报源,**暂无生产者**,见 §3.1)。② **`streamId`(模型流帧 `data` 内新增可选字段)**:同一次模型流(一次 ChatStream 调用)的多帧共享同一 `streamId`,多步工具轮的第二次模型调用取新 id;与帧壳的 `turnId`(整个回合恒定)分工——`turnId` 标记「这轮回答」,`streamId` 标记「这轮里的哪一条流式消息」。③ **`turn.start` / `turn.end`(新帧)**:loop 在回合边界发出、经 ws_bridge 下行;**无 `data` 载荷**(帧壳的 `sessionId`/`turnId`/`seq` 即边界信号;回合终态信息仍由既有 `stream_end` 携带);`turn.end` 覆盖回合的所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错),被静音的内部流程(backthink / 子代理)不发出。④ **向后兼容**:`turn.start/end` 与 `streamId` 为纯新增,老客户端可安全忽略;**破坏性**在旧 `tool_execution.update` 单帧——loop 不再产生它,按旧形状(解析 `delta` 里的 `phase:工具名` 拼串)消费的客户端必须迁移到三事件;现有 Web 前端未消费这些帧,行为不变。⑤ 本批新帧全部落在 v0.48 下行帧门的「仅 `owner` + `admin`」档(fail-closed 默认,无需扩白名单)。⑥ **`graph_update` 会话范围口径修正**(裁决 01.4.5,随实现事实补记):`web` / `web:` / `sess-backthink` 三范围(见 §3.5)。见 §3.1 与 v0.50 变更记录。**上一版 `v0.49`**:(**WS 握手回放与上行帧的角色门(design/11 §7.2)**:补上 §7.1 下行门之外的**同一类缺口的另两处**,对 `viewer` **继续收紧**,`owner` / `admin` 行为不变,**无端点、无字段形状变更**——① **握手 `history` 回放改为仅 `owner` + `admin`**:此前任意角色一连上 `/ws/chat` 就会收到主会话最近 50 条消息正文(`thinking` 已剥离,但**正文本身**就是主人的对话),`viewer` 从此**不收**;② **上行 JSON 帧按角色授权(fail-closed)**:非 `owner`/`admin` 连接发来的**任何**上行 JSON 帧一律拒绝,**定向**(只回发送者)回 `{type:"error",data:{code:"forbidden"}}`,**不广播、不驱动模型、不落库、不返回任何内容**——被拒帧含 `chat`(此前 `viewer` 可**真的驱动模型并落库**)、`interrupt`、`subagent_ask` / `task_output_query`(其应答是**定向**帧、能绕过 §7.1 下行门把子代理上下文/任务输出直接回给 `viewer`)、`voice_input`、`mode_switch`,且**未登记的新上行帧默认拒**;**协议级帧(ping/pong、close)不受影响**。③ **合口径**:`viewer` = **可连接、可读 12 条只读监控 REST(v0.40)、可收 7 个状态机信号帧(v0.48)**;**除此以外 WS 上拿不到任何内容、也发不出任何指令**。见 §3 开头的「上行帧与握手回放的角色门」与 v0.49 变更记录。**上一版 `v0.48`**:(**WS 下行帧的角色门(design/11 §7.1)**:hub 的下行现在是**按帧类型分档**的——只有 7 个**状态机信号**(`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full` / `session_read`,**均不含任何内容**)继续发给**所有已认证连接**(含 `viewer`),**其余全部下行帧**(流式内容 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` / `stream_chunk` / `stream_end` / `thinking`;持久化 `message_persisted` / `message_edited` / `message_deleted` / `channel_persisted`;系统内部 `graph_update` / `subagent_event` / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`;私有状态 `mood` / `device_update` / `reminder`)**改为仅发 `owner` + `admin`**)。**对 `viewer` 是收紧**:此前它连上 `/ws/chat` 就能收到角色回复正文与推理内容,现在收不到;**`owner` / `admin` 行为不变**;**无端点、无字段形状变更**——`viewer` 的 12 条只读监控 REST 端点(`viewerAuth`,v0.40)与 `/ws/chat` 的连接鉴权都**照旧**;**新帧类型默认只发 owner/admin**(fail-closed)。见 §3 开头的「下行帧的角色门」与 v0.48 变更记录。**上一版 `v0.47`**:(**`graph_update` 子代理泳道根节点新增可选 `props.persona`**:派发子代理时若指定了目标角色,该分支根节点的 `props` 就多一个 `persona` 键,标注它**正在扮演的角色**——用途是让流程图能显示「这个子代理是以谁的身份在替班」,而不只是子代理 id;**纯增量**:不带角色派发时该键不出现,老客户端忽略未知键即可,见 §3.5 与 v0.47 变更记录)。**上一版 `v0.46`**:(**`PATCH /api/v1/sessions/{id}` 的 `personaId` 由「可重新绑定该会话的角色」改为「出现即 `400`」**:**会话角色在创建时确定、之后不可变**,要换角色请新开会话(design/22 §8-1)——**明确拒绝**而非静默忽略/200 空操作;**键出现**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)一律 `400 bad_request` 且消息点名 `personaId`,该检查**先于**会话存在性检查(未知会话 + `personaId` 也回 `400` 而不是 `404`)。**破坏性**:按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色 / 显式 `""` 解绑」的客户端必须改为**新建会话**选角色;`title`/`pinned` 行为与 `POST /sessions {personaId}`(创建时绑定)**完全不变**,不再有 `501/200 {personaId}` 的 PATCH 路径)。上一版 `v0.45`:`PUT /admin/channels/{platform}/config` 的 `personaId` 由「省略/`""` 都 = 解绑」修订为三态(**省略(或 `null`)= 保留已存绑定**、**显式 `""` = 解绑**、**非空 id = 绑定**,未知角色仍 `400`)。 @@ -222,20 +224,20 @@ ### 2.0 管理侧权限矩阵(v0.40 起,与 `internal/services/gateway/routes.go` 逐条一致) -- **匿名(无 token / token 无效)**:任意 `/api/v1/admin/*` → `401 {error:{code:"unauthorized"}}`(fail-closed,全部 **60** 条管理路由无一例外 —— 12 条 viewer 档 + 10 条 owner/admin 档 + 38 条 owner-only 档)。 +- **匿名(无 token / token 无效)**:任意 `/api/v1/admin/*` → `401 {error:{code:"unauthorized"}}`(fail-closed,全部 **61** 条管理路由无一例外 —— 12 条 viewer 档 + 11 条 owner/admin 档 + 38 条 owner-only 档)。 - **角色分档**(`✅` 放行,`❌` 回 `403 forbidden`;owner>admin>viewer 递增): | 端点组 | owner | admin | viewer | |------|:---:|:---:|:---:| | **只读监控档**(12 条 GET,见下①) | ✅ | ✅ | ✅ | -| **owner/admin 只读档**(10 条,见下②) | ✅ | ✅ | ❌ | +| **owner/admin 只读档**(11 条,见下②) | ✅ | ✅ | ❌ | | **owner-only 写/凭据档**(38 条,见下③) | ✅ | ❌ | ❌ | ① **`viewer` 可读的 12 条只读监控档**(`viewerAuth` = owner/admin/viewer)——模块清单 + 运行时指标/日志/通知,全部是**非敏感投影**(无凭据、无他人账号数据、无消息正文): `GET /admin/personas`、`GET /admin/sessions`、`GET /admin/plugins`、`GET /admin/plugins/installed`、`GET /admin/channels`、`GET /admin/iot`、`GET /admin/automations`、`GET /admin/rag`、`GET /admin/observability/metrics`、`GET /admin/observability/trace`、`GET /admin/observability/logs`、`GET /admin/notifications`。 -② **仅 `owner`/`admin` 的 10 条**(`viewer` → `403`;理由:含**他人数据 / 隐私正文 / 完整导出 / 审计流水 / 全局凭据面**): -`GET /admin/memories`(私人记忆正文)、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`(完整 IM 消息流/转录)、`GET /admin/llm`(provider/模型配置面)、`GET /admin/llm/catalog`(模型目录候选面,**v0.53 新增**)、`GET /admin/users`(账号与 API key 列表)、`GET /admin/backthink`(后台思考记录)、`GET /admin/observability/audit`(审计流水)、`GET /admin/sessions/{id}/export`(会话导出)、`POST /admin/rag/query`(知识库检索,语义上是读,但按「全局知识面」归此档)。 +② **仅 `owner`/`admin` 的 11 条**(`viewer` → `403`;理由:含**他人数据 / 隐私正文 / 完整导出 / 审计流水 / 全局凭据面 / 成本面**): +`GET /admin/memories`(私人记忆正文)、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`(完整 IM 消息流/转录)、`GET /admin/llm`(provider/模型配置面)、`GET /admin/llm/catalog`(模型目录候选面,**v0.53 新增**)、**`GET /admin/llm/usage`(LLM 用量/成本汇总面,**v0.64 新增**——含 token 成本与模型/用途分解,故与 `/admin/llm` 同门,**不放给 viewer**)**、`GET /admin/users`(账号与 API key 列表)、`GET /admin/backthink`(后台思考记录)、`GET /admin/observability/audit`(审计流水)、`GET /admin/sessions/{id}/export`(会话导出)、`POST /admin/rag/query`(知识库检索,语义上是读,但按「全局知识面」归此档)。 ③ **仅 `owner` 的 38 条**(`admin` 与 `viewer` 均 `403`)——全部写操作,外加**凭据获取路径**(`login-qr`/`login-status`,`view`/`friends`,渠道连接/测试/配置)与**主人身份档案**(决定谁能进 owner 车道,连读都不给 admin): `GET /admin/channels/{platform}/login-qr`、`login-status`、`view`、`friends`;`POST /admin/channels/{platform}/friend/handle`、`message/test`;`PUT|DELETE /admin/channels/{platform}/config`;`POST /admin/channels/open|close|test`;`POST /admin/plugins/policy|install|uninstall`;`POST /admin/personas`、`PUT /admin/personas/{id}`;`GET|PUT /admin/owner-profile`(v0.51);`POST /admin/users`、`POST /admin/users/apikey`、`DELETE /admin/users/apikey/{id}`;`POST /admin/iot/{id}/control`、`POST /admin/iot/scenarios`、`POST /admin/iot/rules`;`POST /admin/automations`、`POST /admin/automations/{id}/run`、`DELETE /admin/automations/{id}`;`POST /admin/llm`、`POST /admin/llm/test`、**`POST /admin/llm/reset-from-env`(v0.62 新增,破坏性写——覆盖面板对若干键的修改,因此门与同页两条 LLM 写端点一致,**不比同族更宽**)**;`POST /admin/memories`、`POST /admin/memories/search`;`POST /admin/rag`、`POST /admin/rag/delete`;`POST /admin/system/revoke-keys|rotate-jwt|backup|restore`。 @@ -274,6 +276,7 @@ | 22 | `/api/v1/admin/system/backup` | POST | ✅(写) | 备份数据(owner-only,审计);用 SQLite `VACUUM INTO` 快照(含已提交 WAL 内容)到 `/backup-.db` → `{path,size}` | | 23 | `/api/v1/admin/system/restore` | POST | ✅(写) | 恢复数据(owner-only,审计);body `{path}` → 校验路径在 dataDir 内→**先备份当前**→替换 DB 文件→`{restored:true,backup,note:"需重启后生效"}`(运行进程持有 DB 句柄,热替换需重启可见) | | 24 | `/api/v1/admin/system/storage` | GET | ✅ | **存储状态(只读,v0.60)**:owner-only、**无审计动作**;`{data:{main:{kind:"sqlite",path,fileBytes,walBytes,shmBytes,pageSize,pageCount,freelistPages,freeBytes},documents:{count,bytes},vector:{kind:"pgvector"|"none",enabled,ready}}}`——**绝不含凭据**(DSN/密码/`postgres://` 全文一个字都不回);未接活连接 / 路径未配置 / `documents` 表读不到 → `500 storage_unavailable`(**不回全 0 假快照**)。详见下方「系统设置契约」 | +| 24a | `/api/v1/admin/llm/usage` | GET | ✅ | **LLM 用量汇总(只读,v0.64,切片 U1)**:**owner\|admin 读**、**无审计动作**;`?since=&until=&purpose=`(时间接受 RFC3339 或 Unix 毫秒;缺省窗口 = 近 24h;跨度上限 **90 天**,超过 → `400`,**不静默裁剪**)→ `{data:{window:{since,until,purpose},totals:{calls,prompt,completion,cacheHit,cacheMiss,hitRate},byPurpose:[…],byModel:[…]}}`;**`hitRate` = cacheHit/(cacheHit+cacheMiss),分母 0 ⇒ `0`**;**绝不含凭据与消息正文**;未接线 / 查库失败 → `500 usage_unavailable`(**不回全 0 假汇总**)。详见下方「LLM 用量汇总契约(v0.64)」 | > **M10 前端契约补充(管理面板)**:`GET /api/v1/admin/users` · `GET /api/v1/admin/llm` · `GET /api/v1/admin/automations` · `GET /api/v1/admin/backthink` · `GET /api/v1/admin/rag` · `POST /api/v1/admin/rag/query` · `GET /api/v1/admin/observability/metrics` · `GET /api/v1/admin/observability/trace` · `GET /api/v1/admin/observability/audit` · `GET /api/v1/admin/observability/logs` · `GET /api/v1/admin/notifications` 已实现,返回真实数据(**日志 tail 见下方「可观测性契约」**;未注册 `GET /api/v1/admin/logs`)。**写操作已补齐**(见下方「管理面板写操作契约」):人格/API key/设备控制/自动化/LLM 测试/渠道连接与识别测试/记忆检索/会话导出/插件卸载**真实持久化或真实生效**;**系统设置安全与存储类**(吊销 API key / 轮换 JWT 密钥 / 备份 / 恢复)也已**真实实现**(见下方「系统设置契约」);**主人档案保存(v0.51)同样已实现**——`GET`/`PUT /api/v1/admin/owner-profile` 真实落盘 + 热替换活档案,面板的「保存」不再是假成功;仍为后置 M10:**新增用户**、**IoT 场景与规则**、**编辑环境变量/重启服务/检查更新**——后端如实返回 `501 not_implemented`,不伪造成功。(**知识库删除已实现**:`POST /admin/rag/delete` 真实删除,见下方写操作契约;**插件安装已实现**:v1 为 MCP server 热安装。) @@ -460,6 +463,39 @@ - **只读保证**:调用前后 `documents` 行数与数据目录内的文件集合均不变(有用例钉住:既没有写、也没有顺手备份)。 - **前端用法**:`adminSystemStorage()`(`frontend/web/src/lib/api/adminSystem.ts`)→ 卡片 `value` = `SQLite 12.3 MiB`、`hint` = WAL/页数/空闲页、详情行 = `documents N 件` 与 `PG 增强 …`;取数走与其它已接线卡片同一条加载/错误态。 +**LLM 用量汇总契约(`GET /api/v1/admin/llm/usage`,v0.64,切片 U1)** —— 只读查询:**owner|admin 读**(与同页 `GET /admin/llm` 同门;含成本与模型/用途分解,**不是**给 `viewer` 的监控投影)、**不审计**、**只读**(只有 `SELECT`,复用组合根注入的活连接——不新开池、不写、不迁移)。它回答「这些模型调用到底花了多少」,是面板 LLM 页「用量」小卡的数据源。字段名逐字对齐 `internal/services/gateway/admin_llm_usage.go`。 + +- **请求**:`GET /api/v1/admin/llm/usage?since=&until=&purpose=`,`Authorization: Bearer `(role=owner|admin)或 `X-API-Key`。 + - `since`/`until`:**接受两种形态** —— **RFC3339**(如 `2026-10-05T00:00:00Z`,JS 的 `new Date().toISOString()` 就是这个)或 **Unix 毫秒**(纯数字,如 `1759622400000`;与 `/sessions/{id}/messages` 的 `before`/`after` 游标同一种口径,便于客户端复用)。其它形态 → `400 bad_request`(**绝不静默当成「现在」**)。 + - **缺省窗口 = 近 24 小时**(`until` 缺省 = 现在,`since` 缺省 = `until − 24h`);`until` 必须**严格晚于** `since`,否则 `400`。 + - **窗口上限 = 90 天**:跨度超过上限回 `400 {error:{code:"bad_request"}}`(文案点名上限)——**不静默裁剪**,静默裁剪会让客户端把「我没查那么久」误读成「就那么少」。 + - `purpose`:空格截断后**精确匹配**用途标签(如 `chat` / `memory_extract` / `noise_review`);缺省/空 = **不过滤**。 +- **响应**:`200` + +```json +{"data":{ + "window":{"since":"2026-10-04T00:00:00Z","until":"2026-10-05T00:00:00Z","purpose":""}, + "totals":{"calls":128,"prompt":412000,"completion":38000, + "cacheHit":356000,"cacheMiss":56000,"hitRate":0.8641}, + "byPurpose":[{"purpose":"chat","calls":120,"prompt":410000,"completion":37000, + "cacheHit":355000,"cacheMiss":55000,"hitRate":0.8659}], + "byModel":[{"model":"deepseek-chat","calls":128,"prompt":412000,"completion":38000, + "cacheHit":356000,"cacheMiss":56000,"hitRate":0.8641}] +}} +``` + + - `window` 回显**补齐后**的时间窗(缺省值也回显,客户端不必自己猜)与用途过滤(`""` = 未过滤);时间一律 **UTC RFC3339**。 + - `totals`:整窗合计,键**恰好六个**——`calls`(成功调用次数)、`prompt`(输入侧 token)、`completion`(输出侧 token)、`cacheHit`/`cacheMiss`(输入侧两档)、`hitRate`。 + - **`hitRate` 口径(重要)**:`cacheHit / (cacheHit + cacheMiss)`,**分母为 0 时下发 `0`**(口径与 `model.Response.CacheHitRate` 同源,绝不下发 `NaN`/`Inf`/编造的 100%)。⚠️ 客户端展示时建议把「分母为 0」与「真 0% 命中」分开:前者显示 `—` + 说明「本窗口没有输入侧缓存明细」,后者才是 `0.0%`(Web 面板的用量小卡就是这么做的)。 + - `byPurpose` / `byModel`:按用途 / 按模型分解,**元素键 = 分组键 + 上面六个合计键**(分别是 `purpose` / `model`);排序 = **调用次数降序、同次数按键升序**(输出确定,客户端可安全 diff);**无数据时是 `[]` 而非 `null`**。 + - **不含凭据(红线)**:响应里既没有 `apiKey` / token,也**没有消息正文** —— 那些东西根本没进 `llm_usage` 表(写入方的样本结构里就没有这些字段)。 +- **错误**:`401`(无凭据 / 凭据无效);`403`(`viewer`;与 `/admin/llm` 同门);`400 bad_request`(`since`/`until` 形态非法、`since` 不早于 `until`、跨度超 90 天);`500 {error:{code:"usage_unavailable"}}`(汇总出口未接线,或查库失败)——**诚实边界**:此时**不返回**全 0 的假汇总,客户端应显示错误而不是 `0`。 +- **数据来源与写入口径(谁在写这张表)**:每次**成功**的模型调用都会把一条**非敏感**样本(`adapter` / `model` / `purpose` / 六个 token 计数 / `tookMs`)异步落进本机 `llm_usage` 表(迁移 `0017`)。写入是**有界队列 + 单写者 + 批量事务**:队列满即**丢弃并限流 WARN**,**绝不阻塞模型调用**;整批失败只记 WARN,不影响任何一次对话。**凭据与消息正文绝不入库、绝不进日志**。 + - ⚠️ **`session_id` 当前恒为 `NULL`**:模型适配器跑在 loop 之下、没有会话概念(`internal/runtime/model/stream.go` 的 `StreamEvent.SessionID` 注释有同样说明),把会话标识带到适配器边界需要 loop 侧在请求上打标 —— 列已建好、索引已建好,接线后即可用;**当前聚合也按会话分组**(本端点只按 `purpose` / `model` 分解)。 + - **保留期**:`LLM_USAGE_RETENTION`(默认 `720h` = 30 天;**≤0 = 关闭清理**,样本全留,启动日志会写明「llm usage retention disabled」);启动期清一次 + 此后每小时清一次。 +- **鉴权/审计档位说明**:它是**查询不是动作**,因此**无审计记录**;矩阵归类见 §2.0 的 ②(owner/admin 只读档)。 +- **前端用法**:`adminLlmUsage()`(`frontend/web/src/lib/api/admin.ts`)→ 卡片「用量(近 24h)」显示**调用数 / 输入 token / 缓存命中率**(`cacheHit + cacheMiss = 0` 时命中率显示 `—`);取数走与其它已接线卡片同一条加载/错误态(**读不到就显示错误,不显示 0**)。 + **知识库 RAG 契约**: - `GET /admin/rag` → `{"data":{"documents":[{id,name,desc?,chunks,embedded}],"hits"?:[{docId,docName,chapter?,text,score,semantic}]}}`。`hits` 仅在携带 `?query=` 时出现;`embedded` 反映部署是否配置向量增强(未配置为待嵌入)。 - `POST /admin/rag` body `{"name","desc?","content"}` → `201 {"data":{"name"}}`。**导入文档**:后端分块(空行分段,超长段按固定长度切)并落项目索引(项目+章节),立即可被关键词/行上下文检索;未注入 `RagStore` 回 `501`。语义/向量嵌入为优雅降级(知识库 Store 不直属向量索引,导入文档走 keyword/line 路径;语义检索仍对既有 project-linked 记忆生效)。 @@ -467,7 +503,7 @@ - `POST /admin/rag/delete` body `{"id":"..."}` → `200 {"data":{"id":"...","deleted":true}}`。**真实删除**:从项目索引删除该文档条目并**级联删除其全部章节**(单事务),删除后 `GET /admin/rag` 不再出现、检索不再命中。`id` 缺失/空白回 `400`;文档不存在回 `404 not_found`;未注入 `RagStore` 回 `501`(**owner-only + 审计 `rag.delete`**)。 **可观测性契约**(`GET /api/v1/admin/observability/*`,均需 owner/admin;`metrics`/`trace`/`audit` 的 `?limit=N` 缺省 50、上限 500): -- `GET /metrics` → `{"data":{"totalRequests","qps","avgLatencyMs","maxLatencyMs","perMethod{}","perPath{}","statusCounts{}","llmCalls","subagentEvents"}}`。`qps` 为近 1 秒窗口计数;`llmCalls`/`subagentEvents` 由实际观测到的 trace span 按 `kind` 累计(Tracer 未注入时为 0)。**Token/成本后端不统计、不返回**(不伪造指标)。 +- `GET /metrics` → `{"data":{"totalRequests","qps","avgLatencyMs","maxLatencyMs","perMethod{}","perPath{}","statusCounts{}","llmCalls","subagentEvents"}}`。`qps` 为近 1 秒窗口计数;`llmCalls`/`subagentEvents` 由实际观测到的 trace span 按 `kind` 累计(Tracer 未注入时为 0)。**Token/成本本端点不统计、不返回**(不伪造指标)——**token 与成本由 v0.64 新增的 `GET /admin/llm/usage` 单独回答**(见 §2「LLM 用量汇总契约(v0.64)」)。 - `GET /trace?limit=100` → `{"data":[{id,parentId?,name,kind,status,startedAt,endedAt?,durationNs,props?,events?]}`。`kind` ∈ `llm_call|tool_call|orchestration|state_transition|subagent|memory|guardrail`;`durationNs` 为纳秒;`events` 为 `[{time,type,props?}]`。 - `GET /audit?limit=100` → `{"data":[{id,who,what,when,result}]}`。`id` 为整数自增;`when` 为 RFC3339;来源为 SQLite `audit_logs` 表(未注入 AuditStore 时退化为进程内存)。 - `GET /logs?level=&limit=` → `{"data":[{time,level,source,msg}]}`(**后端日志 tail**,v0.35)。 @@ -739,6 +775,7 @@ - 语音:上行 `voice_input`(base64 音频或已转写 text,**v0.59 起可带 `clientMsgId` 幂等**),下行 `voice_output`(合成音频 base64 + text + **`sessionId`/`deviceId?`**,**定向回发起那条连接、回合结算时才到**,空正文/TTS 失败带 `provider` 降级信号);**语音回合与文本回合走同一条路**(落库 / 跨端 / 忙时排队),见 §3.15。 ## 5. 变更记录 +- `v0.64`(**LLM 用量落库 + 只读汇总端点 + 面板用量小卡**,依据主人 2026-10-04 夜裁决「**2.4 落**」与 T1(`72b95df`:每次模型调用已记结构化用量日志、但**只落日志**),2026-10-04 落实)。**三档标注**:**纯增量** = 新端点 `GET /api/v1/admin/llm/usage`、新迁移 `0017_llm_usage`、新 env 键 `LLM_USAGE_RETENTION`(默认 `720h`;`≤0` = 关闭清理)、面板 LLM 页新小卡、前端新 API `adminLlmUsage()`;**行为变更** = 无;**破坏性** = 无。**A. 为什么落库**:日志能看单次、看不了趋势,也做不出按用途/模型/会话的成本视图,面板更无数据源 —— 主人要的是「这些调用到底花了多少」。**B. 写入绝不阻塞调用(本版最关键的一处设计)**:`model` 层**不直接开库**,只在既有 `logUsage` 处增加**可选**接口 `model.UsageSink`(**`nil` ⇒ 仅日志、行为逐字不变**,因此既有 model 用例**零改动**即全绿);组合根注入 `model.UsageWriter` = **有界队列(1024)+ 单写者 + 批量事务(128 行/批、2s 兜底冲刷)**:队列满 ⇒ **丢弃 + 限流 WARN(30s 一条,带累计丢弃数)**,**绝不阻塞、绝不 panic、绝不无界增长**;整批失败只记一条带来源 WARN(不静默);停机时先 drain 写完再退。sink 挂在 `RuntimeLLM`(热重载时稳定)而非适配器上,因此面板每次保存 models.json 都不会把落库静默断掉;**每一条链**(主/备/本地第三级/按角色跨协议)都带上它。**C. 表**:`llm_usage`(`id/session_id/purpose/adapter/model/prompt_tokens/completion_tokens/cache_hit_tokens/cache_miss_tokens/reasoning_tokens/total_tokens/took_ms/created_at`)+ `idx_llm_usage_created_at` + `idx_llm_usage_session_id`。⚠️ **`session_id` 当前恒为 `NULL`**:模型适配器跑在 loop 之下、没有会话概念(`internal/runtime/model/stream.go` 对 `StreamEvent.SessionID` 有同样说明),填它需要 loop 在请求上打标,而那份文件属本轮并行切片在途,故**先留列与索引、后接线**;本端点也**不按会话分组**。**D. 读取**:`GET /api/v1/admin/llm/usage?since=&until=&purpose=`(**owner|admin 读**,与同页 `/admin/llm` 同门;**不审计** —— 查询不是动作)→ `{data:{window:{since,until,purpose},totals:{calls,prompt,completion,cacheHit,cacheMiss,hitRate},byPurpose:[…],byModel:[…]}}`;**缺省窗口 = 近 24h**;`since`/`until` 接受 **RFC3339 或 Unix 毫秒**;**跨度上限 90 天,超过 ⇒ `400`(不静默裁剪)**;**`hitRate` = cacheHit/(cacheHit+cacheMiss),分母为 0 ⇒ `0`**(口径已写进 §2 契约与前端展示规则:**分母 0 显示 `—`,不显示 `0%`**);分解**按调用次数降序、同次数按键升序**;**绝不含凭据与消息正文**(表里根本没有这些列)。**E. 保留期**:`LLM_USAGE_RETENTION` 默认 `720h`(30 天),**`≤0` = 关闭清理**(启动日志写明「llm usage retention disabled」);**启动期清一次 + 每小时清一次**(复用 `cmd/cyrene/wire_document_gc.go` 的既有模式;清扫间隔**不做成 env 键** —— 保留期才是部署者的旋钮)。**F. 面板**:LLM 页「用量(近 24h)」小卡 = **调用数 / 输入 token / 缓存命中率**,取数走既有 `useAsyncData` 加载/错误态(**读不到显示错误,绝不显示 0**)。**G. 失败不伪装**:汇总出口未接线或查库失败 ⇒ `500 {error:{code:"usage_unavailable"}}`,**不回全 0 假汇总**。**H. 可观测性端点的口径不变**:`GET /admin/observability/metrics` 仍只报 `llmCalls`/`subagentEvents`(它不统计 token/成本),token 与成本由本版端点单独回答。**实现落点**:`internal/runtime/model/{usage.go,usage_store.go,usage_query.go,openai.go,anthropic.go,factory.go,runtime.go}`、`internal/db/migrations/0017_llm_usage.{up,down}.sql`、`cmd/cyrene/{wire_usage.go,wire_runtime.go,wire_frontend.go,wire_services.go,main.go}`、`internal/services/gateway/{admin_llm_usage.go,routes.go,deps.go,contracts.go,server.go}`、`frontend/web/src/admin/modules/llm/LlmUsageCard.tsx`、`.env.example`。**验证**:sink 为 nil 逐字不变、队列满不阻塞(有超时判定)、批量写入→读回(含 hitRate 公式)、时间窗/用途过滤与超限 400、保留期清理(过期删 / 未过期留 / ≤0 不清理)、鉴权(无凭据 401 / viewer 403 / admin 放行)、不回显凭据(键集恰好六个);**两条变异**:① `RecordUsage` 改阻塞发送 ⇒ 「队列满不阻塞」红;② `hitRate` 分母写成 `cacheMiss` ⇒ 命中率断言红(还原后均复绿)。 - `v0.63`(**客户端登记的两条落地:`documents[]` 增可选 `reason`(读不到的原因)+ `GET /sessions/{id}/messages` 对不存在/已删除的会话回 `404 session_not_found`**,依据 `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的两条登记(2026-10-05 凌晨)与后端「回执九」的接受与选择,2026-10-05 落实)。**三档标注**:**纯增量** = ① `documents[]` 元素增可选 `reason`(`omitempty`,**只在 `status:"unreadable"` 时下发**;不读该键的客户端行为逐字不变);**行为变更** = ② `messages` 对**不存在/已删除**的会话由 `200 {"data":[]}` 改为 **`404 {"error":{"code":"session_not_found"}}`**(**客户端需按 404 处理**:退回主会话 + 一句提示;回执九已提前告知);**破坏性** = 无。**A. `reason`(§1.2 ④)**——数据来源 = 服务端**已有的那份抽取失败原因文案**(就是注入模型清单里那条,`internal/runtime/documents/sidecar.go` 的 `reason*` 常量);装配期在给出 `status` 的同时给出 `Reason`(`internal/runtime/loop/document.go` 的 `documentReason`),**同一个字符串两处用**:既进模型可见上下文,也作为客户端可见的 `reason` —— 服务端因此不必为客户端另编一句,两处口径也不可能分叉。**只在「读不到」时下发**(`extracted`/`listed` 为空 ⇒ 键缺席;客户端按「缺失 ⇒ 回落通用句」实现)。**文案集合(逐字)**:`无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)` / `…PDF 已加密(无法用空口令打开),请解除密码后重新上传` / `…PDF 损坏或无法解析` / `…本期不支持 Word/Excel/PPT 解析(无文本层)` / `…内容不在可抽取的纯文本范围内` / `…文档文件不存在或已被清理` / `…读取文档失败` / `…文档引用形态非法` / `…文档上传目录未配置` / 解析器整条消息失败时的诚实兜底 `无法抽取正文:读取或解析失败`。**只放文案**:不含落盘路径、不含凭据、不含存储名以外的内容;原始错误(可能带路径)**只进日志** —— 端到端用例用一个**独特的临时目录名**做反证断言(响应体里搜不到它,且 reason 不含 `uploads`、`\documents` 等路径片段)。**抵达面**:它随 `history.DocumentRef`(**与落库同一个结构体**)由 S3.5 的回合后回写写进 `document` 列(`mergeDocumentStatus` 把 `reason` 与 `status`/`chars`/`truncated` **同批**搬运、并计入「有无变化」判据),因此**所有读取面**(`GET /sessions/{id}/messages`、握手回放)都有它;⚠️ `message_persisted` 帧在用户消息落库时就发出,那时抽取还没发生(**不带** `status`/`reason`,与既有 `status` 行为一致 —— 客户端以读接口为准)。**B. `404 session_not_found`(§1 行 6)**——现象:此前对**已删除/凭空编造**的会话都回 `200 {"data":[]}`,客户端分不清「空会话」与「会话已不在」(主人的真实遭遇:正看着的临时会话被清理,界面上只剩一片空白)。**理由(与 API 自身一致)**:`PATCH /sessions/{id}`、`POST /sessions/{id}/read` 对未知会话本来就是 `404`,让 `messages` 单独例外会让每个新客户端都踩一次。**实现**:判定**复用既有存储面**(`history.Store.SessionPersona` —— 既有存储里唯一一个「按 id 读**单个**会话 + 未知 id 报 `history.ErrSessionNotFound`」的方法;`ListSessions` 是全量列表扫描,在热分页端点上代价不成比例),**不新写 SQL、不新增存储方法**,落点 `internal/services/gateway/history_session_guard.go`(新文件)+ `handleListMessages` 一行收口。**存在但没有消息仍是 `200 {"data":[]}`**(空会话绝不能被答成 404);参数校验(`limit`/`before`/`after`/`q`)**先于**存在性判定(形状错了就是 `400`,与目标存不存在无关);存储**没有该视图**(测试替身 / 未接库的窄存储)视为**无从判定** ⇒ 保持旧的空集合语义(判不出来绝不等于「不存在」)。**C. 会话注册表口径(重要,随本版更正 §3.4 旧文)**:`chat_sessions` 是会话注册表;**隔离的 IM 会话(群聊/陌生人的 `sess-xxx`、内部 `sess-backthink`)不在其中** ⇒ 它们在本端点与 `POST /sessions/{id}/read` 拿到**同一个答案**(`404`),这是「与 API 自身一致」的必然推论;它们的消息仍可由 `GET /api/v1/admin/channels/{platform}/messages` 读出(`Docs/api-reference.md` §3.4 那句「IM 隔离会话 `sess-xxx` 亦可用」**随本版更正**)。**D. 错误码风格**:新码 **`session_not_found`**(点明是**会话**,与 `chat` 的 `document_not_found` 同风格),与 `PATCH /sessions/{id}` 的既有通用码 `not_found` **不同码** —— 本版**不动** PATCH 的既有码(不制造新的破坏性变更),差异已在 §4.4 明写。**E. 未做 / 诚实边界**:`reason` 不做 i18n、不改成码值枚举(就是服务端文案,客户端原样展示);不为「会话不存在」新增 WS 帧;不新增/变更任何 `.env` 键。**实现落点**:`internal/runtime/history/document.go`(DTO 字段)+ `internal/runtime/loop/document.go`(`documentReason`)+ `internal/services/gateway/session_document_status.go`(回写)+ `internal/services/gateway/{history.go,history_session_guard.go}`(404)+ `Docs/{api-client,api-reference}.md`。**验证**:`go build ./... && go vet ./... && go test ./... -count=1` 全绿 + windows/darwin 交叉编译通过;新用例:①扫描件 PDF 经「真实文件→真实 sidecar→真实 loop→真实 HTTP DTO」链路后 `reason` 含「OCR」且 `extracted` 时该键缺席;②reason 逐字等于注入模型那份(含解析器整条失败的兜底);③已删除会话 ⇒ `404 session_not_found`,④存在但无消息 ⇒ `200 {"data":[]}`(回归),⑤`?q=`/`after`/参数校验既有用例全绿;反证:用一个**独特的临时目录名**做路径泄漏断言(响应体里搜不到它)。 - `v0.62`(**面板「重置为 env 默认」动作落地:materialize 的反方向显式动作 + 一个新的 owner-only 管理写端点**,依据任务书 `DevDocs/_dispatch/R1-面板重置为env默认.md` 与 design/04 §3.1(`.env` 为默认/播种层、面板文件为一等来源)和 `DevDocs/开发时间线.md` 的欠账项(「Materialize 已导出、未接端点」),2026-10-04 落实)。**三档标注**:**纯增量** = 新端点 `POST /api/v1/admin/llm/reset-from-env`、面板 LLM 页的新卡片、审计动作 `llm.resetFromEnv`、2 个新错误码(`env_defaults_unreadable` / `models_unreadable`,另复用既有 `llm_persist_failed` / `llm_reload_failed` / `llm_unavailable`);**行为变更** = 无(既有端点 / 帧 / 字段一字未动);**破坏性** = 无(新端点的新用途;它本身的破坏性由请求体 `confirm:true` 强制)。**A. 新端点**(**owner-only**——与同页 `POST /admin/llm`、`POST /admin/llm/test` **同门**,破坏性写不比同族更宽;带审计 `llm.resetFromEnv`)body **必须** `{"confirm":true}`(缺失 / `false` / 非法 body ⇒ `400 bad_request` 且**一个字节都不动**),→ `200 {data:{reset:[{group,keys:[…]}], skipped:[{group,reason}], path}}`。**B. 语义(与 materialize 共用同一张键表 `runtimeconfig.groupFillSpecs`,方向相反)**:① **只覆盖 `.env` 声明过非空值的键**(`provider` / `baseUrl` / `apiKey` / `model` / `contextWindow`;协议族按**组自己的 `provider`** 选,`.env` 同时配扁平块与 `ANTHROPIC_*` 时两族各取自己的值);② **env 未声明的键一律保留面板值**(`fallback` / `multimodal` / `maxTokens` / `reasoning` / `personaModels` 在 `.env` 里没有出处 ⇒ 永不被本动作改动);③ **组集合不增减**(不复活被面板删掉的组、不删面板新增的组,**顺序也不动**);④ **面板值已与 env 一致的键不算「改回」**,不出现在 `reset` 里(`provider` 恒属此种情形);⑤ **无键可改时不写盘**(幂等,重复调用第二次回 `reset:[]` + 每组一条「已一致」的 `skipped`);⑥ **真的改了盘就热重载**运行中的 LLM 栈(无需重启;`contextWindow` 的**预算推导**仍只在启动时算一次,与 `POST /admin/llm` 同口径)。**C. 响应与凭据纪律**:`reset[].keys` 只有 **`models.json` 的 JSON 键名**、`skipped[].reason` 是中文原因,**凭据值(尤其 `apiKey`)绝不回显、绝不进日志**(`reset` / `skipped` 恒为数组,无记录时是 `[]` 而非 `null`;`path` 与 `POST /admin/llm` 同源)。**D. 对照基准**:**请求时刻按既有解析入口读到的 `.env` 默认层**(新增 `config.LoadLLMDefaults`,复用 `parseDotEnv` + 同一份 `env:"…"` 解码器 ⇒ 优先级与启动期 `config.Load` 逐字一致:进程环境 > `.env` > 标签默认);路径由组合根经 `Deps.EnvPath` 注入(与 `main.go` 的 `config.MustLoad(envPath)` 同一条),**网关自己绝不 fopen `.env`**。**E. 失败不伪装**(都**不改文件**且带审计 `error`):`.env` 源不可读 → `500 env_defaults_unreadable`;面板配置不存在/不可解析 → `500 models_unreadable`;写盘失败 → `500 llm_persist_failed`;已写盘但热重载失败 → `500 llm_reload_failed`(说明文件已改、未生效);LLM 栈未注入 → `500 llm_unavailable`。**F. 面板**:`frontend/web` 的 LLM 页新增「重置为 env 默认」卡片(`admin/modules/llm/LlmResetFromEnv.tsx`,标 `owner 专属`)——点按钮先展开**中文二次确认**文案,确认后才带 `confirm:true` 提交;结果按 `reset`/`skipped` 渲染成可复核的组名 + 键名摘要,失败原样显示后端错误文案。**G. 实现落点**:`internal/config/llm_defaults.go`(env 默认层读取)+ `internal/config/runtimeconfig/reset.go`(`ResetFromEnv` 语义,新文件)+ `internal/services/gateway/admin_write_llm_reset.go`(端点,新文件)+ `routes.go`(注册为 `ownerAuth`)+ `deps.go`/`server.go`(`Deps.EnvPath`)+ `cmd/cyrene/wire_frontend.go`(组合根接线)+ 前端 `lib/types/adminWrite.ts` / `lib/api/admin.ts` / `admin/modules/llm/LlmResetFromEnv.tsx` / `admin/modules/Llm.tsx`。**H. 未做 / 诚实边界**:**不支持「只重置某一组」**(一次调用对全部组生效;`skipped` 会如实报出没被改动的组与原因);**不做「重置 channels.json / plugins.json」**(materialize 本身也只覆盖 `models.json` 一族);**不做「重置后自动重启」**(provider 栈本身热更新,窗口推导需重启由面板文案写明)。 - `v0.61`(**PDF 文本层抽取落地:白名单第 23 项 `.pdf` + 类型裁决由「嗅探只放行 `text/plain`」改为 `(扩展名, 嗅探)` 成对裁决**,依据 design/24 §7 的 **S6 第一段**(PDF/Office 的裁决:PDF 文本层引库,扫描件与 Office 仍诚实拒绝)与 commit `f82c76d`,2026-10-04 落实)。**三档标注**:**行为变更** = ① **`.pdf` 由「一律 `415`」变为「可上传 + 可读取 + 文本层可抽取进上下文」**(把「`.pdf` 必被拒」写死的客户端必须改);② **类型裁决收紧为成对判定**(`documents.ContentAllowed`)——**方向是收紧/细化,不是放宽**:HTML/纯文本改名成 `.pdf` ⇒ `415`(嗅探不是 PDF),**PDF 字节改名成 `.txt` ⇒ `415`**(PDF 字节也只收在 `.pdf` 名下);**纯增量** = 扩展名白名单 **22 → 23 项**(唯一新增 `.pdf`),其余 22 个文本类的判定与既有响应**一字未变**。**A. 上传(`POST /api/v1/documents`,owner|admin)**——白名单加 `.pdf`;裁决 = **字节嗅探是唯一裁决权 ∧ 扩展名白名单**,自本版起**按对判定**(纯文本类必须 `text/plain`、`.pdf` 必须 `application/pdf`;落点 `internal/runtime/documents/store.go` 的 `ExtAllowed`(23 项)/ `ContentAllowed`,网关名字形态校验经它判定);`mime` 仍是**嗅探基类型**(真 PDF → `application/pdf`);`415` 文案同时点明两条口径(「只接受白名单内的文档(如 .txt/.md/.csv/.json,或内容确为 PDF 的 `.pdf`),且内容必须与扩展名相符」)。**B. 读取(`GET /api/v1/documents/{name}`,必带 Bearer owner|admin)**——**真 PDF 如实下发 `Content-Type: application/pdf`**(读时按同一对裁决重嗅探,判不定者仍退 `application/octet-stream`),`Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` **一字未变** ⇒ **PDF 永不被内联渲染**(与图片读侧刻意相反);`Cache-Control: private, max-age=86400, immutable` 与 Range 支持不变。**C. 抽取语义(DTO `status`)**——**文本层 PDF 抽正文**(页内容流里的 `Tj`/`TJ`/`'`/`"` 文本算子,页间以换行相接)⇒ `status:"extracted"`,与纯文本类走**同一条** sidecar / 注入路径;**扫描件/图片型 PDF(无文本层)⇒ 诚实拒绝 `status:"unreadable"`**,原因文案含「这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」,**绝不假装成功、也绝不返回空正文**(`ErrNoTextLayer`);**加密 PDF** 与**损坏/截断 PDF** 是**可区分**的两类失败(`ErrPDFEncrypted` / `ErrPDFDamaged`,加密的提示解除密码后重试);安全阀 = 单份最多 **5000 页** + sidecar 正文 **≤8 MiB**(撞上即 `truncated`)。**D. 客户端可见的一次性重算**——抽取器版本内部升到 **`documents/v3`**(`internal/runtime/documents/extract.go`)⇒ **旧 sidecar 一次性失效重抽**:同一份**旧附件**的 `chars`/`truncated` 结论会在**某次回合之后**重新计算(旧 PDF 若是文本层 PDF,将从 `unreadable` 变为 `extracted`)——不是附件被改写,是结论被重算。**E. 未做(诚实边界)**——**不做 OCR / 扫描件栅格化**(纯 Go 无可靠实现,会撞「单二进制 + 交叉编译」「零跨进程 IPC」两条红线)、**不做 PDF 写/生成**(只读)、**不做 `.docx`/`.xlsx` 等 Office**(仍诚实拒绝);**无新端点、无新 WS 帧、无鉴权变更、无 `.env` 新增键**。**实现落点**:`internal/runtime/documents/extract_pdf.go`(文本层抽取 + 加密/损坏分类)+ `internal/runtime/documents/{extract.go,store.go,sidecar.go}`(版本号 / 白名单与成对裁决 / unreadable 原因文案)+ 依赖 `github.com/ledongthuc/pdf`(纯 Go、无 cgo)。 diff --git a/Docs/api-reference.md b/Docs/api-reference.md index 5948157..2d2fb98 100644 --- a/Docs/api-reference.md +++ b/Docs/api-reference.md @@ -156,10 +156,11 @@ | 60 | POST | `/api/v1/admin/system/backup` | **owner** | 200 | 备份数据库 | M10/M11 | | 61 | POST | `/api/v1/admin/system/restore` | **owner** | 200 | 恢复数据库 | M10/M11 | | 69 | GET | `/api/v1/admin/system/storage` | **owner** | 200/500 | **存储状态(只读,v0.60)**:SQLite 文件/WAL/页级统计 + `documents` 表件数与占用 + 向量增强真假(`kind`/`enabled`/`ready`);**不含凭据**(DSN 一个字都不回);路径/活连接未接入或读不到表 → `500 storage_unavailable`(见 §5.35) | M10/M11 | +| 71 | GET | `/api/v1/admin/llm/usage` | owner\|admin | 200/400/500 | **LLM 用量汇总(只读,v0.64,切片 U1)**:`?since=&until=&purpose=`(RFC3339 或 Unix 毫秒;缺省近 24h;跨度上限 90 天,超过 → `400`)→ `{window,totals{calls,prompt,completion,cacheHit,cacheMiss,hitRate},byPurpose[],byModel[]}`;`hitRate = cacheHit/(cacheHit+cacheMiss)`,**分母 0 ⇒ 0**;**绝不含凭据与消息正文**;未接线/查库失败 → `500 usage_unavailable`(见 §4.19) | 后 M12 | **另**:外部 webhook 回调(如 `webhook` 渠道的 HMAC 回调)由 composition root 通过 `Deps.Webhooks` 动态挂载,**不走网关鉴权**(handler 自校验签名/授权);路径由调用方决定,不在上表枚举。 -**补登记说明(2026-10-04)**:上表中 `#62`–`#68` 是本次按 `routes.go` 逐条核对后新增的行(`plugins/installed` / `observability/logs` / `notifications` / `llm/catalog` / `owner-profile` GET+PUT / `automations/reminders`),插在各自的鉴权分档里、**既有编号一律未动**。§1.1 的 `#3`/`#4`(`auth/refresh`、`auth/logout`)与 §1.2 的 `#14`–`#18`(`upload`、`files/{name}`、三支 `audio/*`)同理。**v0.59 再补两行**:§1.2 的 `#19`/`#20`(`POST /api/v1/documents`、`GET /api/v1/documents/{name}`,均按 `routes.go` 的 `writeAuth` 注册核对)——同样取本表下一个可用号、不动既有编号。**v0.60 再补一行**:`#69` GET `/api/v1/admin/system/storage`(owner-only 只读,按 `routes.go` 的 `ownerAuth` 注册核对;**无审计动作**——它是查询不是动作),同样取下一个可用号、既有编号未动。**v0.62 再补一行**:`#70` POST `/api/v1/admin/llm/reset-from-env`(**owner-only 写**,按 `routes.go` 的 `ownerAuth` 注册核对,见 §5.13a),同样取下一个可用号、既有编号未动。**仍未登记的是 WS 事件帧**(见 §6.4 与本文档开头的历史包袱说明)。 +**补登记说明(2026-10-04)**:上表中 `#62`–`#68` 是本次按 `routes.go` 逐条核对后新增的行(`plugins/installed` / `observability/logs` / `notifications` / `llm/catalog` / `owner-profile` GET+PUT / `automations/reminders`),插在各自的鉴权分档里、**既有编号一律未动**。§1.1 的 `#3`/`#4`(`auth/refresh`、`auth/logout`)与 §1.2 的 `#14`–`#18`(`upload`、`files/{name}`、三支 `audio/*`)同理。**v0.59 再补两行**:§1.2 的 `#19`/`#20`(`POST /api/v1/documents`、`GET /api/v1/documents/{name}`,均按 `routes.go` 的 `writeAuth` 注册核对)——同样取本表下一个可用号、不动既有编号。**v0.60 再补一行**:`#69` GET `/api/v1/admin/system/storage`(owner-only 只读,按 `routes.go` 的 `ownerAuth` 注册核对;**无审计动作**——它是查询不是动作),同样取下一个可用号、既有编号未动。**v0.62 再补一行**:`#70` POST `/api/v1/admin/llm/reset-from-env`(**owner-only 写**,按 `routes.go` 的 `ownerAuth` 注册核对,见 §5.13a),同样取下一个可用号、既有编号未动。**v0.64 再补一行**:`#71` GET `/api/v1/admin/llm/usage`(**owner|admin 读**,切片 U1,按 `routes.go` 的 `writeAuth` 注册核对;**无审计动作**——它是查询不是动作),同样取下一个可用号、既有编号未动。**仍未登记的是 WS 事件帧**(见 §6.4 与本文档开头的历史包袱说明)。 **WS**:`GET /ws/chat`(握手,JWT,见 §6)。 @@ -747,6 +748,33 @@ curl -s 'http://:8080/api/v1/admin/observability/trace?limit=100' -H "Auth curl -s http://:8080/api/v1/admin/sessions/web:0123.../export -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1' ``` +### 4.19 GET /api/v1/admin/llm/usage(v0.64,只读;切片 U1) +- **鉴权**:`Authorize(owner, admin)`(`routes.go` 的 `writeAuth`);**无凭据 → `401`**、**`viewer` → `403`**(与同页 `GET /admin/llm` 同门:它含成本与模型/用途分解,不是给 viewer 的监控投影)。**无审计动作**(查询不是动作)。 +- **请求**:`GET /api/v1/admin/llm/usage?since=&until=&purpose=` + - `since`/`until`:**RFC3339**(`2026-10-05T00:00:00Z`)或 **Unix 毫秒**(纯数字,与 `/sessions/{id}/messages` 的 `before`/`after` 同口径);其它形态 → `400 bad_request`。 + - **缺省窗口 = 近 24h**(`until` 缺省 = 现在,`since` 缺省 = `until − 24h`);`until` 必须严格晚于 `since`;**跨度上限 90 天**,超过 → `400`(**不静默裁剪**)。 + - `purpose`:截断空白后精确匹配;空 = 不过滤。 +- **响应**:`200` +```json +{"data":{ + "window":{"since":"2026-10-04T00:00:00Z","until":"2026-10-05T00:00:00Z","purpose":""}, + "totals":{"calls":128,"prompt":412000,"completion":38000,"cacheHit":356000,"cacheMiss":56000,"hitRate":0.8641}, + "byPurpose":[{"purpose":"chat","calls":120,"prompt":410000,"completion":37000,"cacheHit":355000,"cacheMiss":55000,"hitRate":0.8659}], + "byModel":[{"model":"deepseek-chat","calls":128,"prompt":412000,"completion":38000,"cacheHit":356000,"cacheMiss":56000,"hitRate":0.8641}] +}} +``` +- **字段**(逐字对齐 `internal/services/gateway/admin_llm_usage.go`):`window` 回显补齐后的 UTC RFC3339 时间窗与 `purpose`(`""` = 未过滤);`totals` 恒为六个键(`calls`/`prompt`/`completion`/`cacheHit`/`cacheMiss`/`hitRate`);`byPurpose[].purpose` 与 `byModel[].model` 是分组键,其余同上;**排序 = 调用次数降序、同次数按键升序**;无数据时两个数组是 `[]` **不是 `null`**。 +- **`hitRate` 口径**:`cacheHit / (cacheHit + cacheMiss)`;**分母为 0 ⇒ `0`**(口径与 `model.Response.CacheHitRate` 同源)。客户端应把「分母 0」与「真 0% 命中」分开(前者显示 `—`)。 +- **错误**:`400 bad_request`(时间形态非法 / `since` 不早于 `until` / 跨度超 90 天);`401`;`403`;`500 {"error":{"code":"usage_unavailable","message":"读取 LLM 用量汇总失败"}}`(汇总出口未接线或查库失败 —— **绝不回全 0 假汇总**)。 +- **数据来源**:每次**成功**模型调用把一条非敏感样本异步落进 `llm_usage`(迁移 `0017`;写入口径见 §4.19 的契约与 changelog v0.64)——**有界队列 + 单写者 + 批量事务,队列满丢弃并限流 WARN,绝不阻塞模型调用**;**凭据与消息正文绝不入库**。⚠️ `session_id` 当前恒为 `NULL`(模型层无会话概念),本端点也**不按会话分组**。保留期 `LLM_USAGE_RETENTION`(默认 `720h`;`≤0` = 关闭清理)。 +- **curl**: + +```bash +curl -s 'http://:8080/api/v1/admin/llm/usage?since=2026-10-04T00:00:00Z&until=2026-10-05T00:00:00Z' \ + -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1' +# => {"data":{"window":{"since":"2026-10-04T00:00:00Z","until":"2026-10-05T00:00:00Z","purpose":""},"totals":{...},...}} +``` + --- ## 5. 管理后台 · 写端点(`owner`,全程审计) diff --git a/cmd/cyrene/main.go b/cmd/cyrene/main.go index 98fdbbe..24cf247 100644 --- a/cmd/cyrene/main.go +++ b/cmd/cyrene/main.go @@ -93,6 +93,9 @@ func run() error { // 文档回收(切片 S7):启动期清扫一次 + 此后按 DOCUMENT_GC_INTERVAL 定时清扫。 // 它不等这一次跑完就去拉网关(见 wire_document_gc.go 的取舍 1)。 a.startDocumentGC(ctx) + // LLM 用量落库与保留期清理(切片 U1):单写者 + 有界队列的异步写入器, + // 加上按 LLM_USAGE_RETENTION 的过期样本清理。 + a.startUsage(ctx) a.log.Info("cyrene subsystems started", "personas", a.personas.CountPersonas(), "tools", len(a.caps.List(capreg.KindTool)), diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index b351543..f0484db 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -85,6 +85,12 @@ type app struct { // docGC 是文档回收的常驻任务(切片 S7):未被引用且超过保留期的文档连同 // sidecar 与元数据行一起回收。GC 被配置关闭时为 nil(见 wire_document_gc.go)。 docGC *documentGC + // usage 是 LLM 用量的异步落库出口(切片 U1):注入模型栈后,每次成功模型 + // 调用都会把非敏感计数样本塞进它的有界队列。库未接时为 nil(= 只记日志)。 + usage *model.UsageWriter + // usageGC 是用量样本的保留期清理常驻任务。`LLM_USAGE_RETENTION ≤ 0` 时为 + // nil(清理关闭,见 wire_usage.go)。 + usageGC *usageRetentionGC // capacitySink 把 capacity_awaken 状态迁移广播给 Web UI(design/14 §5)。 // 由 *gateway.Server 满足(BroadcastCapacityAwaken);以接口形式持有, // 组合根才能用假出口做单元测试。 diff --git a/cmd/cyrene/wire_frontend.go b/cmd/cyrene/wire_frontend.go index 5f81022..78436d6 100644 --- a/cmd/cyrene/wire_frontend.go +++ b/cmd/cyrene/wire_frontend.go @@ -10,6 +10,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway" ) @@ -84,6 +85,10 @@ func (a *app) wireFrontend() error { DataDir: resolveDataDir(a.cfg), Database: a.db, Vector: a.vectorState(), + // Usage 是 `llm_usage` 表的只读汇总出口(切片 U1):网关复用组合根已打开的 + // 连接池,自己不开池、不读环境变量。库未接时为 nil(端点回 500 + // usage_unavailable,不回假汇总)。 + Usage: model.NewUsageReader(a.db), }) if err != nil { return fmt.Errorf("gateway: %w", err) diff --git a/cmd/cyrene/wire_runtime.go b/cmd/cyrene/wire_runtime.go index d844c28..5cace27 100644 --- a/cmd/cyrene/wire_runtime.go +++ b/cmd/cyrene/wire_runtime.go @@ -128,7 +128,15 @@ func (a *app) wireRuntime() error { // 一等来源——把 `.env` 补齐进面板配置里缺失的键(面板已有的键绝不覆盖,组集合 // 完全由面板决定),幂等;失败只记日志、不阻断启动。 materializeModels(modelsPath, a.cfg.LLM, a.log) - runtimeStack := model.NewRuntimeLLM(modelsPath, a.cfg.LLM, a.log) + // 用量落库出口(切片 U1):先建 sink,再建模型栈 —— 栈里**每一条**链 + // (主/备/本地第三级/按角色跨协议)的每一步适配器都会带上它,因此任何一条 + // 路径上的模型调用都计成本。库未接时 sink 为 nil,行为与之前逐字相同。 + a.usage = a.newUsageSink() + llmOpts := make([]model.RuntimeOption, 0, 1) + if a.usage != nil { + llmOpts = append(llmOpts, model.WithRuntimeUsageSink(a.usage)) + } + runtimeStack := model.NewRuntimeLLM(modelsPath, a.cfg.LLM, a.log, llmOpts...) a.modelAdapter = runtimeStack a.llm = runtimeStack diff --git a/cmd/cyrene/wire_services.go b/cmd/cyrene/wire_services.go index 99c294f..6d099aa 100644 --- a/cmd/cyrene/wire_services.go +++ b/cmd/cyrene/wire_services.go @@ -104,6 +104,8 @@ func (a *app) pushOutletConfig() backthink.PushOutletConfig { func (a *app) close(ctx context.Context) { // 文档回收循环要先用连接池(每轮一次查询),因此排在关库之前。 a.docGC.stop() + // 用量落库与保留期清理同样要用连接池:先把队列里剩下的样本写完,再停清理。 + a.stopUsage() if a.backthink != nil { _ = a.backthink.Stop(ctx) } diff --git a/cmd/cyrene/wire_usage.go b/cmd/cyrene/wire_usage.go new file mode 100644 index 0000000..329f872 --- /dev/null +++ b/cmd/cyrene/wire_usage.go @@ -0,0 +1,126 @@ +package main + +// 本文件把 **LLM 用量落库与保留期清理**接进组合根(切片 U1,主人裁决「2.4 落」): +// 构造异步写入器、把它注入模型栈,并在启动期清一次过期样本、此后按间隔定时清。 +// +// 三条取舍写在这里(写入器实现见 `internal/runtime/model/usage_store.go`): +// +// 1. **写入绝不阻塞模型调用**:模型栈拿到的是一个有界队列 + 单写者的 sink, +// `RecordUsage` 不做任何等待(队列满就丢弃并限流 WARN)。因此库慢、库锁、 +// 库挂了,主人的每次对话都不受影响。 +// 2. **失败不退出进程**:整批落库失败只记一条带来源的 WARN;保留期清理失败 +// 同样是 WARN 后等下一轮 —— 用量是观测数据,不该决定进程生死。 +// 3. **关闭 = 显式配 ≤0**:`LLM_USAGE_RETENTION=0`(或负值)不构造清理器, +// 由下面的一条 INFO 写明「清理已关闭」(静默的「配了却不生效」不存在)。 +// +// 清理间隔刻意不做成 env 键:保留期才是部署者的旋钮,清扫频率没有可调价值。 + +import ( + "context" + "database/sql" + "log/slog" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// usagePurgeInterval 是用量保留期清理的定时间隔(启动期另有一次立即清扫)。 +const usagePurgeInterval = time.Hour + +// usageRetentionGC 是常驻的用量保留期清理任务:一个连接 + 一个可停的循环。 +type usageRetentionGC struct { + db *sql.DB + retention time.Duration + log *slog.Logger + cancel context.CancelFunc + done chan struct{} +} + +// newUsageSink 按当前配置构造用量写入器;库未接时返回 nil(= 只记日志, +// 与这一版之前逐字相同)。 +func (a *app) newUsageSink() *model.UsageWriter { + return model.NewUsageWriter(a.db, a.log) +} + +// newUsageRetentionGC 按当前配置构造保留期清理器;库未接或保留期 ≤0(关闭清理) +// 时返回 nil。关闭原因由 startUsage 记一条 INFO。 +func (a *app) newUsageRetentionGC() *usageRetentionGC { + if a.db == nil || a.cfg.Server.LLMUsageRetention <= 0 { + return nil + } + return &usageRetentionGC{ + db: a.db, + retention: a.cfg.Server.LLMUsageRetention, + log: logger.WithSource(a.log, "cmd/cyrene"), + } +} + +// startUsage 拉起用量落库的单写者与保留期清理(各自关闭时是空操作)。 +// 它不等清理跑完就去拉网关(与文档 GC 同一取舍:慢盘上的一次 DELETE 不该把 +// 「昔涟能不能起来」绑在磁盘上)。 +func (a *app) startUsage(ctx context.Context) { + if a.usage != nil { + a.usage.Start(ctx) + } + gc := a.newUsageRetentionGC() + if gc == nil { + a.log.Info("llm usage retention disabled", + "env", "LLM_USAGE_RETENTION", "value", a.cfg.Server.LLMUsageRetention.String()) + return + } + runCtx, cancel := context.WithCancel(ctx) + gc.cancel = cancel + gc.done = make(chan struct{}) + a.usageGC = gc + go gc.loop(runCtx) +} + +// stopUsage 停掉用量落库与保留期清理(先让单写者把队列里剩下的样本写完,再停 +// 清理)。两者都要用连接池,因此必须在关库**之前**调用;未启动时是空操作。 +func (a *app) stopUsage() { + if a.usage != nil { + a.usage.Stop() + } + a.usageGC.stop() +} + +// loop 是清理循环:启动期清扫一次 → 定时清扫。 +func (g *usageRetentionGC) loop(ctx context.Context) { + defer close(g.done) + g.sweep(ctx) + ticker := time.NewTicker(usagePurgeInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + g.sweep(ctx) + } + } +} + +// sweep 跑一轮清理。删除条数**不是**每次都要记(正常运转时每轮都是 0,记满日志 +// 只是噪声);真的删了才记一条 INFO,失败一律 WARN 并让循环继续。 +func (g *usageRetentionGC) sweep(ctx context.Context) { + before := time.Now().Add(-g.retention) + removed, err := model.PurgeUsage(ctx, g.db, before) + if err != nil { + g.log.Warn("llm usage retention sweep failed", "err", err) + return + } + if removed > 0 { + g.log.Info("llm usage retention sweep removed rows", + "rows", removed, "retention", g.retention.String()) + } +} + +// stop 停掉清理循环并等它退出;未启动时是空操作。 +func (g *usageRetentionGC) stop() { + if g == nil || g.cancel == nil { + return + } + g.cancel() + <-g.done +} diff --git a/cmd/cyrene/wire_usage_test.go b/cmd/cyrene/wire_usage_test.go new file mode 100644 index 0000000..f155841 --- /dev/null +++ b/cmd/cyrene/wire_usage_test.go @@ -0,0 +1,197 @@ +package main + +import ( + "context" + "database/sql" + "log/slog" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/db" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// 本文件钉住用量落库与保留期清理**被接上组合根**(切片 U1;与 +// TestWireDocumentGC_IsWired 同一手法): +// +// - sink 必须装进模型栈,且模型栈必须由组合根构造 —— 少任一处,落库都会 +// **静默不工作**(日志照记、库里一行没有),而这不会让任何既有用例变红; +// - 写入器与清理器必须在**关库之前**停掉(两者都要用那条连接池); +// - 启动期必须真的把两者拉起来(`a.startUsage(ctx)`); +// - 保留期 ≤0 = 关闭清理,且关闭是**显式**的(记一条 INFO)。 +func TestWireUsage_IsWired(t *testing.T) { + t.Parallel() + + seams := map[string][]string{ + "wire_runtime.go": { + "a.usage = a.newUsageSink()", + "model.WithRuntimeUsageSink(a.usage)", + }, + "wire_usage.go": { + "model.NewUsageWriter(a.db, a.log)", + "a.cfg.Server.LLMUsageRetention", + "model.PurgeUsage(ctx, g.db, before)", + "a.log.Info(\"llm usage retention disabled\"", + }, + "wire_services.go": { + // 先让单写者把队列里剩下的样本写完,再停清理,最后才关库。 + "a.stopUsage()", + }, + "main.go": { + "a.startUsage(ctx)", + }, + "wire_frontend.go": { + // 管理端点的只读汇总出口(网关复用组合根已打开的连接池)。 + "Usage: model.NewUsageReader(a.db)", + }, + } + for file, wants := range seams { + src, err := os.ReadFile(file) + if err != nil { + t.Fatalf("read %s: %v", file, err) + } + for _, want := range wants { + if !strings.Contains(string(src), want) { + t.Fatalf("%s 里找不到 %q —— 用量落库没有被接上组合根", file, want) + } + } + } + + // 新 env 键必须与代码里的 env 标签逐字一致(AGENTS §10 铁律)。 + raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example")) + if err != nil { + t.Fatalf("read .env.example: %v", err) + } + if !strings.Contains(string(raw), "LLM_USAGE_RETENTION=") { + t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10)", "LLM_USAGE_RETENTION=") + } + if tag := envTagOf(t, "LLMUsageRetention"); tag != "LLM_USAGE_RETENTION" { + t.Fatalf("config.ServerConfig.LLMUsageRetention 的 env 标签 = %q, 与模板不一致", tag) + } +} + +// envTagOf 返回 config.ServerConfig 上某字段的 env 标签。 +func envTagOf(t *testing.T, field string) string { + t.Helper() + typ, ok := reflect.TypeOf(config.ServerConfig{}).FieldByName(field) + if !ok { + t.Fatalf("config.ServerConfig 里没有字段 %q", field) + } + return typ.Tag.Get("env") +} + +// usageGCDB 打开一个带迁移的内存库。 +func usageGCDB(t *testing.T) *sql.DB { + t.Helper() + ctx := context.Background() + conn, err := db.Open(ctx, ":memory:", slog.New(slog.NewTextHandler(os.Stderr, nil))) + if err != nil { + t.Fatalf("open in-memory db: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + if err := db.MigrateUp(ctx, conn, slog.New(slog.NewTextHandler(os.Stderr, nil))); err != nil { + t.Fatalf("migrate: %v", err) + } + return conn +} + +// usageGCApp 构造一个只带清理器所需依赖的 app。 +func usageGCApp(t *testing.T, conn *sql.DB, retention time.Duration) *app { + t.Helper() + return &app{ + cfg: &config.Config{Server: config.ServerConfig{LLMUsageRetention: retention}}, + db: conn, + log: slog.New(slog.NewTextHandler(os.Stderr, nil)), + } +} + +// TestUsageRetentionGC_SweepRemovesExpiredOnly 钉住清理判据:早于保留期的样本被 +// 删、保留期内的原样留着。 +func TestUsageRetentionGC_SweepRemovesExpiredOnly(t *testing.T) { + t.Parallel() + ctx := context.Background() + conn := usageGCDB(t) + insertUsageAt(t, conn, "2020-01-01T00:00:00.000Z") + insertUsageAt(t, conn, "2099-01-01T00:00:00.000Z") + + gc := usageGCApp(t, conn, 720*time.Hour).newUsageRetentionGC() + if gc == nil { + t.Fatal("保留期 > 0 时必须构造清理器") + } + gc.sweep(ctx) + + if rows := countUsage(t, conn); rows != 1 { + t.Fatalf("清理后剩 %d 行, want 1(只删过期那条)", rows) + } + var remaining string + if err := conn.QueryRowContext(ctx, `SELECT created_at FROM llm_usage`).Scan(&remaining); err != nil { + t.Fatalf("read remaining: %v", err) + } + if remaining != "2099-01-01T00:00:00.000Z" { + t.Fatalf("剩下的是 %q, want 未过期那条", remaining) + } +} + +// TestUsageRetentionGC_DisabledWhenNonPositive 钉住「≤0 = 关闭清理」是显式关: +// 0 与负值都不构造清理器(因而一行都不会被删),正数才构造。 +func TestUsageRetentionGC_DisabledWhenNonPositive(t *testing.T) { + t.Parallel() + conn := usageGCDB(t) + for _, retention := range []time.Duration{0, -time.Hour} { + if gc := usageGCApp(t, conn, retention).newUsageRetentionGC(); gc != nil { + t.Fatalf("LLM_USAGE_RETENTION=%s 时不该构造清理器", retention) + } + } + // 关闭状态下过期行原样留着(没有任何代码路径去删它)。 + insertUsageAt(t, conn, "2020-01-01T00:00:00.000Z") + if rows := countUsage(t, conn); rows != 1 { + t.Fatalf("关闭清理却动了数据:剩 %d 行", rows) + } + if gc := usageGCApp(t, conn, time.Hour).newUsageRetentionGC(); gc == nil { + t.Fatal("LLM_USAGE_RETENTION=1h 时必须构造清理器") + } +} + +// TestUsageWriter_NilDBMeansNoSink 钉住「库未接 ⇒ sink 为 nil ⇒ 只记日志」的 +// 组合根口径(与 model 层的 nil-sink 行为合成一条链)。 +func TestUsageWriter_NilDBMeansNoSink(t *testing.T) { + t.Parallel() + a := &app{log: slog.New(slog.NewTextHandler(os.Stderr, nil))} + if sink := a.newUsageSink(); sink != nil { + t.Fatalf("库未接时 newUsageSink 必须返回 nil,实得 %#v", sink) + } + if gc := a.newUsageRetentionGC(); gc != nil { + t.Fatal("库未接时不该构造清理器") + } + // 未注入 sink 的模型栈照常构造(不 panic),且模型栈不因 nil sink 变红。 + stack := model.NewRuntimeLLM(filepath.Join(t.TempDir(), "models.json"), + config.LLMConfig{}, a.log) + if stack == nil { + t.Fatal("nil sink 不该影响模型栈构造") + } +} + +// insertUsageAt 写一行带指定 created_at 的用量样本。 +func insertUsageAt(t *testing.T, conn *sql.DB, createdAt string) { + t.Helper() + if _, err := conn.ExecContext(context.Background(), + `INSERT INTO llm_usage (purpose, adapter, model, prompt_tokens, created_at) + VALUES ('chat', 'openai', 'm', 1, ?)`, createdAt); err != nil { + t.Fatalf("insert usage row: %v", err) + } +} + +// countUsage 返回 llm_usage 行数。 +func countUsage(t *testing.T, conn *sql.DB) int64 { + t.Helper() + var rows int64 + if err := conn.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM llm_usage`).Scan(&rows); err != nil { + t.Fatalf("count llm_usage: %v", err) + } + return rows +} diff --git a/frontend/web/src/admin/modules/Llm.tsx b/frontend/web/src/admin/modules/Llm.tsx index 18f76c0..2a27196 100644 --- a/frontend/web/src/admin/modules/Llm.tsx +++ b/frontend/web/src/admin/modules/Llm.tsx @@ -9,6 +9,7 @@ import { Icon } from "../../components/Icon"; import { CatalogPanel } from "./llm/LlmCatalogPanel"; import { LlmProviderForm } from "./llm/LlmProviderForm"; import { LlmResetFromEnv } from "./llm/LlmResetFromEnv"; +import { LlmUsageCard } from "./llm/LlmUsageCard"; import type { CatalogHint } from "./llm/LlmProviderRouting"; /** 一个供应商分组行:身份 + token 预算 + 上下文窗口 + 推理形状 + 路由 + 测试/编辑动作。 */ @@ -224,6 +225,10 @@ export function LlmModule(): React.ReactNode {
+ {/* 用量小卡(v0.64,切片 U1):近 24h 调用数 / 输入 token / 缓存命中率。 + 数源是只读端点 GET /admin/llm/usage —— 读不到就显示错误,不显示 0。 */} + +

供应商 / 模型

= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; + return String(value); +} + +/** 时间窗 → 「MM-DD HH:mm ~ MM-DD HH:mm(UTC)」。解析不出来的原样显示。 */ +function formatWindow(since: string, until: string): string { + const fmt = (iso: string): string => { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + const pad = (n: number) => String(n).padStart(2, "0"); + return `${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; + }; + return `${fmt(since)} ~ ${fmt(until)}(UTC)`; +} + +/** 一格指标:标签 + 数值 + 可选补充说明。 */ +function UsageCell({ label, value, hint }: { label: string; value: string; hint: string }): React.ReactNode { + return ( +
+
{label}
+
{value}
+
{hint}
+
+ ); +} + +export function LlmUsageCard(): React.ReactNode { + const usage = useAsyncData(adminLlmUsage, []); + // 成本/用量变化很慢,比供应商列表再放慢一档(仍是同一个 useAutoReload)。 + useAutoReload(usage.reload, 60000); + + const header = ( +
+ +

用量(近 24h)

+ + {usage.error ? "端点报错" : usage.data ? "已接入" : "加载中"} + +
+ ); + + if (usage.loading) { + return ( + + {header} +

加载中…

+
+ ); + } + if (usage.data == null) { + // 错误态:显示端点自己的错误文本,**不回落成 0**。 + return ( + + {header} + + 读取 LLM 用量失败:{usage.error ?? "未知错误"} —— 这是错误态,不是「用量为零」。 + + + ); + } + + const { totals, window } = usage.data; + return ( + + {header} + {usage.error ? ( + 刷新失败,下方仍是上次成功读取的用量:{usage.error} + ) : null} +
+ + + 0 + ? `命中 ${formatTokens(totals.cacheHit)} / 未命中 ${formatTokens(totals.cacheMiss)} token` + : "本窗口没有输入侧缓存明细(不是 0% 命中)" + } + /> +
+

+ 窗口 {formatWindow(window.since, window.until)} + {window.purpose ? ` · 用途过滤 ${window.purpose}` : ""} + {usage.data.byModel.length > 0 + ? ` · 模型 ${usage.data.byModel.map((m) => `${m.model} ${m.calls} 次`).join(",")}` + : ""} +

+ + 每次成功模型调用都会把一条**非敏感**用量样本(token 计数 / 缓存两档 / 模型名 / 用途 / 耗时)异步落进本机 + `llm_usage` 表:写入走有界队列 + 单写者批量事务,队列满即丢弃并告警,**绝不阻塞模型调用**;**凭据与消息正文绝不入库、绝不进日志**。 + 命中率口径 = 命中 /(命中 + 未命中),分母为 0 时按 0 处理。 + +
+ ); +} diff --git a/frontend/web/src/lib/api.ts b/frontend/web/src/lib/api.ts index ce41d7e..56dfe7b 100644 --- a/frontend/web/src/lib/api.ts +++ b/frontend/web/src/lib/api.ts @@ -75,6 +75,7 @@ export { adminListRag, adminListSessions, adminListUsers, + adminLlmUsage, adminMemorySearch, adminPluginInstall, adminPluginUninstall, diff --git a/frontend/web/src/lib/api/admin.ts b/frontend/web/src/lib/api/admin.ts index 6f17371..26fbda7 100644 --- a/frontend/web/src/lib/api/admin.ts +++ b/frontend/web/src/lib/api/admin.ts @@ -6,6 +6,7 @@ import type { AdminLlmConfig, AdminLlmProvider, AdminLlmResetResult, + AdminLlmUsage, AdminPluginRecord, AdminRagHit, AdminRagResponse, @@ -100,6 +101,20 @@ export function adminGetLlmCatalog(opts: { refresh?: boolean } = {}): Promise(`/admin/llm/catalog${qs}`); } +/** GET /api/v1/admin/llm/usage — LLM 用量汇总(owner|admin 读,契约 v0.64,切片 U1): + * 一块时间窗内的调用数 / 输入·输出 token / 缓存两档与命中率,并按用途与模型分解。 + * `since`/`until` 接受 RFC3339 或 Unix 毫秒;缺省窗口 = 近 24h,跨度上限 90 天 + * (超限后端回 400 bad_request,**不静默裁剪**)。响应里绝不含凭据与正文。 */ +export function adminLlmUsage( + params: { since?: string; until?: string; purpose?: string } = {} +): Promise { + const qs = new URLSearchParams(); + if (params.since) qs.set("since", params.since); + if (params.until) qs.set("until", params.until); + if (params.purpose) qs.set("purpose", params.purpose); + const q = qs.toString(); + return request(`/admin/llm/usage${q ? `?${q}` : ""}`); +} /** GET /api/v1/admin/automations */ export function adminListAutomations(): Promise { return request("/admin/automations"); diff --git a/frontend/web/src/lib/types/adminWrite.ts b/frontend/web/src/lib/types/adminWrite.ts index 315a719..4afacad 100644 --- a/frontend/web/src/lib/types/adminWrite.ts +++ b/frontend/web/src/lib/types/adminWrite.ts @@ -244,3 +244,43 @@ export interface AdminStorageStatus { documents: AdminStorageDocuments; vector: AdminStorageVector; } + +/* --- LLM 用量汇总(切片 U1,契约 v0.64)。字段名与 + * internal/services/gateway/admin_llm_usage.go 的 DTO 逐字对齐; + * **绝不含凭据、也绝不含消息正文**(那些东西根本没进 `llm_usage` 表)。 --- */ + +/** 一个时间窗(或一个分组)内的用量合计。`hitRate` = cacheHit / (cacheHit + + * cacheMiss),**分母为 0 时后端下发 0**(不编造其它值)。 */ +export interface AdminLlmUsageTotals { + calls: number; + prompt: number; + completion: number; + cacheHit: number; + cacheMiss: number; + hitRate: number; +} + +/** 本次生效的时间窗与用途过滤(缺省补齐后的真值;`purpose` 为空 = 未过滤)。 */ +export interface AdminLlmUsageWindow { + since: string; + until: string; + purpose: string; +} + +/** 按用途分解的一行。 */ +export interface AdminLlmUsagePurposeRow extends AdminLlmUsageTotals { + purpose: string; +} + +/** 按模型分解的一行。 */ +export interface AdminLlmUsageModelRow extends AdminLlmUsageTotals { + model: string; +} + +/** GET /api/v1/admin/llm/usage — LLM 用量汇总(owner|admin 读)。 */ +export interface AdminLlmUsage { + window: AdminLlmUsageWindow; + totals: AdminLlmUsageTotals; + byPurpose: AdminLlmUsagePurposeRow[]; + byModel: AdminLlmUsageModelRow[]; +} diff --git a/internal/config/config.go b/internal/config/config.go index 24ec55b..2919e0c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -92,6 +92,13 @@ type ServerConfig struct { // 「立即回收」,一次笔误不该把整库未引用附件清空。判据与动作见 // internal/runtime/documents/gc.go。 DocumentRetention time.Duration `env:"DOCUMENT_RETENTION" default:"168h"` + + // LLMUsageRetention 是 LLM 用量样本(`llm_usage` 表)的保留期(Go duration + // string),默认 720h = 30 天(切片 U1,主人裁决「2.4 落」)。**≤0 = 关闭 + // 清理**:0 同样刻意不解释成「立即清空」,而是「不清理、全留」——一次笔误 + // 不该把成本视图清空。清理在启动期跑一次,此后按固定间隔(见 + // cmd/cyrene/wire_usage.go)继续。 + LLMUsageRetention time.Duration `env:"LLM_USAGE_RETENTION" default:"720h"` } // AdminPathPrefix 返回管理面板前端路由的绝对路径前缀(默认 `/admin`)。 diff --git a/internal/db/migrations/0017_llm_usage.down.sql b/internal/db/migrations/0017_llm_usage.down.sql new file mode 100644 index 0000000..76d7bd9 --- /dev/null +++ b/internal/db/migrations/0017_llm_usage.down.sql @@ -0,0 +1,11 @@ +-- 0017_llm_usage down: 回滚用量表。 +-- +-- 回滚后的状态是「用量只落日志、库里查不到」的旧形态(切片 U1 之前):已记录的 +-- 历史样本随表一起丢弃,汇总端点(GET /api/v1/admin/llm/usage)随之报错 —— 这是 +-- 诚实的失败,而不是回一个全 0 的假汇总。丢弃是安全的:本表不被任何外键引用, +-- 运行时的写入路径(model.UsageWriter)也只在表存在时才有意义。 +DROP INDEX IF EXISTS idx_llm_usage_session_id; + +DROP INDEX IF EXISTS idx_llm_usage_created_at; + +DROP TABLE IF EXISTS llm_usage; diff --git a/internal/db/migrations/0017_llm_usage.up.sql b/internal/db/migrations/0017_llm_usage.up.sql new file mode 100644 index 0000000..1edd62c --- /dev/null +++ b/internal/db/migrations/0017_llm_usage.up.sql @@ -0,0 +1,46 @@ +-- 0017_llm_usage: 每次成功模型调用的用量落库(切片 U1,主人裁决「2.4 落」)。 +-- +-- 背景:T1 已在每次模型调用后记一条结构化日志(prompt / completion / total / +-- reasoning / cacheHit / cacheMiss / hitRate / tookMs),但**只落日志** —— +-- 看不见趋势、算不出按用途/模型的成本,面板也没有数据源。本表把同一条样本 +-- 落进库里,供只读汇总端点(GET /api/v1/admin/llm/usage)聚合。 +-- +-- 口径: +-- * **无凭据、无正文**:本表只有 token 计数、模型名、用途标签与耗时 —— +-- apiKey / token / 消息正文一个字都不入库(写入方 model.UsageWriter 连 +-- 参数都拿不到凭据)。 +-- * cache_hit_tokens / cache_miss_tokens 是**输入侧两档**(提供商按命中/未命中 +-- 两档计价,命中价约为未命中的 1/50),命中率 = hit / (hit + miss),分母为 0 +-- 时按 0 处理(口径与 model.Response.CacheHitRate 同源)。 +-- * session_id 可空。**当前尚无写入方**(恒为 NULL):模型适配器跑在 loop 之下、 +-- 没有会话概念(见 internal/runtime/model/stream.go 的 StreamEvent.SessionID +-- 注释),把会话标识带到适配器边界需要 loop 侧在请求上打标 —— 那份文件本轮 +-- 不在本切片改动范围内,故先留列、后接线。 +-- * created_at 由 SQLite 以 UTC ISO-8601(毫秒)写入,与 0001 起的口径一致; +-- 汇总端点的时间窗按同一格式做**字典序**比较(定宽格式下等价于时间序)。 +-- * 表名用复数,援引 AGENTS §10 已登记的例外(既有迁移口径一致)。 +CREATE TABLE IF NOT EXISTS llm_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT, + purpose TEXT NOT NULL DEFAULT '', + adapter TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + prompt_tokens INTEGER NOT NULL DEFAULT 0, + completion_tokens INTEGER NOT NULL DEFAULT 0, + cache_hit_tokens INTEGER NOT NULL DEFAULT 0, + cache_miss_tokens INTEGER NOT NULL DEFAULT 0, + reasoning_tokens INTEGER NOT NULL DEFAULT 0, + total_tokens INTEGER NOT NULL DEFAULT 0, + took_ms INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +-- 汇总端点与保留期清理都按时间窗扫表(`created_at >= ? AND created_at <= ?` / +-- `created_at < ?`),这条索引是两者的唯一依赖。 +CREATE INDEX IF NOT EXISTS idx_llm_usage_created_at + ON llm_usage (created_at); + +-- 按会话下钻(面板将来要「这个会话烧了多少」)。当前 session_id 恒为 NULL, +-- 索引仍然建好 —— 它不影响写入正确性,接线后立刻可用。 +CREATE INDEX IF NOT EXISTS idx_llm_usage_session_id + ON llm_usage (session_id); diff --git a/internal/runtime/model/anthropic.go b/internal/runtime/model/anthropic.go index ec6326a..9cdda01 100644 --- a/internal/runtime/model/anthropic.go +++ b/internal/runtime/model/anthropic.go @@ -133,6 +133,9 @@ type AnthropicChatAdapter struct { model string client *http.Client recorder Recorder + // usageSink 是可选的用量落库出口(切片 U1)。为 nil 时只记结构化日志, + // 行为逐字等于引入落库之前。 + usageSink UsageSink // log 上报推理调整与被忽略的指令。它永不为 nil: // 调用方未提供时,会装入一个带有本适配器来源标记的丢弃式日志器, // 因此提供商的每个决定都可追溯。 @@ -153,6 +156,12 @@ func WithAnthropicRecorder(r Recorder) AnthropicOption { return func(a *AnthropicChatAdapter) { a.recorder = r } } +// WithAnthropicUsageSink 装入用量落库出口(切片 U1)。nil 会被忽略,从而保留 +// 「只记日志」的默认行为;实现方必须非阻塞(见 UsageSink)。 +func WithAnthropicUsageSink(s UsageSink) AnthropicOption { + return func(a *AnthropicChatAdapter) { a.usageSink = s } +} + // WithAnthropicModel 覆盖适配器的默认模型名。 func WithAnthropicModel(m string) AnthropicOption { return func(a *AnthropicChatAdapter) { a.model = m } @@ -403,7 +412,7 @@ func (a *AnthropicChatAdapter) upstreamError(resp *http.Response) error { // record 上报一次成功调用的用量:先记一条结构化用量日志(含缓存命中率, // 所有调用点都覆盖 —— 见 usage.go),再喂给可选的 CallRecord 计账出口。 func (a *AnthropicChatAdapter) record(req Request, start time.Time, resp Response) { - logUsage(a.log, a.name, req, resp, time.Since(start)) + logUsage(a.log, a.usageSink, a.name, req, resp, time.Since(start)) if a.recorder == nil { return } diff --git a/internal/runtime/model/factory.go b/internal/runtime/model/factory.go index 57b300c..8814a4d 100644 --- a/internal/runtime/model/factory.go +++ b/internal/runtime/model/factory.go @@ -57,20 +57,26 @@ func NewChatChain(cfg config.LLMConfig) (*Chain, error) { // 配置好时才会追加;否则链保持两级形状,并 // 上报原因。 func NewChatChainWithLog(cfg config.LLMConfig, log *slog.Logger) (*Chain, error) { + return newChainWith(cfg, log, nil) +} + +// newChainWith 是 NewChatChainWithLog 加上用量 sink(切片 U1)。sink 为 nil 时 +// 逐字等于后者:适配器只记日志。组合根经 RuntimeLLM 注入真实写入器。 +func newChainWith(cfg config.LLMConfig, log *slog.Logger, sink UsageSink) (*Chain, error) { var chain *Chain var err error switch cfg.Provider { case ProviderOpenAI, "": - chain, err = newOpenAIChain(cfg, WithOpenAILogger(log)) + chain, err = newOpenAIChain(cfg, WithOpenAILogger(log), WithUsageSink(sink)) case ProviderAnthropic: - chain, err = newAnthropicChain(cfg, WithAnthropicLogger(log)) + chain, err = newAnthropicChain(cfg, WithAnthropicLogger(log), WithAnthropicUsageSink(sink)) default: return nil, fmt.Errorf("model: %w: provider %q", ErrUnknownProvider, cfg.Provider) } if err != nil { return nil, err } - if step, ok := localChainStep(cfg, log); ok { + if step, ok := localChainStep(cfg, log, sink); ok { chain.steps = append(chain.steps, step) } return chain, nil @@ -80,8 +86,8 @@ func NewChatChainWithLog(cfg config.LLMConfig, log *slog.Logger) (*Chain, error) // 当该级关闭、未配置或其提供商未知时,返回 ok=false // (若该级被要求但配置不全,则附一条点出缺失字段的 WARN) // ——一个半配置的步骤只会失败,那还不如没有 -// 它。 -func localChainStep(cfg config.LLMConfig, log *slog.Logger) (Step, bool) { +// 它。用量 sink 照旧透传:本地级也是模型调用,同样要计成本。 +func localChainStep(cfg config.LLMConfig, log *slog.Logger, sink UsageSink) (Step, bool) { local := cfg.Local // Configured() 本身已要求 Enabled,所以该级默认关闭, // 被禁用的级永远不会成为链步骤。只有当该级确实被 @@ -102,9 +108,9 @@ func localChainStep(cfg config.LLMConfig, log *slog.Logger) (Step, bool) { } switch local.Provider { case ProviderAnthropic: - return Step{Model: local.Model, Adapter: NewAnthropicChat(localCfg, WithAnthropicLogger(log))}, true + return Step{Model: local.Model, Adapter: NewAnthropicChat(localCfg, WithAnthropicLogger(log), WithAnthropicUsageSink(sink))}, true case ProviderOpenAI, "": - return Step{Model: local.Model, Adapter: NewOpenAIChat(localCfg, WithOpenAILogger(log))}, true + return Step{Model: local.Model, Adapter: NewOpenAIChat(localCfg, WithOpenAILogger(log), WithUsageSink(sink))}, true default: warn(log, "local model rung skipped: unknown provider", "provider", local.Provider, "field", "LLM_LOCAL_PROVIDER") diff --git a/internal/runtime/model/openai.go b/internal/runtime/model/openai.go index e300335..e4ce55b 100644 --- a/internal/runtime/model/openai.go +++ b/internal/runtime/model/openai.go @@ -40,6 +40,9 @@ type OpenAIChatAdapter struct { model string client *http.Client recorder Recorder + // usageSink 是可选的用量落库出口(切片 U1)。为 nil 时只记结构化日志, + // 行为逐字等于引入落库之前 —— 这也是所有既有测试与未接线调用方的形态。 + usageSink UsageSink // reasoningKind 是本端点已配置的推理开关形状 // (models.json 组的 reasoning.kind / LLM_REASONING_KIND,裁决 03.4.2/08.4.1)。 // 为空表示 "auto":按模型名/base URL 推断厂商, @@ -65,6 +68,12 @@ func WithRecorder(r Recorder) OpenAIOption { return func(a *OpenAIChatAdapter) { a.recorder = r } } +// WithUsageSink 装入用量落库出口(切片 U1)。nil 会被忽略,从而保留 +// 「只记日志」的默认行为;实现方必须非阻塞(见 UsageSink)。 +func WithUsageSink(s UsageSink) OpenAIOption { + return func(a *OpenAIChatAdapter) { a.usageSink = s } +} + // WithModel 覆盖适配器的默认模型名。 func WithModel(m string) OpenAIOption { return func(a *OpenAIChatAdapter) { a.model = m } @@ -233,7 +242,7 @@ func (a *OpenAIChatAdapter) upstreamError(resp *http.Response) error { // record 上报一次成功调用的用量:先记一条结构化用量日志(含缓存命中率, // 所有调用点都覆盖 —— 见 usage.go),再喂给可选的 CallRecord 计账出口。 func (a *OpenAIChatAdapter) record(req Request, start time.Time, resp Response) { - logUsage(a.log, a.name, req, resp, time.Since(start)) + logUsage(a.log, a.usageSink, a.name, req, resp, time.Since(start)) if a.recorder == nil { return } diff --git a/internal/runtime/model/runtime.go b/internal/runtime/model/runtime.go index 3b89d59..618b0a2 100644 --- a/internal/runtime/model/runtime.go +++ b/internal/runtime/model/runtime.go @@ -45,17 +45,41 @@ type RuntimeLLM struct { path string env config.LLMConfig log *slog.Logger + sink UsageSink current atomic.Value // 类型:*runtimeChainSnapshot } +// RuntimeOption 用于配置 RuntimeLLM。 +type RuntimeOption func(*RuntimeLLM) + +// WithRuntimeUsageSink 装入用量落库出口(切片 U1):它透传到本栈构建的**每一条** +// 链、每一步适配器(含按角色跨协议的链与本地第三级),因此任何一条路径上的模型 +// 调用都会被记入成本。nil 会被忽略,从而保留「只记日志」的默认行为。 +// +// 为什么 sink 挂在栈上而不是适配器上:适配器由本栈在**每次热重载**时重建,sink +// 必须是那段生命周期之外的稳定引用 —— 否则面板每保存一次 models.json,落库就 +// 静默断一次。 +func WithRuntimeUsageSink(sink UsageSink) RuntimeOption { + return func(m *RuntimeLLM) { + if sink != nil { + m.sink = sink + } + } +} + // NewRuntimeLLM 由 path 处的 models.json 构建提供商栈,并回退 // 到 env。它会加载初始快照,因此运行时立即可用。 // log 可为 nil(此时使用丢弃式日志器)。 -func NewRuntimeLLM(path string, env config.LLMConfig, log *slog.Logger) *RuntimeLLM { +func NewRuntimeLLM(path string, env config.LLMConfig, log *slog.Logger, opts ...RuntimeOption) *RuntimeLLM { if log == nil { log = slog.New(slog.NewTextHandler(io.Discard, nil)) } m := &RuntimeLLM{path: path, env: env, log: logger.WithSource(log, "model")} + for _, opt := range opts { + if opt != nil { + opt(m) + } + } m.loadInitial() return m } @@ -184,7 +208,7 @@ func (m *RuntimeLLM) swap(groups []runtimeconfig.Group) error { if len(groups) == 0 { groups = runtimeconfig.GroupFromEnv(m.env) } - snap, err := buildSnapshot(groups, m.env, m.log) + snap, err := buildSnapshot(groups, m.env, m.log, m.sink) if err != nil { return err } @@ -213,11 +237,11 @@ func (m *RuntimeLLM) load() *runtimeChainSnapshot { // (裁决 03.4.1)。它刻意不给 env 的视觉模型:图像路由 // 归 vision.Router 所有,而这里曾有的「带图 → 切换模型」规则绕过了 // 那个决策(裁决 14.4.1/14.4.5)。 -func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog.Logger) (*runtimeChainSnapshot, error) { +func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog.Logger, sink UsageSink) (*runtimeChainSnapshot, error) { active := groups[0] cfg := active.ToLLMConfig() cfg.Local = env.Local - chain, err := NewChatChainWithLog(cfg, log) + chain, err := newChainWith(cfg, log, sink) if err != nil && !errors.Is(err, ErrNoModel) { return nil, err } @@ -233,7 +257,7 @@ func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog router: router, group: active, groups: groups, - providerChains: personaProviderChains(groups, active, env, log), + providerChains: personaProviderChains(groups, active, env, log, sink), }, nil } @@ -248,7 +272,7 @@ func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog // 模型名经路由器到达它。personaProviders 中协议 // 没有对应组的条目会被上报并跳过——半构建的链只会 // 失败,该 persona 改为保留默认链。 -func personaProviderChains(groups []runtimeconfig.Group, active runtimeconfig.Group, env config.LLMConfig, log *slog.Logger) map[string]*Chain { +func personaProviderChains(groups []runtimeconfig.Group, active runtimeconfig.Group, env config.LLMConfig, log *slog.Logger, sink UsageSink) map[string]*Chain { if len(active.PersonaProviders) == 0 { return nil } @@ -272,7 +296,7 @@ func personaProviderChains(groups []runtimeconfig.Group, active runtimeconfig.Gr "persona", persona, "provider", provider) continue } - chain := personaChain(g, model, env, log) + chain := personaChain(g, model, env, log, sink) if chain != nil { out[provider] = chain } @@ -289,7 +313,7 @@ func personaProviderChains(groups []runtimeconfig.Group, active runtimeconfig.Gr // (reasoning.kind)随组的配置一起走,因此被路由到 // 另一厂商的 persona 仍保持该厂商的开关形状。它会报告原因, // 并在该组无法服务一条链时返回 nil。 -func personaChain(g runtimeconfig.Group, model string, env config.LLMConfig, log *slog.Logger) *Chain { +func personaChain(g runtimeconfig.Group, model string, env config.LLMConfig, log *slog.Logger, sink UsageSink) *Chain { cfg := g.ToLLMConfig() if cfg.Provider == ProviderAnthropic { cfg.AnthropicModel = model @@ -297,7 +321,7 @@ func personaChain(g runtimeconfig.Group, model string, env config.LLMConfig, log cfg.Model = model } cfg.Local = env.Local - chain, err := NewChatChainWithLog(cfg, log) + chain, err := newChainWith(cfg, log, sink) if err != nil && !errors.Is(err, ErrNoModel) { warn(log, "per-persona chain skipped", "group", g.ID, "model", model, "error", err) return nil diff --git a/internal/runtime/model/usage.go b/internal/runtime/model/usage.go index 294a267..6ef9fc7 100644 --- a/internal/runtime/model/usage.go +++ b/internal/runtime/model/usage.go @@ -16,32 +16,96 @@ import ( // 这样流式、非流式、后台思考、子代理、管理面板试连等**所有**模型调用点都覆盖到, // 不会有「换个调用入口就没了成本日志」的缺口。 // -// 红线:日志里只出现模型名、用途、token 数与耗时 —— **凭据(apiKey / token)绝不 -// 出现在这里**,它连参数都进不来。 +// 出口有**两条**,互不阻塞: +// +// 1. 结构化日志(本条一直如此,T1); +// 2. 落库(切片 U1)—— 经可选的 UsageSink 交给组合根注入的异步写入器。 +// sink 为 nil 时**只记日志、行为逐字不变**(既有测试因此零改动)。 +// +// 红线:日志与落库样本里只出现模型名、用途、token 数与耗时 —— **凭据(apiKey / +// token)与消息正文绝不出现**,它们连参数都进不来。 // usageLogEvent 是每条用量日志的事件名,便于按事件过滤与统计。 const usageLogEvent = "model usage" +// UsageSample 是**一次成功模型调用的用量样本**:既是那条结构化日志的字段来源, +// 也是落库写入(UsageSink)的载荷。 +// +// 它刻意只带非敏感计数与标签:凭据与正文不在字段集里,因此无论日志还是落库都 +// 没有把它们写出去的可能。 +type UsageSample struct { + // Adapter 是服务该次调用的适配器/提供商分组标识。 + Adapter string + // Model 是产出该响应的模型名。 + Model string + // Purpose 是调用用途标签(如 "chat" / "memory_extract" / "engage_judge")。 + Purpose string + // PromptTokens / CompletionTokens / TotalTokens 是提供商上报的用量。 + PromptTokens int + CompletionTokens int + TotalTokens int + // CacheHitTokens / CacheMissTokens 是**输入侧**的两档 token 数。 + CacheHitTokens int + CacheMissTokens int + // ReasoningTokens 是完成侧里属于思维链的 token 数。 + ReasoningTokens int + // TookMs 是端到端调用耗时(毫秒)。 + TookMs int64 +} + +// UsageSink 接收每次成功调用的用量样本。实现方必须**非阻塞**:它在模型调用的 +// 返回路径上被同步调用,任何等待都会把成本记到主人的每次对话上。 +// +// 组合根注入的 model.UsageWriter 满足它(有界队列 + 单写者,满则丢弃并限流告警)。 +// 为 nil = 不落库,只记日志 —— 这是「没接 sink」与「接了 sink」唯一的行为差别。 +type UsageSink interface { + // RecordUsage 收下一次调用的用量样本。实现方绝不阻塞、绝不 panic。 + RecordUsage(sample UsageSample) +} + +// usageSampleOf 把一次调用归一化成 UsageSample。它是日志与落库**唯一**的字段来源, +// 两处口径因此不可能分叉。 +func usageSampleOf(adapter string, req Request, resp Response, took time.Duration) UsageSample { + return UsageSample{ + Adapter: adapter, + Model: resp.Model, + Purpose: req.Purpose, + PromptTokens: resp.PromptTokens, + CompletionTokens: resp.CompletionTokens, + TotalTokens: resp.TotalTokens, + CacheHitTokens: resp.CacheHitTokens, + CacheMissTokens: resp.CacheMissTokens, + ReasoningTokens: resp.ReasoningTokens, + TookMs: took.Milliseconds(), + } +} + // logUsage 记一条结构化用量日志:prompt / completion / total / reasoning / -// cacheHit / cacheMiss / hitRate,外加模型名、用途、适配器与耗时。 +// cacheHit / cacheMiss / hitRate,外加模型名、用途、适配器与耗时;随后把同一个 +// 样本交给可选的 sink 落库。 // // log 可以为 nil(适配器在未接日志时用的是丢弃 logger),这里仍做一次防御: -// 缺字段容错、绝不 panic。 -func logUsage(log *slog.Logger, adapter string, req Request, resp Response, took time.Duration) { - if log == nil { +// 缺字段容错、绝不 panic。sink 为 nil 时**这一步是空操作** —— 逐字等于引入落库 +// 之前的行为。 +func logUsage(log *slog.Logger, sink UsageSink, adapter string, req Request, resp Response, took time.Duration) { + sample := usageSampleOf(adapter, req, resp, took) + if log != nil { + log.Info(usageLogEvent, + "adapter", sample.Adapter, + "model", sample.Model, + "purpose", sample.Purpose, + "prompt", sample.PromptTokens, + "completion", sample.CompletionTokens, + "total", sample.TotalTokens, + "reasoning", sample.ReasoningTokens, + "cacheHit", sample.CacheHitTokens, + "cacheMiss", sample.CacheMissTokens, + "hitRate", resp.CacheHitRate(), + "tookMs", sample.TookMs, + ) + } + if sink == nil { return } - log.Info(usageLogEvent, - "adapter", adapter, - "model", resp.Model, - "purpose", req.Purpose, - "prompt", resp.PromptTokens, - "completion", resp.CompletionTokens, - "total", resp.TotalTokens, - "reasoning", resp.ReasoningTokens, - "cacheHit", resp.CacheHitTokens, - "cacheMiss", resp.CacheMissTokens, - "hitRate", resp.CacheHitRate(), - "tookMs", took.Milliseconds(), - ) + sink.RecordUsage(sample) } diff --git a/internal/runtime/model/usage_query.go b/internal/runtime/model/usage_query.go new file mode 100644 index 0000000..9ce4326 --- /dev/null +++ b/internal/runtime/model/usage_query.go @@ -0,0 +1,239 @@ +package model + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +// ErrUsageUnavailable 表示用量汇总出口不可用(未接库 / 未构造读取器)。 +// 网关据此回 500 usage_unavailable,而不是回一份全 0 的假汇总。 +var ErrUsageUnavailable = errors.New("model: llm usage store unavailable") + +// 本文件是**用量落库的读取侧**(切片 U1):把 `llm_usage` 表在给定时间窗与用途 +// 过滤下聚合成一份只读汇总,供管理端点 `GET /api/v1/admin/llm/usage` 与面板的 +// 用量小卡消费。 +// +// 三条纪律: +// +// 1. **只读**:只有 SELECT(外加保留期清理的 DELETE,见 PurgeUsage),不建表、 +// 不迁移,复用组合根注入的活连接。 +// 2. **不含凭据**:查出来的字段只有 token 计数与模型/用途标签 —— 表里本来就没有 +// 凭据与正文,这条红线在写入侧就已经成立。 +// 3. **不编造**:读不到就返回错误,绝不用一份全 0 的假汇总假装成功(面板据此 +// 显示错误,而不是一个看着正常的假数字)。 + +const ( + // UsageDefaultWindow 是汇总的缺省时间窗(近 24 小时):不带 since/until 时 + // 「现在」往前推这么久。 + UsageDefaultWindow = 24 * time.Hour + // UsageMaxWindow 是一次汇总查询允许的最大时间窗(90 天)。超过它的请求由 + // 网关以 400 明确拒绝,**不静默裁剪** —— 静默裁剪会让客户端以为拿到的是 + // 全量,把「我没查那么久」误读成「就那么少」。 + UsageMaxWindow = 90 * 24 * time.Hour + // usageTimeLayout 与迁移 0017 的 `strftime('%Y-%m-%dT%H:%M:%fZ')` 逐字同形 + // (定宽 UTC ISO-8601,毫秒)。定宽使字典序比较等价于时间序,时间窗因此能 + // 直接用字符串比较、吃上 idx_llm_usage_created_at。 + usageTimeLayout = "2006-01-02T15:04:05.000Z" +) + +// UsageQuery 是一次汇总查询的条件。Since/Until 为零值时由调用方按缺省窗口补齐 +// (见 UsageDefaultWindow);Purpose 为空表示不过滤。 +type UsageQuery struct { + Since time.Time + Until time.Time + Purpose string +} + +// UsageTotals 是一个时间窗(或一个分组)内的用量合计。全部是**非敏感计数**。 +type UsageTotals struct { + // Calls 是窗口内成功调用次数。 + Calls int64 + // Prompt / Completion 是输入侧与输出侧 token 合计。 + Prompt int64 + Completion int64 + // CacheHit / CacheMiss 是输入侧两档 token 合计。 + CacheHit int64 + CacheMiss int64 +} + +// HitRate 返回缓存命中率 = cacheHit / (cacheHit + cacheMiss)。分母为 0(窗口内没有 +// 任何输入侧明细,或根本没有调用)时返回 0 —— 口径与 Response.CacheHitRate 同源, +// 不编造 100% 或 0% 以外的假值。 +func (t UsageTotals) HitRate() float64 { + total := t.CacheHit + t.CacheMiss + if total <= 0 { + return 0 + } + return float64(t.CacheHit) / float64(total) +} + +// UsageGroup 是按某个键(用途 / 模型)聚合的一行。 +type UsageGroup struct { + // Key 是分组键(purpose 或 model 的取值)。 + Key string + UsageTotals +} + +// UsageSummary 是一次汇总查询的结果。 +type UsageSummary struct { + // Window 是本次实际生效的时间窗与过滤条件(回显给客户端,而不是让它猜 + // 缺省值)。 + Window UsageQuery + // Totals 是整窗合计。 + Totals UsageTotals + // ByPurpose / ByModel 是按用途与按模型的分解(按调用次数降序,同次数按键 + // 升序 —— 输出确定,客户端可安全 diff)。 + ByPurpose []UsageGroup + ByModel []UsageGroup +} + +// UsageReader 是 `llm_usage` 表的只读出口。 +type UsageReader struct { + db *sql.DB +} + +// NewUsageReader 构造读取器;db 为 nil(未接库)时返回 nil。 +func NewUsageReader(db *sql.DB) *UsageReader { + if db == nil { + return nil + } + return &UsageReader{db: db} +} + +// usageRowScanner 是 *sql.Row 与 *sql.Rows 共有的扫码能力,使合计与分组两条 +// 查询路径共用同一个扫码函数。 +type usageRowScanner interface { + Scan(dest ...any) error +} + +// usageAggregateCols 是合计列清单(顺序与 scanUsageTotals 的扫码顺序逐字一致)。 +const usageAggregateCols = `COUNT(*), + COALESCE(SUM(prompt_tokens),0), + COALESCE(SUM(completion_tokens),0), + COALESCE(SUM(cache_hit_tokens),0), + COALESCE(SUM(cache_miss_tokens),0)` + +// Summary 聚合一个时间窗内的用量。q 的时间窗必须已由调用方校验并补齐(零值窗口 +// 会原样查库、什么都查不到)。 +func (r *UsageReader) Summary(ctx context.Context, q UsageQuery) (UsageSummary, error) { + if r == nil || r.db == nil { + return UsageSummary{}, fmt.Errorf("model: %w", ErrUsageUnavailable) + } + totals, err := r.totalsOf(ctx, q) + if err != nil { + return UsageSummary{}, err + } + byPurpose, err := r.groupsOf(ctx, q, usageGroupByPurpose) + if err != nil { + return UsageSummary{}, err + } + byModel, err := r.groupsOf(ctx, q, usageGroupByModel) + if err != nil { + return UsageSummary{}, err + } + return UsageSummary{Window: q, Totals: totals, ByPurpose: byPurpose, ByModel: byModel}, nil +} + +// totalsOf 读整窗合计。空窗时 SUM 为 NULL,COALESCE 收敛成 0;表不存在(迁移没跑) +// 则直接报错,不静默报 0。 +func (r *UsageReader) totalsOf(ctx context.Context, q UsageQuery) (UsageTotals, error) { + query := `SELECT ` + usageAggregateCols + ` FROM llm_usage WHERE created_at >= ? AND created_at <= ?` + args := []any{usageTime(q.Since), usageTime(q.Until)} + if q.Purpose != "" { + query += ` AND purpose = ?` + args = append(args, q.Purpose) + } + totals, err := scanUsageTotals(r.db.QueryRowContext(ctx, query, args...)) + if err != nil { + return UsageTotals{}, fmt.Errorf("model: read usage totals: %w", err) + } + return totals, nil +} + +// usageGroupBy* 是允许的分组列:它们是代码常量,**不是**用户输入(分组列无法参数化, +// 因此这里以白名单常量而非拼接来保证不注入)。 +const ( + usageGroupByPurpose = "purpose" + usageGroupByModel = "model" +) + +// groupsOf 按 groupBy 列聚合。groupBy 只接受 usageGroupBy* 常量,未知值一律报错 +// (绝不把任意字符串拼进 SQL)。 +func (r *UsageReader) groupsOf(ctx context.Context, q UsageQuery, groupBy string) ([]UsageGroup, error) { + if groupBy != usageGroupByPurpose && groupBy != usageGroupByModel { + return nil, fmt.Errorf("model: %w: unknown usage group %q", ErrBadRequest, groupBy) + } + query := `SELECT ` + groupBy + `, ` + usageAggregateCols + + ` FROM llm_usage WHERE created_at >= ? AND created_at <= ?` + args := []any{usageTime(q.Since), usageTime(q.Until)} + if q.Purpose != "" { + query += ` AND purpose = ?` + args = append(args, q.Purpose) + } + query += ` GROUP BY ` + groupBy + ` ORDER BY COUNT(*) DESC, ` + groupBy + ` ASC` + + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("model: read usage groups by %s: %w", groupBy, err) + } + defer rows.Close() + + out := make([]UsageGroup, 0, 8) + for rows.Next() { + group, err := scanUsageGroup(rows) + if err != nil { + return nil, fmt.Errorf("model: scan usage group by %s: %w", groupBy, err) + } + out = append(out, group) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("model: iterate usage groups by %s: %w", groupBy, err) + } + return out, nil +} + +// scanUsageTotals 按 usageAggregateCols 的顺序扫码。 +func scanUsageTotals(sc usageRowScanner) (UsageTotals, error) { + var t UsageTotals + if err := sc.Scan(&t.Calls, &t.Prompt, &t.Completion, &t.CacheHit, &t.CacheMiss); err != nil { + return UsageTotals{}, err + } + return t, nil +} + +// scanUsageGroup 扫码「分组键 + 合计列」。 +func scanUsageGroup(sc usageRowScanner) (UsageGroup, error) { + var key string + var t UsageTotals + if err := sc.Scan(&key, &t.Calls, &t.Prompt, &t.Completion, &t.CacheHit, &t.CacheMiss); err != nil { + return UsageGroup{}, err + } + return UsageGroup{Key: key, UsageTotals: t}, nil +} + +// PurgeUsage 删除 `created_at` 早于 before 的用量行,返回删除条数。db 为 nil 时是 +// 空操作。它与保留期清理(组合根按 LLM_USAGE_RETENTION 定时调用)是唯一会写这张 +// 表、又不是追加样本的语句。 +func PurgeUsage(ctx context.Context, db *sql.DB, before time.Time) (int64, error) { + if db == nil { + return 0, nil + } + res, err := db.ExecContext(ctx, `DELETE FROM llm_usage WHERE created_at < ?`, usageTime(before)) + if err != nil { + return 0, fmt.Errorf("model: purge llm usage: %w", err) + } + removed, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("model: purge llm usage rows affected: %w", err) + } + return removed, nil +} + +// usageTime 把时间点转成与迁移 0017 的 DEFAULT 逐字同形的 UTC 字符串(定宽毫秒), +// 使时间窗比较是纯字符串比较、且能走 idx_llm_usage_created_at。 +func usageTime(t time.Time) string { + return t.UTC().Format(usageTimeLayout) +} diff --git a/internal/runtime/model/usage_store.go b/internal/runtime/model/usage_store.go new file mode 100644 index 0000000..4796311 --- /dev/null +++ b/internal/runtime/model/usage_store.go @@ -0,0 +1,258 @@ +package model + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "sync" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" +) + +// 本文件是**用量落库的写入侧**(切片 U1,主人裁决「2.4 落」):一个有界队列 + +// 单写者 + 批量事务的异步写入器。 +// +// 为什么不是「在 logUsage 里直接 INSERT」:那条语句跑在**每一次模型调用的返回 +// 路径**上,库慢一点、锁一下,主人的每次对话就跟着慢一点 —— 记成本的开销绝不 +// 能加到被记成本的那次调用上。这里因此把两者彻底解耦: +// +// 1. `RecordUsage` 只往**有界** channel 里塞一个样本,塞不进就**丢弃并限流 +// WARN**(绝不阻塞、绝不 panic、绝不无界增长); +// 2. 一个常驻单写者把队列攒成批次,在一个事务里落库; +// 3. 整批失败只记一条带来源的 WARN(**不静默**),调用路径毫无感知。 +// +// 写入样本里没有凭据、没有正文(见 UsageSample),这条红线在类型层面就成立。 + +// usageSource 是本文件每条日志的来源标签(跨模块可追溯定位,项目规范 §10)。 +const usageSource = "model/usage_store" + +const ( + // usageQueueCapacity 是落库队列的容量。它是**有界**的:满了就丢弃, + // 而不是把模型调用拖住。 + usageQueueCapacity = 1024 + // usageBatchMax 是单次批量事务最多写入的行数。 + usageBatchMax = 128 + // usageFlushInterval 是队列非空时的最长等待:低频调用不会因为没攒满一批 + // 就一直不落库(面板看得到的是近 24h,不该差这几十秒)。 + usageFlushInterval = 2 * time.Second + // usageDropWarnInterval 是「队列满、已丢弃样本」告警的限流间隔:真实部署里 + // 库挂了会让每一次调用都丢弃,不限流就会把日志刷爆。 + usageDropWarnInterval = 30 * time.Second + // usageFinalFlushTimeout 是停止时把队列剩余样本写完的期限。 + usageFinalFlushTimeout = 5 * time.Second +) + +// insertUsageSQL 写入一条用量样本。**session_id 当前恒为 NULL**(模型适配器跑在 +// loop 之下、没有会话概念;见迁移 0017 的说明),列留着是为了接线时只改这一处。 +const insertUsageSQL = `INSERT INTO llm_usage + (session_id, purpose, adapter, model, prompt_tokens, completion_tokens, + cache_hit_tokens, cache_miss_tokens, reasoning_tokens, total_tokens, took_ms) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + +// UsageWriter 把用量样本异步落进 `llm_usage`。它实现 UsageSink,因此在模型适配器 +// 里就是一个「可选出口」:注入即有、不注入则只记日志。 +// +// 构造后必须 Start 才会真的落库;未 Start 时队列照样收、塞满后照样丢弃并告警 +// (不静默失败)。Stop 必须在关库之前调用。 +type UsageWriter struct { + db *sql.DB + log *slog.Logger + + queue chan UsageSample + done chan struct{} + + mu sync.Mutex + cancel context.CancelFunc + started bool + dropped int64 + lastWarn time.Time +} + +// NewUsageWriter 构造写入器。db 为 nil(未接库)时返回 nil —— 调用方据此把 +// 「无 sink」原样传给适配器,行为等于这一版之前。 +func NewUsageWriter(db *sql.DB, log *slog.Logger) *UsageWriter { + if db == nil { + return nil + } + if log == nil { + log = discardLogger(usageSource) + } + return &UsageWriter{ + db: db, + log: logger.WithSource(log, usageSource), + queue: make(chan UsageSample, usageQueueCapacity), + done: make(chan struct{}), + } +} + +// RecordUsage 实现 UsageSink:把样本塞进有界队列,塞不进就丢弃并(限流)告警。 +// +// 它**绝不阻塞**:整个函数是一次非阻塞 channel 发送加一次可能的加锁自增, +// 因此模型调用路径的额外开销与库的健康状况无关。 +func (w *UsageWriter) RecordUsage(sample UsageSample) { + if w == nil { + return + } + select { + case w.queue <- sample: + default: + w.noteDrop() + } +} + +// Start 拉起单写者循环。重复调用是空操作;ctx 取消(或 Stop)后循环退出。 +func (w *UsageWriter) Start(ctx context.Context) { + if w == nil { + return + } + w.mu.Lock() + defer w.mu.Unlock() + if w.started { + return + } + w.started = true + runCtx, cancel := context.WithCancel(ctx) + w.cancel = cancel + go w.loop(runCtx) +} + +// Stop 停掉循环并等它退出,退出前尽力把队列里剩下的样本写完。未启动时是空操作。 +// 它必须在关库**之前**调用(收尾写入要用那条连接池)。 +func (w *UsageWriter) Stop() { + if w == nil { + return + } + w.mu.Lock() + cancel := w.cancel + started := w.started + w.mu.Unlock() + if !started || cancel == nil { + return + } + cancel() + <-w.done +} + +// loop 是单写者:攒批 → 批量事务落库,直到 ctx 取消。 +func (w *UsageWriter) loop(ctx context.Context) { + defer close(w.done) + ticker := time.NewTicker(usageFlushInterval) + defer ticker.Stop() + batch := make([]UsageSample, 0, usageBatchMax) + flush := func() { + if len(batch) == 0 { + return + } + w.writeBatch(ctx, batch) + batch = batch[:0] + } + for { + select { + case <-ctx.Done(): + // 收尾用**脱离取消**的上下文:ctx 已经取消,沿用它会让最后一批 + // 必然写失败,等于丢掉已经收下的样本。 + w.flushFinal(drainQueue(w.queue, batch)) + return + case sample := <-w.queue: + batch = append(batch, sample) + if len(batch) >= usageBatchMax { + flush() + } + case <-ticker.C: + flush() + } + } +} + +// flushFinal 在停机路径上把剩余样本写完(有期限,绝不无限等库)。 +func (w *UsageWriter) flushFinal(remaining []UsageSample) { + if len(remaining) == 0 { + return + } + ctx, cancel := context.WithTimeout(context.Background(), usageFinalFlushTimeout) + defer cancel() + w.writeBatch(ctx, remaining) +} + +// writeBatch 在一个事务里落一批样本。失败只记 WARN(带批次大小与原因), +// 绝不上抛 —— 调用路径早已返回,没有任何人在等这个结果。 +func (w *UsageWriter) writeBatch(ctx context.Context, batch []UsageSample) { + if err := insertUsageBatch(ctx, w.db, batch); err != nil { + w.log.Warn("llm usage batch insert failed; batch dropped", "count", len(batch), "err", err) + } +} + +// noteDrop 记一次「队列满、样本被丢弃」,并按 usageDropWarnInterval 限流告警。 +// 丢弃计数是累计值,因此告警里能看到这一段到底丢了多少条。 +func (w *UsageWriter) noteDrop() { + w.mu.Lock() + w.dropped++ + dropped := w.dropped + now := time.Now() + shouldWarn := w.lastWarn.IsZero() || now.Sub(w.lastWarn) >= usageDropWarnInterval + if shouldWarn { + w.lastWarn = now + } + w.mu.Unlock() + if shouldWarn { + w.log.Warn("llm usage queue full; samples dropped (never blocks the model call)", + "dropped", dropped, "capacity", usageQueueCapacity) + } +} + +// drainQueue 把队列里当前排着的样本全部取出(不阻塞),追加到 dst 之后返回。 +func drainQueue(queue chan UsageSample, dst []UsageSample) []UsageSample { + for { + select { + case sample := <-queue: + dst = append(dst, sample) + default: + return dst + } + } +} + +// insertUsageBatch 在一个事务里写入一批样本:单条 prepared statement 循环执行, +// 一次提交。用 prepared statement 而不是多值 INSERT,是为了避开各家 SQLite 对 +// 绑定参数个数的不同上限(11 列 × 128 行)。 +func insertUsageBatch(ctx context.Context, db *sql.DB, batch []UsageSample) error { + if db == nil || len(batch) == 0 { + return nil + } + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin usage batch: %w", err) + } + defer tx.Rollback() + + stmt, err := tx.PrepareContext(ctx, insertUsageSQL) + if err != nil { + return fmt.Errorf("prepare usage insert: %w", err) + } + defer stmt.Close() + + for i, sample := range batch { + // session_id 传 NULL:见 insertUsageSQL 的说明。 + if _, err := stmt.ExecContext(ctx, + nil, + sample.Purpose, + sample.Adapter, + sample.Model, + sample.PromptTokens, + sample.CompletionTokens, + sample.CacheHitTokens, + sample.CacheMissTokens, + sample.ReasoningTokens, + sample.TotalTokens, + sample.TookMs, + ); err != nil { + return fmt.Errorf("insert usage sample %d: %w", i, err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit usage batch: %w", err) + } + return nil +} diff --git a/internal/runtime/model/usage_store_test.go b/internal/runtime/model/usage_store_test.go new file mode 100644 index 0000000..0f92f47 --- /dev/null +++ b/internal/runtime/model/usage_store_test.go @@ -0,0 +1,349 @@ +package model + +import ( + "context" + "database/sql" + "net/http" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/db" +) + +// 本文件钉住 LLM 用量**落库**(切片 U1): +// - sink 为 nil ⇒ 行为逐字不变(只记日志); +// - sink 非 nil ⇒ 样本抵达写入器,字段与日志同源; +// - 队列满 ⇒ 丢弃 + 限流 WARN,**绝不阻塞**模型调用路径; +// - 批量落库后读回,命中率按 cacheHit / (cacheHit + cacheMiss) 算出。 + +// usageSinkProbeTimeout 是「RecordUsage 不许阻塞」的判定预算:真实现是微秒级, +// 这里给 5 秒是为了让判定只对「真的在等」敏感,不对机器忙敏感。 +const usageSinkProbeTimeout = 5 * time.Second + +// usageTestDB 打开一个带全部迁移的内存库(`:memory:` 每个句柄一份私有库, +// 并行测试天然隔离)。 +func usageTestDB(t *testing.T) *sql.DB { + t.Helper() + conn, err := db.Open(context.Background(), ":memory:", discardLogger("model/usage_test")) + if err != nil { + t.Fatalf("open in-memory db: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + if err := db.MigrateUp(context.Background(), conn, discardLogger("model/usage_test")); err != nil { + t.Fatalf("migrate: %v", err) + } + return conn +} + +// fakeUsageSink 捕获被投递的样本,用于断言 sink ↔ logUsage 调用点的字段一致。 +type fakeUsageSink struct { + samples []UsageSample +} + +func (f *fakeUsageSink) RecordUsage(sample UsageSample) { f.samples = append(f.samples, sample) } + +// TestUsageSink_NilKeepsLogOnlyBehavior 钉住「不注入 sink ⇒ 行为逐字不变」: +// 适配器照常完成调用,且**恰好**记一条结构化用量日志。 +func TestUsageSink_NilKeepsLogOnlyBehavior(t *testing.T) { + t.Parallel() + srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, nil) + log, buf := captureJSONLog() + ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "gpt-test"}, WithOpenAILogger(log)) + + resp, err := ad.Chat(context.Background(), Request{ + Purpose: "chat", + Messages: []Message{TextMessage(RoleUser, "hi")}, + }, Options{}) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if resp.Content != "Hello!" { + t.Fatalf("正文受落库改动影响: %q", resp.Content) + } + // 没有 sink 时也恰好一条用量日志(多一条或少一条都会在这里变红)。 + rec := usageLogRecord(t, buf.String()) + if rec["purpose"] != "chat" || rec["prompt"] == nil || rec["cacheMiss"] == nil { + t.Fatalf("用量日志字段缺失:%v", rec) + } +} + +// TestUsageSink_ReceivesSameFieldsAsLog 钉住 sink ↔ 既有 logUsage 调用点的成对 +// 契约:同一个样本既进日志、也进 sink,字段逐项一致(两处口径不分叉)。 +func TestUsageSink_ReceivesSameFieldsAsLog(t *testing.T) { + t.Parallel() + body := `{ + "id": "cmpl-1", "model": "deepseek-chat", + "choices": [{"index":0,"message":{"role":"assistant","content":"你好"},"finish_reason":"stop"}], + "usage": {"prompt_tokens": 1000, "completion_tokens": 200, "total_tokens": 1200, + "prompt_cache_hit_tokens": 900, "prompt_cache_miss_tokens": 100, + "completion_tokens_details": {"reasoning_tokens": 150}} + }` + srv := newCompletionServer(t, http.StatusOK, "application/json", body, nil) + sink := &fakeUsageSink{} + ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "deepseek-chat"}, WithUsageSink(sink)) + + if _, err := ad.Chat(context.Background(), Request{ + Purpose: "chat", + Messages: []Message{TextMessage(RoleUser, "hi")}, + }, Options{}); err != nil { + t.Fatalf("Chat: %v", err) + } + if len(sink.samples) != 1 { + t.Fatalf("sink 收到 %d 条样本, want 1", len(sink.samples)) + } + got := sink.samples[0] + want := UsageSample{ + Adapter: ad.Name(), + Model: "deepseek-chat", + Purpose: "chat", + PromptTokens: 1000, + CompletionTokens: 200, + TotalTokens: 1200, + CacheHitTokens: 900, + CacheMissTokens: 100, + ReasoningTokens: 150, + } + got.TookMs = 0 // 耗时不做等值断言(它是真实的墙钟量) + if got != want { + t.Fatalf("sink 样本 = %+v, want %+v", got, want) + } +} + +// TestUsageWriter_QueueFullDropsWithoutBlocking 钉住「写入绝不阻塞模型调用」: +// 队列满时再投递只丢弃(并限流 WARN),投递方**不等待**。 +// +// 写入器刻意不 Start(没有消费者),队列因此停在满的状态 —— 这正是真实部署里 +// 库卡住/变慢时的形态。 +func TestUsageWriter_QueueFullDropsWithoutBlocking(t *testing.T) { + t.Parallel() + conn := usageTestDB(t) + log, buf := captureJSONLog() + writer := NewUsageWriter(conn, log) + if writer == nil { + t.Fatal("NewUsageWriter 在接了库时不该返回 nil") + } + sample := UsageSample{Adapter: "openai", Model: "m", Purpose: "chat", PromptTokens: 1} + + for i := 0; i < usageQueueCapacity; i++ { + writer.RecordUsage(sample) + } + if got := len(writer.queue); got != usageQueueCapacity { + t.Fatalf("队列长度 = %d, want 满 %d", got, usageQueueCapacity) + } + + const overflow = 64 + delivered := make(chan struct{}) + go func() { + for i := 0; i < overflow; i++ { + writer.RecordUsage(sample) + } + close(delivered) + }() + select { + case <-delivered: + case <-time.After(usageSinkProbeTimeout): + t.Fatal("队列满时 RecordUsage 阻塞了 —— 它必须丢弃而不能等") + } + + // 丢弃不许静默:一条限流 WARN(后续丢弃被 30s 窗口压住,不刷屏), + // 且累计丢弃数是**全部**溢出条数。 + raw := buf.String() + if !strings.Contains(raw, "llm usage queue full") { + t.Fatalf("队列满没有告警(不静默的红线):%s", raw) + } + if got := strings.Count(raw, "llm usage queue full"); got != 1 { + t.Fatalf("限流告警记了 %d 条, want 1(否则库一挂就把日志刷爆):%s", got, raw) + } + if writer.dropped != overflow { + t.Fatalf("累计丢弃 = %d, want %d", writer.dropped, overflow) + } + // 没有 Start ⇒ 一条都没落库(丢弃是真的丢弃,不是「攒着」)。 + if rows := usageRowCount(t, conn); rows != 0 { + t.Fatalf("未启动写入器却落了 %d 行", rows) + } +} + +// TestUsageWriter_PersistsBatchAndReaderSummarizes 钉住「批量写入 → 读回」: +// Stop 会把队列里剩下的样本在一个事务里写完,读侧按时间窗聚合,命中率按 +// cacheHit / (cacheHit + cacheMiss) 算出。 +func TestUsageWriter_PersistsBatchAndReaderSummarizes(t *testing.T) { + t.Parallel() + ctx := context.Background() + conn := usageTestDB(t) + log, buf := captureJSONLog() + writer := NewUsageWriter(conn, log) + writer.Start(ctx) + + samples := []UsageSample{ + {Adapter: "openai", Model: "deepseek-chat", Purpose: "chat", + PromptTokens: 1000, CompletionTokens: 200, TotalTokens: 1200, + CacheHitTokens: 900, CacheMissTokens: 100, TookMs: 50}, + {Adapter: "openai", Model: "deepseek-chat", Purpose: "chat", + PromptTokens: 1000, CompletionTokens: 100, TotalTokens: 1100, + CacheHitTokens: 0, CacheMissTokens: 1000, TookMs: 70}, + {Adapter: "openai", Model: "gpt-x", Purpose: "memory_extract", + PromptTokens: 500, CompletionTokens: 50, TotalTokens: 550, + CacheHitTokens: 500, CacheMissTokens: 0, TookMs: 30}, + } + for _, sample := range samples { + writer.RecordUsage(sample) + } + writer.Stop() + + if rows := usageRowCount(t, conn); rows != int64(len(samples)) { + t.Fatalf("落库行数 = %d, want %d(日志:%s)", rows, len(samples), buf.String()) + } + // session_id 当前恒为 NULL(模型层无会话概念):列在、但序列化面已说明。 + var nullSessions int64 + if err := conn.QueryRowContext(ctx, + `SELECT COUNT(*) FROM llm_usage WHERE session_id IS NULL`).Scan(&nullSessions); err != nil { + t.Fatalf("count null session_id: %v", err) + } + if nullSessions != int64(len(samples)) { + t.Fatalf("session_id 为 NULL 的行 = %d, want %d", nullSessions, len(samples)) + } + + until := time.Now().Add(time.Minute) + summary, err := NewUsageReader(conn).Summary(ctx, UsageQuery{Since: until.Add(-time.Hour), Until: until}) + if err != nil { + t.Fatalf("Summary: %v", err) + } + wantTotals := UsageTotals{Calls: 3, Prompt: 2500, Completion: 350, CacheHit: 1400, CacheMiss: 1100} + if summary.Totals != wantTotals { + t.Fatalf("totals = %+v, want %+v", summary.Totals, wantTotals) + } + if got := summary.Totals.HitRate(); got != 0.56 { + t.Fatalf("hitRate = %v, want 0.56(1400/2500)", got) + } + if len(summary.ByPurpose) != 2 || summary.ByPurpose[0].Key != "chat" { + t.Fatalf("byPurpose = %+v, want chat 在前(2 次调用)", summary.ByPurpose) + } + if summary.ByPurpose[0].Calls != 2 || summary.ByPurpose[0].HitRate() != 0.45 { + t.Fatalf("byPurpose[0] = %+v, want calls 2 / hitRate 0.45", summary.ByPurpose[0]) + } + if summary.ByPurpose[1].Key != "memory_extract" || summary.ByPurpose[1].HitRate() != 1 { + t.Fatalf("byPurpose[1] = %+v, want memory_extract / hitRate 1", summary.ByPurpose[1]) + } + if len(summary.ByModel) != 2 || summary.ByModel[0].Key != "deepseek-chat" || summary.ByModel[0].Calls != 2 { + t.Fatalf("byModel = %+v, want deepseek-chat 在前(2 次调用)", summary.ByModel) + } + // 空窗(无调用)不是错误:零合计 + 命中率 0(分母 0 的口径)。 + empty, err := NewUsageReader(conn).Summary(ctx, UsageQuery{ + Since: until.Add(-48 * time.Hour), Until: until.Add(-47 * time.Hour), + }) + if err != nil { + t.Fatalf("Summary(empty): %v", err) + } + if empty.Totals != (UsageTotals{}) || empty.Totals.HitRate() != 0 { + t.Fatalf("空窗 = %+v, want 全零且 hitRate 0", empty.Totals) + } + if len(empty.ByPurpose) != 0 || len(empty.ByModel) != 0 { + t.Fatalf("空窗不该有分组行:%+v / %+v", empty.ByPurpose, empty.ByModel) + } +} + +// TestUsageReader_WindowAndPurposeFilter 钉住时间窗与用途过滤真的生效(写入侧 +// 逐行给定 created_at,因此窗口边界是确定的,不靠墙钟)。 +func TestUsageReader_WindowAndPurposeFilter(t *testing.T) { + t.Parallel() + ctx := context.Background() + conn := usageTestDB(t) + insertUsageRow(t, conn, "chat", "m1", "2026-10-01T00:00:00.000Z", 100, 10, 90, 10) + insertUsageRow(t, conn, "chat", "m1", "2026-10-03T00:00:00.000Z", 200, 20, 180, 20) + insertUsageRow(t, conn, "noise_review", "m2", "2026-10-03T00:00:00.000Z", 40, 4, 0, 40) + + reader := NewUsageReader(conn) + day := func(iso string) time.Time { + parsed, err := time.Parse(time.RFC3339, iso) + if err != nil { + t.Fatalf("parse %s: %v", iso, err) + } + return parsed + } + + // 只覆盖第二天 ⇒ 两条算进来(m1 的 200 与 noise_review 的 40)。 + all, err := reader.Summary(ctx, UsageQuery{ + Since: day("2026-10-02T00:00:00Z"), + Until: day("2026-10-04T00:00:00Z"), + }) + if err != nil { + t.Fatalf("Summary(window): %v", err) + } + if all.Totals.Calls != 2 || all.Totals.Prompt != 240 { + t.Fatalf("窗口内合计 = %+v, want calls 2 / prompt 240", all.Totals) + } + // 同窗口 + purpose 过滤 ⇒ 只剩 chat 那条。 + chatOnly, err := reader.Summary(ctx, UsageQuery{ + Since: day("2026-10-02T00:00:00Z"), + Until: day("2026-10-04T00:00:00Z"), + Purpose: "chat", + }) + if err != nil { + t.Fatalf("Summary(purpose): %v", err) + } + if chatOnly.Totals.Calls != 1 || chatOnly.Totals.Prompt != 200 || chatOnly.ByPurpose[0].Key != "chat" { + t.Fatalf("purpose 过滤结果 = %+v, want 只剩 chat 的 200", chatOnly.Totals) + } + // 全窗(含第一天)⇒ 三条。 + full, err := reader.Summary(ctx, UsageQuery{ + Since: day("2026-09-30T00:00:00Z"), + Until: day("2026-10-05T00:00:00Z"), + }) + if err != nil { + t.Fatalf("Summary(full): %v", err) + } + if full.Totals.Calls != 3 || full.Totals.CacheHit != 270 || full.Totals.CacheMiss != 70 { + t.Fatalf("全窗合计 = %+v, want calls 3 / hit 270 / miss 70", full.Totals) + } +} + +// TestPurgeUsageRemovesExpiredOnly 钉住保留期清理的判据:早于 before 的删掉、 +// 之后的原样保留(before 恰好等于某行的时刻时保留 —— 判据是**严格早于**)。 +func TestPurgeUsageRemovesExpiredOnly(t *testing.T) { + t.Parallel() + ctx := context.Background() + conn := usageTestDB(t) + insertUsageRow(t, conn, "chat", "m1", "2026-09-01T00:00:00.000Z", 10, 1, 5, 5) + insertUsageRow(t, conn, "chat", "m1", "2026-10-04T00:00:00.000Z", 20, 2, 10, 10) + + cutoff, err := time.Parse(time.RFC3339, "2026-10-04T00:00:00Z") + if err != nil { + t.Fatalf("parse cutoff: %v", err) + } + removed, err := PurgeUsage(ctx, conn, cutoff) + if err != nil { + t.Fatalf("PurgeUsage: %v", err) + } + if removed != 1 { + t.Fatalf("删除 %d 行, want 1(只删严格早于 cutoff 的那条)", removed) + } + if rows := usageRowCount(t, conn); rows != 1 { + t.Fatalf("剩余行数 = %d, want 1", rows) + } +} + +// insertUsageRow 直接写一行样本,created_at 由调用方给定(窗口/清理用例靠它把 +// 时间钉死,不依赖墙钟)。 +func insertUsageRow(t *testing.T, conn *sql.DB, purpose, model, createdAt string, prompt, completion, hit, miss int) { + t.Helper() + if _, err := conn.ExecContext(context.Background(), + `INSERT INTO llm_usage + (purpose, adapter, model, prompt_tokens, completion_tokens, + cache_hit_tokens, cache_miss_tokens, total_tokens, took_ms, created_at) + VALUES (?, 'openai', ?, ?, ?, ?, ?, ?, 0, ?)`, + purpose, model, prompt, completion, hit, miss, prompt+completion, createdAt); err != nil { + t.Fatalf("insert usage row: %v", err) + } +} + +// usageRowCount 返回 llm_usage 的当前行数。 +func usageRowCount(t *testing.T, conn *sql.DB) int64 { + t.Helper() + var rows int64 + if err := conn.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM llm_usage`).Scan(&rows); err != nil { + t.Fatalf("count llm_usage: %v", err) + } + return rows +} diff --git a/internal/services/gateway/admin_llm_usage.go b/internal/services/gateway/admin_llm_usage.go new file mode 100644 index 0000000..93d78e6 --- /dev/null +++ b/internal/services/gateway/admin_llm_usage.go @@ -0,0 +1,203 @@ +package gateway + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "time" + + apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// LLM 用量汇总**只读**端点(GET /api/v1/admin/llm/usage,切片 U1,主人裁决 +// 「2.4 落」;契约 v0.64)。面板的「用量」小卡(近 24h 掉用数 / 输入 token / +// 缓存命中率)与将来的成本视图都以它为数源。 +// +// 四条纪律: +// +// 1. **只读**:只有 SELECT(汇总)—— 不写、不迁移,复用组合根注入的活连接 +// (见 Deps.Usage)。 +// 2. **不回显凭据**:响应里只有 token 计数与模型/用途标签 —— `llm_usage` 表里 +// 根本没有凭据与消息正文,因此没有任何「回显 DSN/apiKey」的代码路径。 +// 3. **不猜**:没接汇总出口、或查库失败,一律 500 + 明确 code,绝不回一份 +// 全 0 的假汇总(面板据此显示错误,而不是一个看着正常的假数字)。 +// 4. **不静默裁剪窗口**:`since`/`until` 跨度超过 90 天时回 400 并点名上限 —— +// 静默裁剪会让客户端把「我没查那么久」误读成「就那么少」。 +const llmUsageLogSource = "gateway/admin_llm_usage" + +// llmUsageWindow 回显本次生效的时间窗与用途过滤(缺省值补齐后的真值,客户端 +// 不必自己猜)。`purpose` 为空 = 未过滤。 +type llmUsageWindow struct { + Since string `json:"since"` + Until string `json:"until"` + Purpose string `json:"purpose"` +} + +// llmUsageTotals 是一个窗口/分组内的用量合计。`hitRate` = cacheHit / +// (cacheHit + cacheMiss),**分母为 0 时下发 0**(口径与 model.UsageTotals.HitRate +// 同源,绝不下发 NaN/Inf)。 +type llmUsageTotals struct { + Calls int64 `json:"calls"` + Prompt int64 `json:"prompt"` + Completion int64 `json:"completion"` + CacheHit int64 `json:"cacheHit"` + CacheMiss int64 `json:"cacheMiss"` + HitRate float64 `json:"hitRate"` +} + +// llmUsagePurposeRow / llmUsageModelRow 是按用途 / 按模型分解的一行。两类分开 +// 建模(而不是共用一个带 `key` 的结构),好让线上字段名就是它真实的分组名。 +type llmUsagePurposeRow struct { + Purpose string `json:"purpose"` + llmUsageTotals +} + +type llmUsageModelRow struct { + Model string `json:"model"` + llmUsageTotals +} + +// llmUsageResponse 是响应 data 本体。 +type llmUsageResponse struct { + Window llmUsageWindow `json:"window"` + Totals llmUsageTotals `json:"totals"` + ByPurpose []llmUsagePurposeRow `json:"byPurpose"` + ByModel []llmUsageModelRow `json:"byModel"` +} + +// adminLlmUsage 处理 GET /api/v1/admin/llm/usage(owner|admin 读,与同页其它 +// 管理读同门)。它是只读查询,因此不审计(审计只覆盖动作)。 +func (s *Server) adminLlmUsage(w http.ResponseWriter, r *http.Request) { + if s.usage == nil { + writeError(w, apperr.Wrap(apperr.ErrInternal, "usage_unavailable", "LLM 用量汇总未接线")) + return + } + query, err := llmUsageQuery(r) + if err != nil { + writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%s", err.Error())) + return + } + summary, err := s.usage.Summary(r.Context(), query) + if err != nil { + // 原始错误(可能带 SQL/路径)只进日志;响应只给稳定 code + 中文说明。 + logger.WithSource(s.log, llmUsageLogSource).Error("read llm usage summary", "err", err) + writeError(w, apperr.Wrap(apperr.ErrInternal, "usage_unavailable", "读取 LLM 用量汇总失败")) + return + } + writeJSON(w, http.StatusOK, llmUsageResponseOf(summary)) +} + +// llmUsageQuery 解析并**校验**查询参数:`since`/`until` 见 parseLlmUsageInstant, +// 缺省窗口为近 24h,跨度上限见 model.UsageMaxWindow。 +// +// 校验顺序是「形状先于语义」:时间点解析不了就直接 400,不会拿一个零值窗口去查库。 +func llmUsageQuery(r *http.Request) (model.UsageQuery, error) { + params := r.URL.Query() + since, err := parseLlmUsageInstant(params.Get("since")) + if err != nil { + return model.UsageQuery{}, fmt.Errorf("since 非法:%w", err) + } + until, err := parseLlmUsageInstant(params.Get("until")) + if err != nil { + return model.UsageQuery{}, fmt.Errorf("until 非法:%w", err) + } + if until.IsZero() { + until = time.Now() + } + if since.IsZero() { + since = until.Add(-model.UsageDefaultWindow) + } + if !since.Before(until) { + return model.UsageQuery{}, fmt.Errorf("since 必须早于 until") + } + if until.Sub(since) > model.UsageMaxWindow { + return model.UsageQuery{}, fmt.Errorf("时间窗超过上限 %s", model.UsageMaxWindow) + } + return model.UsageQuery{ + Since: since, + Until: until, + Purpose: strings.TrimSpace(params.Get("purpose")), + }, nil +} + +// parseLlmUsageInstant 解析一个时间点参数。接受两种形态: +// +// - **RFC3339**(如 `2026-10-05T00:00:00Z`,JS 的 `toISOString()` 就是这个); +// - **Unix 毫秒**(纯数字,如 `1759622400000`)—— 与 `/sessions/{id}/messages` +// 的 `before`/`after` 游标同一种口径,便于客户端复用游标。 +// +// 空串 = 未给(返回零值)。其它形态报错(绝不静默当成「现在」)。 +func parseLlmUsageInstant(raw string) (time.Time, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, nil + } + if allDigits(raw) { + ms, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return time.Time{}, fmt.Errorf("毫秒时间戳 %q 越界", raw) + } + return time.UnixMilli(ms), nil + } + parsed, err := time.Parse(time.RFC3339, raw) + if err != nil { + return time.Time{}, fmt.Errorf("不是 RFC3339 或毫秒时间戳:%q", raw) + } + return parsed, nil +} + +// allDigits 报告 s 是否由纯 ASCII 数字构成(空串为 false)。 +func allDigits(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} + +// llmUsageResponseOf 把存储层汇总翻成线上结构。时间一律以 **UTC RFC3339** 回显; +// 分组列表恒为非 nil 切片(无数据时是 `[]` 而非 `null`)。 +func llmUsageResponseOf(summary model.UsageSummary) llmUsageResponse { + out := llmUsageResponse{ + Window: llmUsageWindow{ + Since: summary.Window.Since.UTC().Format(time.RFC3339), + Until: summary.Window.Until.UTC().Format(time.RFC3339), + Purpose: summary.Window.Purpose, + }, + Totals: llmUsageTotalsOf(summary.Totals), + ByPurpose: make([]llmUsagePurposeRow, 0, len(summary.ByPurpose)), + ByModel: make([]llmUsageModelRow, 0, len(summary.ByModel)), + } + for _, group := range summary.ByPurpose { + out.ByPurpose = append(out.ByPurpose, llmUsagePurposeRow{ + Purpose: group.Key, + llmUsageTotals: llmUsageTotalsOf(group.UsageTotals), + }) + } + for _, group := range summary.ByModel { + out.ByModel = append(out.ByModel, llmUsageModelRow{ + Model: group.Key, + llmUsageTotals: llmUsageTotalsOf(group.UsageTotals), + }) + } + return out +} + +// llmUsageTotalsOf 是一处换算:合计计数 + 命中率(分母 0 ⇒ 0)。 +func llmUsageTotalsOf(t model.UsageTotals) llmUsageTotals { + return llmUsageTotals{ + Calls: t.Calls, + Prompt: t.Prompt, + Completion: t.Completion, + CacheHit: t.CacheHit, + CacheMiss: t.CacheMiss, + HitRate: t.HitRate(), + } +} diff --git a/internal/services/gateway/admin_llm_usage_test.go b/internal/services/gateway/admin_llm_usage_test.go new file mode 100644 index 0000000..c593040 --- /dev/null +++ b/internal/services/gateway/admin_llm_usage_test.go @@ -0,0 +1,320 @@ +package gateway + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "path/filepath" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/db" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// 本文件钉住 LLM 用量汇总只读端点(GET /api/v1/admin/llm/usage,切片 U1, +// 契约 v0.64):数值正确(含 hitRate 公式)、时间窗与用途过滤生效、 +// 超上限窗口 400、鉴权(401 / viewer 403)、不回显凭据。 + +// llmUsageTotalsDTO 与 internal/services/gateway/admin_llm_usage.go 的字段逐字 +// 对齐;字段名写错就解不出来。 +type llmUsageTotalsDTO struct { + Calls int64 `json:"calls"` + Prompt int64 `json:"prompt"` + Completion int64 `json:"completion"` + CacheHit int64 `json:"cacheHit"` + CacheMiss int64 `json:"cacheMiss"` + HitRate float64 `json:"hitRate"` +} + +type llmUsageResponseDTO struct { + Window struct { + Since string `json:"since"` + Until string `json:"until"` + Purpose string `json:"purpose"` + } `json:"window"` + Totals llmUsageTotalsDTO `json:"totals"` + ByPurpose []struct { + Purpose string `json:"purpose"` + llmUsageTotalsDTO + } `json:"byPurpose"` + ByModel []struct { + Model string `json:"model"` + llmUsageTotalsDTO + } `json:"byModel"` +} + +// llmUsageTestDB 在 dir 下打开一个真实 SQLite 文件库并跑迁移(端点用的就是这条 +// 活连接:Deps.Usage)。 +func llmUsageTestDB(t *testing.T, dir string) *sql.DB { + t.Helper() + ctx := context.Background() + conn, err := db.Open(ctx, filepath.Join(dir, "cyrene.db"), gwTestLogger()) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + if err := db.MigrateUp(ctx, conn, gwTestLogger()); err != nil { + t.Fatalf("migrate: %v", err) + } + return conn +} + +// seedUsageSamples 用**真实的写入器**落库三条样本(批量事务路径),使端到端 +// 「写入 → 汇总」与生产同一条路。 +func seedUsageSamples(t *testing.T, conn *sql.DB) { + t.Helper() + writer := model.NewUsageWriter(conn, gwTestLogger()) + writer.Start(context.Background()) + samples := []model.UsageSample{ + {Adapter: "openai", Model: "deepseek-chat", Purpose: "chat", + PromptTokens: 1000, CompletionTokens: 200, TotalTokens: 1200, + CacheHitTokens: 900, CacheMissTokens: 100, TookMs: 50}, + {Adapter: "openai", Model: "deepseek-chat", Purpose: "chat", + PromptTokens: 1000, CompletionTokens: 100, TotalTokens: 1100, + CacheHitTokens: 0, CacheMissTokens: 1000, TookMs: 70}, + {Adapter: "openai", Model: "gpt-x", Purpose: "memory_extract", + PromptTokens: 500, CompletionTokens: 50, TotalTokens: 550, + CacheHitTokens: 500, CacheMissTokens: 0, TookMs: 30}, + } + for _, sample := range samples { + writer.RecordUsage(sample) + } + writer.Stop() +} + +// insertOldUsageRow 写一行**窗口之外**的样本(created_at 显式给定)。 +func insertOldUsageRow(t *testing.T, conn *sql.DB, purpose, model, createdAt string) { + t.Helper() + if _, err := conn.ExecContext(context.Background(), + `INSERT INTO llm_usage + (purpose, adapter, model, prompt_tokens, completion_tokens, + cache_hit_tokens, cache_miss_tokens, total_tokens, took_ms, created_at) + VALUES (?, 'openai', ?, 7, 1, 0, 7, 8, 5, ?)`, + purpose, model, createdAt); err != nil { + t.Fatalf("insert old usage row: %v", err) + } +} + +// getLlmUsage 以 owner token 取一次用量汇总。 +func getLlmUsage(t *testing.T, url, query, token string) (int, []byte) { + t.Helper() + path := "/api/v1/admin/llm/usage" + if query != "" { + path += "?" + query + } + resp, body := doReq(t, http.MethodGet, url+path, nil, token) + return resp.StatusCode, body +} + +// decodeLlmUsage 解出 data 本体。 +func decodeLlmUsage(t *testing.T, body []byte) llmUsageResponseDTO { + t.Helper() + var out llmUsageResponseDTO + if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil { + t.Fatalf("decode usage data: %v (body=%s)", err, body) + } + return out +} + +// TestAdminLlmUsageSummarizesBudgetWindow 钉住默认窗口(近 24h)下的数值与 +// hitRate 公式:窗口外的行**不算进来**,命中率 = cacheHit / (cacheHit + cacheMiss)。 +func TestAdminLlmUsageSummarizesBudgetWindow(t *testing.T) { + t.Parallel() + conn := llmUsageTestDB(t, t.TempDir()) + seedUsageSamples(t, conn) + // 窗口外(2020 年)的一行:默认近 24h 不该看到它。 + insertOldUsageRow(t, conn, "chat", "ancient-model", "2020-01-01T00:00:00.000Z") + + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Usage: model.NewUsageReader(conn)}) + status, body := getLlmUsage(t, ts.URL, "", signAny(t, jwt, "acct", auth.RoleOwner)) + if status != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", status, body) + } + got := decodeLlmUsage(t, body) + + want := llmUsageTotalsDTO{Calls: 3, Prompt: 2500, Completion: 350, CacheHit: 1400, CacheMiss: 1100, HitRate: 0.56} + if got.Totals != want { + t.Fatalf("totals = %+v, want %+v", got.Totals, want) + } + if len(got.ByPurpose) != 2 || got.ByPurpose[0].Purpose != "chat" || got.ByPurpose[0].Calls != 2 { + t.Fatalf("byPurpose = %+v, want chat 在前且 2 次调用", got.ByPurpose) + } + if got.ByPurpose[0].HitRate != 0.45 { + t.Fatalf("byPurpose[chat].hitRate = %v, want 0.45", got.ByPurpose[0].HitRate) + } + if len(got.ByModel) != 2 || got.ByModel[0].Model != "deepseek-chat" { + t.Fatalf("byModel = %+v, want deepseek-chat 在前", got.ByModel) + } + // 窗口回显必须是**补齐后的真值**(缺省 = 近 24h),且是 UTC RFC3339。 + since, err := time.Parse(time.RFC3339, got.Window.Since) + if err != nil { + t.Fatalf("window.since %q 不是 RFC3339: %v", got.Window.Since, err) + } + until, err := time.Parse(time.RFC3339, got.Window.Until) + if err != nil { + t.Fatalf("window.until %q 不是 RFC3339: %v", got.Window.Until, err) + } + if span := until.Sub(since); span != model.UsageDefaultWindow { + t.Fatalf("缺省窗口 = %s, want %s", span, model.UsageDefaultWindow) + } + if got.Window.Purpose != "" { + t.Fatalf("window.purpose = %q, want 空(未过滤)", got.Window.Purpose) + } +} + +// TestAdminLlmUsageHonoursWindowAndPurposeParams 钉住 since/until/purpose 真的 +// 生效(用一个把 2020 那条括进来的显式窗口)。 +func TestAdminLlmUsageHonoursWindowAndPurposeParams(t *testing.T) { + t.Parallel() + conn := llmUsageTestDB(t, t.TempDir()) + seedUsageSamples(t, conn) + insertOldUsageRow(t, conn, "memory_extract", "ancient-model", "2020-01-01T00:00:00.000Z") + + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Usage: model.NewUsageReader(conn)}) + token := signAny(t, jwt, "acct", auth.RoleOwner) + + status, body := getLlmUsage(t, ts.URL, + "since=2019-12-31T00:00:00Z&until=2020-01-02T00:00:00Z", token) + if status != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", status, body) + } + old := decodeLlmUsage(t, body) + if old.Totals.Calls != 1 || old.Totals.Prompt != 7 { + t.Fatalf("历史窗口 totals = %+v, want 只剩 2020 那条(calls 1 / prompt 7)", old.Totals) + } + + // 同一窗口 + purpose 不匹配 ⇒ 空(但仍 200,不是 404)。 + status, body = getLlmUsage(t, ts.URL, + "since=2019-12-31T00:00:00Z&until=2020-01-02T00:00:00Z&purpose=chat", token) + if status != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", status, body) + } + filtered := decodeLlmUsage(t, body) + if filtered.Totals.Calls != 0 || len(filtered.ByPurpose) != 0 || len(filtered.ByModel) != 0 { + t.Fatalf("purpose 过滤未生效:%+v", filtered) + } + // 分组列表恒为数组(不是 null)——客户端 JSON 解析不该踩空。 + if raw := string(decodeBody(t, body)["data"]); strings.Contains(raw, `"byPurpose":null`) { + t.Fatalf("byPurpose 下发成了 null:%s", raw) + } + + // 毫秒时间戳与 RFC3339 同义(客户端可复用消息游标的形态)。 + status, body = getLlmUsage(t, ts.URL, "since=1577750400000&until=1577923200000", token) + if status != http.StatusOK { + t.Fatalf("毫秒窗口 status = %d, want 200 (body=%s)", status, body) + } + if ms := decodeLlmUsage(t, body); ms.Totals.Calls != 1 { + t.Fatalf("毫秒窗口 totals = %+v, want calls 1(与 RFC3339 同义)", ms.Totals) + } +} + +// TestAdminLlmUsageRejectsBadWindows 钉住窗口校验:形状错、区间反了、跨度超上限 +// 一律 400 + bad_request,**不静默裁剪**。 +func TestAdminLlmUsageRejectsBadWindows(t *testing.T) { + t.Parallel() + conn := llmUsageTestDB(t, t.TempDir()) + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Usage: model.NewUsageReader(conn)}) + token := signAny(t, jwt, "acct", auth.RoleOwner) + + cases := map[string]string{ + "since 非法格式": "since=昨天", + "until 非法格式": "until=not-a-time", + "since 不早于 until": "since=2026-10-05T00:00:00Z&until=2026-10-05T00:00:00Z", + "窗口超 90 天上限": "since=2026-01-01T00:00:00Z&until=2026-10-05T00:00:00Z", + } + for name, query := range cases { + status, body := getLlmUsage(t, ts.URL, query, token) + if status != http.StatusBadRequest { + t.Fatalf("%s: status = %d, want 400 (body=%s)", name, status, body) + } + if code := errorCode(t, body); code != "bad_request" { + t.Fatalf("%s: code = %q, want bad_request", name, code) + } + } +} + +// TestAdminLlmUsageAuthAndRole 钉住角色门:无凭据 401;viewer 403(同一道 +// owner|admin 门,与 /admin/llm 一致)。 +func TestAdminLlmUsageAuthAndRole(t *testing.T) { + t.Parallel() + conn := llmUsageTestDB(t, t.TempDir()) + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Usage: model.NewUsageReader(conn)}) + + status, body := getLlmUsage(t, ts.URL, "", "") + if status != http.StatusUnauthorized { + t.Fatalf("无凭据 status = %d, want 401 (body=%s)", status, body) + } + wantErrorShape(t, body) + + status, body = getLlmUsage(t, ts.URL, "", signAny(t, jwt, "watch", auth.RoleViewer)) + if status != http.StatusForbidden { + t.Fatalf("viewer status = %d, want 403 (body=%s)", status, body) + } + wantErrorShape(t, body) + + // admin 与 owner 一样放行。 + status, _ = getLlmUsage(t, ts.URL, "", signAny(t, jwt, "op", auth.RoleAdmin)) + if status != http.StatusOK { + t.Fatalf("admin status = %d, want 200", status) + } +} + +// TestAdminLlmUsageHidesCredentials 钉住红线:响应体里既没有凭据、也没有任何 +// 「顺手带出来」的字段 —— `totals` 的键集恰好是契约里的那六个。 +func TestAdminLlmUsageHidesCredentials(t *testing.T) { + t.Parallel() + conn := llmUsageTestDB(t, t.TempDir()) + seedUsageSamples(t, conn) + cfg := cfgDefault() + cfg.DB.PGURL = "postgres://cyrene_user:s3cret-pw@db.internal:5432/cyrene" + _, ts, jwt := buildEnv(t, cfg, Deps{Usage: model.NewUsageReader(conn)}) + + status, body := getLlmUsage(t, ts.URL, "", signAny(t, jwt, "acct", auth.RoleOwner)) + if status != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", status, body) + } + for _, secret := range []string{ + "postgres://", "s3cret-pw", "cyrene_user", "db.internal", + "apiKey", "api_key", "DSN", "dsn", "Bearer", "sk-", + } { + if strings.Contains(string(body), secret) { + t.Fatalf("响应体泄露/夹带了 %q:%s", secret, body) + } + } + + var envelope struct { + Data struct { + Totals map[string]json.RawMessage `json:"totals"` + } `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + t.Fatalf("decode envelope: %v", err) + } + wantKeys := []string{"calls", "prompt", "completion", "cacheHit", "cacheMiss", "hitRate"} + if len(envelope.Data.Totals) != len(wantKeys) { + t.Fatalf("totals 键集 = %v, want 恰好 %v", envelope.Data.Totals, wantKeys) + } + for _, key := range wantKeys { + if _, ok := envelope.Data.Totals[key]; !ok { + t.Fatalf("totals 缺字段 %q(契约 v0.64):%v", key, envelope.Data.Totals) + } + } +} + +// TestAdminLlmUsageUnwired 钉住「没接线不假装成功」:未注入汇总出口时 500 + +// usage_unavailable,绝不回一份全 0 的假汇总。 +func TestAdminLlmUsageUnwired(t *testing.T) { + t.Parallel() + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) + status, body := getLlmUsage(t, ts.URL, "", signAny(t, jwt, "acct", auth.RoleOwner)) + if status != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500 (body=%s)", status, body) + } + if code := errorCode(t, body); code != "usage_unavailable" { + t.Fatalf("code = %q, want usage_unavailable", code) + } +} diff --git a/internal/services/gateway/contracts.go b/internal/services/gateway/contracts.go index 1468dcf..7cfbcb8 100644 --- a/internal/services/gateway/contracts.go +++ b/internal/services/gateway/contracts.go @@ -246,3 +246,18 @@ type RagStore interface { // 不存在该文档时返回 knowledge.ErrDocumentNotFound。 DeleteDocument(ctx context.Context, id string) error } + +// LlmUsageStore 是 LLM 用量汇总的**只读**出口(切片 U1, +// GET /api/v1/admin/llm/usage)。*runtime/model.UsageReader 是生产 +// 实现:它在一块时间窗内聚合 `llm_usage` 表的 token 计数与缓存两档, +// 按用途与模型分解。 +// +// 汇总里**没有任何凭据、也没有消息正文** —— 那些东西根本没进 +// `llm_usage` 表(写入侧 model.UsageWriter 的样本里就没有这些字段)。 +// 为 nil 时端点诚实作答 500 usage_unavailable,绝不回一份全 0 +// 的假汇总。 +type LlmUsageStore interface { + // Summary 聚合 q 描述的时间窗与用途过滤。q 的时间窗由 + // handler 校验并补齐(缺省近 24h、上限 90 天)。 + Summary(ctx context.Context, q model.UsageQuery) (model.UsageSummary, error) +} diff --git a/internal/services/gateway/deps.go b/internal/services/gateway/deps.go index 8ebc9c3..ca0dcf1 100644 --- a/internal/services/gateway/deps.go +++ b/internal/services/gateway/deps.go @@ -138,4 +138,8 @@ type Deps struct { // handler 自身强制其签名/授权(webhook HMAC)。 // 可选。 Webhooks map[string]http.Handler + // Usage 是 LLM 用量汇总的只读出口(切片 U1)。可选;为 nil 时 + // GET /api/v1/admin/llm/usage 诚实作答 500 usage_unavailable, + // 绝不回一份全 0 的假汇总。 + Usage LlmUsageStore } diff --git a/internal/services/gateway/routes.go b/internal/services/gateway/routes.go index 468e86f..864d05d 100644 --- a/internal/services/gateway/routes.go +++ b/internal/services/gateway/routes.go @@ -168,6 +168,9 @@ func (s *Server) registerAdminRoutes( mux.Handle("GET /api/v1/admin/channels/messages", writeAuth(http.HandlerFunc(s.handleChannelMessages))) mux.Handle("GET /api/v1/admin/channels/{platform}/messages", writeAuth(http.HandlerFunc(s.handleChannelPlatformLog))) mux.Handle("GET /api/v1/admin/llm", writeAuth(http.HandlerFunc(s.adminGetLlm))) + // LLM 用量汇总(切片 U1,**v0.64**):只读、不审计,与同页的 /admin/llm + // 同门(owner|admin)—— 它含成本与模型/用途分解,不是给 viewer 的监控投影。 + mux.Handle("GET /api/v1/admin/llm/usage", writeAuth(http.HandlerFunc(s.adminLlmUsage))) // 模型目录候选(裁决 03.4.2):面板「添加提供商 / 添加模型」的 // baseURL / 模型清单 / 能力位来源,服务端快照 + 离线兜底。 mux.Handle("GET /api/v1/admin/llm/catalog", writeAuth(http.HandlerFunc(s.adminGetLlmCatalog))) diff --git a/internal/services/gateway/server.go b/internal/services/gateway/server.go index 9e7933b..ab4a1d1 100644 --- a/internal/services/gateway/server.go +++ b/internal/services/gateway/server.go @@ -83,6 +83,9 @@ type Server struct { // (尽力而为)。见 Deps.OwnerRegistrar。 ownerRegistrar OwnerRegistrar webhooks map[string]http.Handler + // usage 是 LLM 用量汇总的只读出口(切片 U1,见 Deps.Usage)。为 nil 时 + // GET /api/v1/admin/llm/usage 回 500 usage_unavailable。 + usage LlmUsageStore // db 是组合根注入的活 SQLite 连接池(见 Deps.Database)。管理侧 // 「存储状态」只读端点用它读页级统计与 documents 表;为 nil 时该 // 端点回 500 storage_unavailable(不另开池、不假装有数据)。 @@ -134,6 +137,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) { plugins: deps.Plugins, ownerRegistrar: deps.OwnerRegistrar, webhooks: deps.Webhooks, + usage: deps.Usage, dbPath: deps.DatabasePath, dataDir: deps.DataDir, db: deps.Database,