feat(llm): 用量落库 + 只读汇总端点 + 面板用量小卡(契约 v0.64)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- model 层新增可选 UsageSink(nil ⇒ 逐字不变,既有用例零改动)
- 组合根注入 UsageWriter:有界队列 1024 + 单写者 + 批量事务(128/批、2s 兜底),
  队列满丢弃 + 限流 WARN,绝不阻塞模型调用;sink 挂 RuntimeLLM(热重载不断)
- 迁移 0017:表 llm_usage(13 列,session_id 可空)+ idx_created_at + idx_session_id
- 新端点 GET /api/v1/admin/llm/usage(owner|admin 读;缺省近 24h、上限 90 天、
  超限 400 不静默裁剪;hitRate = cacheHit/(cacheHit+cacheMiss),分母 0 ⇒ 0;
  绝不含凭据与正文;未接线/查库失败 ⇒ 500 usage_unavailable)
- LLM_USAGE_RETENTION(默认 720h,≤0 = 关闭清理):启动清一次 + 每小时
- 面板 LLM 页「用量(近 24h)」小卡 + Docs 两份同步 v0.64
- 已知缺口(如实登记):session_id 恒 NULL,需 loop 侧在请求上打标,留列后接
This commit is contained in:
AskaEth committed 2026-10-04 22:16:26 +08:00
1 parent 18381f65fd
commit be38651ba4
32 files changed
+2233 -44

No files matched your search

+29 -1
View File
@@ -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://<host>:8080/api/v1/admin/observability/trace?limit=100' -H "Auth
curl -s http://<host>: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://<host>: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`,全程审计)