Skip to content
Clone

docs(wiki): replace 3 archived design docs with new wiki contents

zhenyi committed
parent 099b185 commit 058663f Browse files

2 files changed

+2020 −0

File tree 2
Added wiki/basic.md View wiki/basic.md
@@ -0,0 +1,389 @@
1# API 用户关联 UUID 改造计划
2
3## 1. 背景
4
5当前项目的多个 API 响应 DTO 直接暴露用户关联 UUID,例如 `author_id`、`user_id`、`creator_id`、`owner_id`、`actor_id`、`publisher_id` 等。不同领域还存在多套重复且字段不一致的用户投影:
6
7- profile 领域的 `UserPublic`;
8- auth 领域的 `UserPublic`;
9- repo 领域的 `UserMini` 与多个 `UserSummary`;
10- issues 领域的 `IssueUserView`;
11- post、project、space 等领域直接返回裸 UUID。
12
13这会导致以下问题:
14
15- 公开 API 泄露裸用户标识,调用方无法直接获得展示所需的基础信息;
16- 用户投影的字段命名不一致,例如 `uid`/`id`、`avatar`/`avatar_url`;
17- 部分投影可能携带 email 等不应默认公开的字段;
18- DTO 直接从数据库模型转换,批量关联用户时容易产生 N+1 查询;
19- OpenAPI schema、handler、usecase 和测试之间的响应契约不统一。
20
21## 2. 核心目标
22
231. 所有公开 API 响应中,用户关系不再以裸用户 UID 或用户关联 UUID 返回。
242. 用户关系统一返回类似以下结构的公开基础信息:
25
26 ```rust
27 UserBasicInfo {
28 uid,
29 username,
30 display_name,
31 avatar_url,
32 ...
33 }
34 ```
35
363. 用户已确认采用直接迁移:删除旧的响应字段,不同时保留 `*_id` 和嵌套用户对象。
374. 通过共享批量加载逻辑避免逐条查询用户,防止引入 N+1 查询。
385. 统一更新 DTO、mapper、usecase、handler、OpenAPI schema 和测试。
39
40## 3. 范围边界
41
42### 3.1 领域覆盖清单
43
44本次改造是跨领域的 API 响应契约迁移,不是只修改 `user` 模块。以下领域均属于一等范围:
45
46| 领域 | 重点用户关系响应 |
47| --- | --- |
48| `user` / `auth` / profile / social | 公开用户、登录用户、本人信息、关注/屏蔽关系、通知 actor |
49| `project` | creator、lead、activity actor、project member |
50| `issues` / `issue` | issue author、member、watcher、event actor、mention/user 关系 |
51| `space` | owner、member、invited_by、invite user、ban user/banned_by |
52| `repo` / pull request | PR author、comment/review/event user、collaborator、publisher、assignee、reviewer、commit author/committer、fork owner |
53| `post` | post/reply author、last reply、reaction/mention/watch user |
54
55每个领域都必须从 response DTO、mapper、usecase、handler、OpenAPI schema 和测试链路完整迁移;不能只新增 `UserBasicInfo` 而遗漏某个领域的裸用户关系。
56
57### 3.2 需要迁移
58
59- 已确认属于 HTTP 公开响应的用户关系字段;
60- 各领域 response view 中的用户作者、所有者、创建者、操作者、发布者、成员、审核者等关系;
61- 负责构造这些响应的 usecase、mapper 和 handler;
62- 对应的 serde/OpenAPI schema、fixtures 和行为测试。
63
64### 3.3 明确保留不变
65
66- request body DTO 中的用户 UUID;
67- path、query、分页过滤器中的用户 UUID;
68- 领域实体 ID,例如 `issue_id`、`project_id`、`space_id`、`repo_id`、`channel_id`、`message_id`、`comment_id`、`review_id`、`role_id` 等;
69- JWT、session、数据库模型、存储映射和其他未确认处于 HTTP 序列化边界的内部结构;
70- `AppResponseData<T>` 信封及其通用 `IntoResponse` 序列化逻辑。
71
72### 3.4 需要单独评估
73
74- 本人/鉴权响应中的特权字段;
75- 管理员、审计、举报和安全相关响应;
76- 有隐私规则的 profile-detail 响应;
77- chat 等看似 API view、但需要确认 handler/OpenAPI 暴露边界的结构。
78
79## 4. 已确认的代码边界
80
81项目是大型 Rust monorepo。主要响应序列化边界如下:
82
83- `lib/export/data.rs`:`AppResponseData<T>`,负责统一响应 envelope 和 Axum `IntoResponse`;
84- `lib/webapi/openapi.rs`:组合 webapi、auth、issues、post、project、repo、space、user 的 OpenAPI 文档;
85- `lib/export/openapi.rs`:处理 API path 前缀;
86- 各领域 handler/openapi 模块:注册具体 response DTO 和 schema;
87- 各领域 feature usecase:从 DB model 或查询结果构造 response view。
88
89现有 profile social usecase 已有可复用的批量加载模式:收集 UID,使用 `uid.is_in(...)` 一次查询用户,构造 `HashMap<Uuid, user_model::Model>`,然后映射嵌套用户对象。新实现应抽取或复用这一模式,而不是在每个领域重复实现。
90
91## 5. 目标公共用户投影
92
93### 5.1 新增 canonical 类型
94
95在合适的公共 export/user 模块中新增并导出 `UserBasicInfo`,建议候选位置为:
96
97- `lib/export/user/basic.rs`;
98- 或项目已有的公共 user/export 模块,避免与 auth/profile feature 形成反向依赖。
99
100最终位置应遵循 workspace crate 依赖方向,并保证各领域 DTO 可以稳定引用。
101
102### 5.2 字段策略
103
104默认只包含公开、低敏感、适合列表和关联展示的字段:
105
106- `uid`:公开用户标识,类型和序列化形式需统一;
107- `username`;
108- `display_name`;
109- `avatar_url`,或经确认后统一使用现有 avatar 命名;
110- 其他确有公开需求且经过隐私评估的基础字段。
111
112默认不包含:
113
114- email;
115- active、admin、restricted 等状态和权限字段;
116- last login;
117- 安全、会话、设备和内部审计字段;
118- profile-detail 中受隐私规则保护的字段。
119
120需要明确 `uid` 是允许作为嵌套 `UserBasicInfo` 的公开字段返回的用户标识,而不是继续作为 response 顶层裸关联字段返回。
121
122### 5.3 兼容策略
123
124用户已确认直接迁移为 breaking change:
125
126- 删除公开 response 中的 `author_id`、`user_id`、`creator_id`、`owner_id` 等用户关系字段;
127- 不保留旧字段作为兼容字段;
128- 使用语义化嵌套字段,例如 `author`、`user`、`creator`、`owner`、`actor`、`publisher`、`invited_by`;
129- 更新 API 文档和测试中的预期 JSON。
130
131## 6. 缺失用户策略(已确认)
132
133已确认:当关联用户已删除、不可见或无法加载时,统一返回 `null`,而不是返回裸 UUID 或伪造用户对象。该规则同时适用于 required 和 optional 用户关系,不因单个关联缺失而让整个响应失败。
134
135活动、事件、通知等历史响应中的用户关系也必须迁移;历史数据无法解析用户时同样返回 `null`。如未来需要保留历史展示名称,应使用独立的非 UUID 快照字段,不能恢复裸用户关联 UUID。
136
137## 7. 实施步骤
138
139### 阶段一:建立公共类型和加载器
140
1411. 确定 `UserBasicInfo` 的 crate/module 位置和字段 allowlist。
1422. 实现 DB user model 到 `UserBasicInfo` 的显式转换。
1433. 抽取可复用的批量 user loader:
144 - 输入去重后的 `Uuid` 集合;
145 - 空集合不发起数据库查询;
146 - 单次查询并返回 `HashMap<Uuid, UserBasicInfo>` 或带缺失状态的映射;
147 - 明确删除、不可见和不存在用户的处理;
148 - 支持 required/optional 关系的不同映射方式;
149 - 不把 email、权限和安全字段带入公共投影。
1504. 为 loader 增加查询次数、去重和缺失用户测试。
151
152### 阶段二:迁移现有公共用户投影
153
1541. 评估 profile `lib/user/features/profile/types.rs::UserPublic` 是否直接改为 canonical 类型,或保留薄 wrapper/re-export。
1552. 评估 auth `lib/auth/features/login/types.rs::UserPublic` 的登录响应是否引用 canonical 类型。
1563. 使 social follower/following/blocked 响应使用 canonical 投影。
1574. 处理 `ProfileView` 的 `user_id`:遵循 profile 隐私规则,改为 `user: Option<UserBasicInfo>`,移除裸 `user_id`。
1585. 保留 `UserMe` 的本人/特权字段,但不要把它当作公开 `UserBasicInfo` 使用。
159
160### 阶段三:迁移 post 和 issues
161
162涉及主要文件:
163
164- `lib/export/post/views.rs`;
165- `lib/export/issues/issue.rs`;
166- `lib/export/issues/issue_member.rs`;
167- `lib/export/issues/issue_watch.rs`;
168- `lib/export/issues/issue_event.rs`;
169- `lib/export/issues/issue_user.rs`。
170
171需要处理的公开关系包括:
172
173- `PostView.author_id`、`last_reply_by`;
174- `PostReplyView.author_id`、`deleted_by`;
175- post reaction/mention/watch 的 `user_id`;
176- `IssueView.author_id`;
177- issue member/watch/event/user view 的 `user_id`;
178- 删除者、最后回复者等可选关系。
179
180需要保留的请求字段包括 `ListPostQuery.author_id`、`CreateMentionOption.user_id`、创建/更新 issue/member 等 command 中的用户 UUID。
181
182各 handler/usecase 应先收集页面内所有用户关系,再批量加载,最后构造 page/list response,避免在 `From<Model>` 中逐条查询。
183
184### 阶段四:迁移 repo 和 pull request
185
186涉及主要文件:
187
188- `lib/export/repo/pull_request.rs`;
189- `lib/export/repo/collab.rs`;
190- `lib/export/repo/release.rs`;
191- `lib/export/repo/extras.rs`;
192- 相关 repo extras、collab、release、pull request usecase/handler。
193
194需要处理的公开关系包括:
195
196- `PullRequestView.author_id`;
197- PR comment/review/event 的 `user_id`;
198- `CollaboratorView.user_id`;
199- release publisher;
200- `UserMini`、多个 `UserSummary`;
201- assignee、reviewer、commit author/committer、fork owner 等关联。
202
203应统一现有 `UserMini`/`UserSummary` 的字段和命名,移除默认 email 暴露。`review_id`、`repo_id` 等 PR/领域 ID 不是用户关系,不能误改。创建、更新、merge、review submit 和 list query 中的请求 ID 保持不变。
204
205### 阶段五:迁移 project 和 space
206
207涉及主要文件:
208
209- `lib/export/project/lifecycle.rs`;
210- `lib/export/project/activities.rs`;
211- project member usecase/handler;
212- `lib/export/space/spaces.rs`;
213- `lib/export/space/members.rs`;
214- `lib/export/space/invites.rs`;
215- `lib/export/space/bans.rs`;
216- 必要时扩展 roles、channels、overwrites 等响应 DTO。
217
218需要处理的公开关系包括:
219
220- `ProjectView.creator_id`、`lead_id`;
221- `ActivityView.actor_id`;
222- project member user;
223- `SpaceView.owner_id`;
224- `SpaceMemberView.user_id`、`invited_by`;
225- `SpaceInviteView.user_id`;
226- `SpaceBanView.user_id`、`banned_by`。
227
228`ActivityView.target_id` 必须根据具体 verb/target 类型判断,不能仅因字段名相近就当作用户关系。`space_id`、`parent_dir`、role/channel/message 等领域 ID,以及 create/add/list command 中的请求用户 UUID 保持不变。
229
230### 阶段六:迁移 notifications、auth 和管理员响应
231
2321. `NotificationView.actor_uid` 改为可选嵌套 `actor: Option<UserBasicInfo>`。
2332. auth login/me 继续保留鉴权语义和 `UserMe` 特权字段,但公开登录用户信息应复用 canonical 基础投影,避免同名 DTO 漂移。
2343. `ReportView` 的 `reporter_uid`、`reported_uid`、`resolver_uid` 改为嵌套 `Option<UserBasicInfo>`,并继续执行现有管理员授权检查;不在公开响应中保留裸 UUID。
2354. deletion、suspension、agreement、security 等 account/admin DTO 逐一确认是否为管理员/本人响应;确认属于 HTTP response 的用户关系均改为嵌套公开信息,不能通过全局字段重命名直接迁移。
2365. `chat/storagelayer/mapping.rs`、JWT/session、DB/storage model 只有在确认处于 HTTP response 序列化边界后才纳入迁移。
237
238### 阶段七:更新 OpenAPI、示例和测试
239
2401. 为 `UserBasicInfo` 添加 `Serialize`、`Deserialize`、`ToSchema` 以及必要的文档说明。
2412. 更新各领域 response DTO 的 schema 和嵌套关系。
2423. 检查 `lib/webapi/openapi.rs` 的聚合结果和各 handler/openapi 模块注册,确保没有残留旧字段。
2434. 更新 API fixtures、响应 JSON 示例和客户端契约文档。
2445. 不修改 `AppResponseData<T>` 的 envelope 结构。
245
246## 8. 重点文件清单
247
248### 公共类型和基础边界
249
250- `lib/export/data.rs`
251- `lib/export/lib.rs`
252- `lib/export/user/basic.rs`(建议新增)
253- `lib/webapi/openapi.rs`
254- `lib/export/openapi.rs`
255
256### 用户、鉴权和社交
257
258- `lib/user/features/profile/types.rs`
259- `lib/user/features/profile/usecase.rs`
260- `lib/user/features/social/types.rs`
261- `lib/user/features/social/usecase.rs`
262- `lib/auth/features/login/types.rs`
263- `lib/auth/features/login/usecase.rs`
264- `lib/auth/handler/me.rs`
265- `lib/export/user/notifications.rs`
266
267### Post 和 issues
268
269- `lib/export/post/views.rs`
270- `lib/export/issues/issue.rs`
271- `lib/export/issues/issue_member.rs`
272- `lib/export/issues/issue_watch.rs`
273- `lib/export/issues/issue_event.rs`
274- `lib/export/issues/issue_user.rs`
275- 对应 `lib/post`、`lib/issues` 的 handler/usecase/impls/openapi。
276
277### Repo 和 pull request
278
279- `lib/export/repo/pull_request.rs`
280- `lib/export/repo/collab.rs`
281- `lib/export/repo/release.rs`
282- `lib/export/repo/extras.rs`
283- 对应 `lib/repo` 的 usecase/handler/openapi。
284
285### Project 和 space
286
287- `lib/export/project/lifecycle.rs`
288- `lib/export/project/activities.rs`
289- `lib/export/space/spaces.rs`
290- `lib/export/space/members.rs`
291- `lib/export/space/invites.rs`
292- `lib/export/space/bans.rs`
293- 对应 `lib/project`、`lib/space` 的 usecase/handler/openapi。
294
295### CLI/OpenAPI 输出
296
297- `app/apihost/src/cmd_openapi.rs`
298
299以上清单是首批重点范围,不代表可以跳过其他公开 response DTO。最终范围必须以序列化边界和 AST 审计结果为准。
300
301## 9. 测试计划
302
303### 9.1 类型和序列化测试
304
305- `UserBasicInfo` 的字段 allowlist 测试;
306- 确认不存在 email、权限、session、安全字段;
307- nested user JSON 使用统一字段名;
308- 所有用户关系(包括 required 和 optional)在用户缺失、删除或不可见时序列化为 `null`;
309- response JSON 中不再出现旧的用户关系 `*_id` 字段。
310
311### 9.2 loader/usecase 测试
312
313- 输入 UID 去重;
314- 空 UID 集合不查询;
315- 页面内多个关系只触发一次用户查询;
316- 缺失、删除、不可见用户的处理统一返回 `null`,且不会回退为裸 UUID;
317- required/optional 关系分别覆盖成功和失败分支;
318- 映射结果保持原有列表顺序。
319
320### 9.3 handler/行为测试
321
322至少覆盖:
323
324- post author/reply/reaction/mention/watch;
325- issue author/member/watch/event/user;
326- pull request author/comment/review/event;
327- repo collaborator/release/extras;
328- project creator/lead/activity/member;
329- space owner/member/invite/ban;
330- notification actor;
331- profile public、social、auth login/me;
332- admin/audit response 的权限和脱敏行为。
333
334现有测试按行为命名,不能只搜索 `*test*.rs`。重点测试目录包括:
335
336- `lib/issues/tests/`;
337- `lib/post/tests/`;
338- `lib/repo/tests/pull_request/`;
339- 相关 `lib/project`、`lib/space`、`lib/user`、`lib/auth` 测试。
340
341### 9.4 OpenAPI 测试
342
343- 更新已有 post、pull request OpenAPI 测试;
344- 为受影响领域补充 schema 断言;
345- 确认生成 schema 使用 `UserBasicInfo`,且旧裸 UUID 用户关系字段已删除;
346- 检查响应中的领域 ID 仍然存在且类型未被误改。
347
348## 10. 验证和审计
349
350实现后按受影响 workspace crate 执行:
351
3521. `cargo fmt`;
3532. 对 `export`、`user`、`auth`、`issues`、`post`、`repo`、`project`、`space`、`webapi`、`apihost` 进行 `cargo check`;
3543. 运行相关领域测试和 OpenAPI 测试;
3554. 必要时运行完整 workspace test;
3565. 生成并检查 OpenAPI 文档;
3576. 使用 grep/AST 审计所有公开 response DTO:
358 - 找出用户关系字段仍为裸 `Uuid` 的响应结构;
359 - 排除 request/path/query、领域 ID、内部结构和经确认的管理员审计例外;
360 - 审查所有 `From<Model>` 直接返回 response 的路径;
3617. 检查查询日志或 mock query 次数,确认没有新增 N+1。
362
363## 11. 验收标准
364
365- 公开 response 中的用户关系均为统一的嵌套 `UserBasicInfo` 或明确的受控审计投影;
366- 旧的公开 `author_id`、`user_id`、`creator_id`、`owner_id` 等用户关系字段已按直接迁移决策删除;
367- request、path/query、领域和内部 ID 未被误改;
368- `UserBasicInfo` 不包含 email、权限、session、安全或未授权 profile 字段;
369- optional 关联用户的缺失行为符合最终确认的策略;
370- 批量关系加载无 N+1,空集合和缺失用户行为有测试;
371- OpenAPI schema、示例和实际 JSON 一致;
372- 受影响领域测试、OpenAPI 测试、格式化和编译验证通过;
373- 最终 AST/序列化边界审计有明确的 intentional exceptions 清单。
374
375## 12. 待审查问题
376
3771. `UserBasicInfo.uid` 的公开字符串格式是否统一沿用现有 UUID 序列化方式?该细节不影响字段结构和迁移范围。
378
379本文件只记录改动计划,待审查确认后再实施业务代码变更。
380
381---
382
383## 13. 实施状态(2026-08-12 已执行完毕)
384
385- 共享基础设施:新增 `lib/db/user/users_loader.rs`(`load_users`/`load_user` 批量/单点加载,防 N+1)与 `lib/export/user/basic.rs`(canonical `UserBasicInfo { uid, username, display_name, avatar_url }`)。
386- 全部域迁移完成:user(profile/social)、post、issues、repo(PR/collab/release/extras/subscriber)、project(lifecycle/activities/members/documents/views/favorites)、space(spaces/members/invites/bans/roles/channels/overwrites)、user(notifications/admin reports/suspensions/deletion)。所有响应 DTO 改为嵌套 `Option<UserBasicInfo>`,删除裸用户关系字段与 `From<Model>`,改用公开 `from_model(m, ...)` 构造方法。
387- 请求 DTO、path/query 参数、领域/内部 ID 全部保留;`AppResponseData<T>` 信封未改。
388- 验证:`cargo check --workspace` 通过;`cargo fmt` 通过;裸 UUID 审计通过(残留 17 处均为请求 DTO/查询参数/领域 ID);`cargo test -p export -p user -p issues -p post -p project -p repo -p space` 全部通过(含 space 4 个 rename 测试,测试辅助已补 space_members 表 DDL)。
389- 未提交,改动保留在工作树中。
Added wiki/llmprovider.md View wiki/llmprovider.md
@@ -0,0 +1,1631 @@
1# LLM Provider 规划
2
3## 1. 目标
4
5完成 `agent/lprovider` 模块,基于 Axum + tonic 构建本平台统一的 LLM Provider 服务,**作为独立集群进程运行**(多实例水平扩展,对外无差别),具备以下能力:
6
7- 从配置文件加载上游、路由、能力和默认参数;上游相关数据**不落库**,由配置文件提供。
8- **对外统一暴露 OpenAI-compatible 协议**(`POST /v1/chat/completions`、`GET /v1/models`、健康检查等),供外部客户端与第三方网关调用。
9- **内部使用 RPC(gRPC)同时承载 AI 推理与平台管理两类能力**,独立端口、独立鉴权,与对外 HTTP 完全隔离:
10 - **AI 推理 RPC**(`LproviderAI`):供平台内部其他服务直接调用,享受强类型、内部 `ChatRequest`/`ChatResponse`、原生流式、显式租户/优先级等增强能力。
11 - **管理 RPC**(`LproviderAdmin`):配置读写、热加载、优雅重启、绑定 / 审计 / 计费查询、额度 CRUD。
12- 提供统一的 200,000 token 上下文策略,超限请求在网关层拒绝。
13- 同一上游支持多个 API Key,自动按权重、并发与冷却状态挑选 Key。
14- 采用会话亲和性路由,绑定存储由 Redis 实现(**跨实例共享**),最大化上游 KV 缓存命中。
15- 按请求能力自动筛选上游,支持降级轮转。
16- 管理接口能够直接修改本地配置文件并触发**热加载**或**优雅重启**;在集群模式下需配合滚动重启策略。
17- 审计与计费必须严格实现,按请求落库并支持**积分钱包(Wallet)**维度聚合;HTTP 与 RPC 路径都计入审计。
18- 计费采用**积分钱包(Wallet)**:钱包归属于 `User` 或 `Space`(任一为主体);**调用方在每次请求时显式选择使用的钱包**,由 lprovider 校验状态并扣减余额。
19- **仅内部 RPC AI 请求支持匿名模式**(不选择钱包、不扣减、不计费),其余所有路径(HTTP、内部 RPC 管理、HTTP 错误重试等)若匿名一律拒绝;匿名请求仍落审计库但 `wallet_id IS NULL` 且不计费。
20- **整体计费采用积分制**,每个模型在配置文件中声明输入 / 输出 / 缓存命中的积分单价。
21- 模型能力(流式、工具调用、视觉、推理、结构化输出、embeddings 等)在配置文件中完整声明,作为路由筛选的唯一权威来源。
22- **统一内部模型别名固定为 `auto`**:调用方无需关心具体上游模型,由 lprovider 内置选择器根据请求能力、token 预算、积分单价、优先级和钱包余额,自动挑选最优上游与 Key。
23- 多实例集群部署:任一实例可独立处理请求;共享状态经 Redis + DB 协调;管理面写操作需选主或对等互斥。
24
25## 2. 当前代码现状
26
27### 2.1 Workspace 与 lprovider
28
29- Workspace 已包含 `agent/lprovider`。
30- `agent/lprovider/lib.rs` 当前为空。
31- `agent/lprovider/Cargo.toml` 当前仅依赖 `axum.workspace` 和 `tokio.workspace`。
32- 根 workspace 使用 Rust 2024 edition。
33- 当前 Axum 版本为 `0.8.9`,Tokio 使用 full features。
34- Workspace 已有 `serde`、`serde_json`、`futures`、`reqwest`、`tracing` 等依赖。
35- Workspace 已有 `redis = "1.5.0"`(含 tokio-comp / cluster-async / sentinel),封装于 `lib/cache::RedisCache` 与 `RedisConn`。
36- Workspace 已有 `lib/db`(基于 sea-orm 2.x,schema-sync 迁移机制,见 §11),可复用。
37- Workspace **暂无 tonic/gRPC 依赖**,需新增。
38- Workspace **暂无 `lib/secret` 模块**:本文档假设的凭据引用解析、envelope 加密与 mTLS 证书注入能力需要**新建该 crate**(见 §11 / §12 阶段一)。
39
40### 2.2 现有 LLM 抽象
41
42`agent/llm` 已提供可复用的核心能力:
43
44- `LlmProvider` trait:提供 `chat`、`chat_stream`、Provider/模型标识、能力描述和 token 估算。
45- `ChatRequest` 与 `ChatResponse`:统一请求和响应类型。
46- `StreamEvent`:统一流式文本、推理、工具调用、用量和完成事件。
47- `ProviderCapabilities`:描述流式、工具调用、结构化输出、视觉、推理及上下文/输出上限。
48- `LlmError`:包含 Provider、网络、限流、上下文超限、认证、模型不存在和 API 错误等分类,并提供 retryable 判断。
49- `ProviderRegistry`:按 `provider_id/model_id` 注册 Provider,但自身是非线程安全的 HashMap,不直接承担运行时负载均衡。
50- `FallbackProvider`:在可重试错误发生时执行故障转移。
51- OpenAI Provider:已实现普通请求和 SSE 流式请求,默认上下文为 128K。
52
53由于现有 OpenAI Provider 默认上下文为 128K,统一 200K 应作为网关策略,而不是直接伪装成所有底层模型的真实能力。
54
55### 2.3 配置与启动现状
56
57- `lib/config` 的 `ConfigLoader` 支持 `config/default.yaml`、`config/local.yaml`、dotenv 和 `APP__...` 环境变量。
58- `AppConfig` 当前没有 lprovider 配置段。
59- `app/apihost` 当前负责加载配置、构建 `AppServices`、绑定监听地址、启动 Axum 并处理 graceful shutdown。
60- `lib/webapi/services.rs` 与 `lib/webapi/router.rs` 当前没有 lprovider 状态和路由。
61- `app/apihost` CLI 当前提供 `--config`、`--bind` 和 `--port` 参数。
62
63## 3. 推荐架构
64
65### 3.1 运行形态(独立集群进程)
66
67`agent/lprovider` 作为**独立集群进程**运行:
68
69- 同一二进制可在多台主机 / 多个 Pod 启动多个实例,对外通过 L4/L7 负载均衡器(Envoy / Nginx / K8s Service / 云厂商 LB)统一暴露。
70- 每个实例都同时承担:**对外 HTTP(OpenAI-compatible)** + **内部 RPC(gRPC 管理)** 两个端口。
71- 实例之间**不直接通信**:共享状态完全由 Redis(会话亲和、冷却)与 DB(审计/计费)协调;配置文件由外部配置中心下发或由管理 RPC 写本地后经集群滚动重启同步。
72- 启动形式:
73
74```bash
75cargo run -p lprovider -- \
76 --config /etc/lprovider/config.yaml \
77 --bind 0.0.0.0 --port 8080 \
78 --admin-bind 0.0.0.0 --admin-port 9090 \
79 --instance-id $(hostname) \
80 --cluster-name lprovider-prod
81```
82
83关键约束:
84
85- 所有实例必须共享同一 Redis 命名空间与 DB schema,否则亲和绑定和审计聚合会跨集群污染。
86- 任一实例均可处理对外请求;管理面写操作需通过选主或分布式锁避免并发改写。
87- 不耦合 `app/apihost` / `lib/webapi`,便于独立扩缩容与发布。
88
89### 3.2 依赖关系
90
91```text
92agent/lprovider
93 ├── agent/llm
94 │ ├── LlmProvider
95 │ ├── ChatRequest / ChatResponse
96 │ ├── StreamEvent
97 │ └── LlmError
98 ├── lib/config(复用配置模型和加载约定)
99 ├── lib/db(仅用于审计与计费持久化)
100 ├── lib/cache::redis(会话亲和性绑定)
101 ├── lib/secret(KMS / envelope 加密 API Key;**当前不存在,需新建**,见 §11)
102 ├── axum(对外 HTTP)
103 ├── tonic(gRPC 管理接口)
104 ├── tokio
105 ├── serde / serde_json
106 └── tracing
107```
108
109## 4. 数据模型与职责划分
110
111**上游数据完全由配置文件承载,不写入数据库**。会话亲和性绑定由 Redis 承载。**落库的只有:审计、计费、钱包(余额与流水)、租户额度、管理操作审计**,且要求严格。
112
113### 4.1 配置 vs Redis vs 数据库职责
114
115| 项 | 配置文件 | Redis | 数据库 |
116| ------------------------ | -------- | ----- | ------ |
117| 上游类型、base URL、默认模型 | 是 | 否 | 否 |
118| 上游能力、上下文上限 | 是 | 否 | 否 |
119| 平台统一模型别名 → 上游映射 | 是 | 否 | 否 |
120| 路由权重 | 是 | 否 | 否 |
121| 上游 Key 明文 / 引用 | 是(引用) | 否 | 否 |
122| 运行时 Key 冷却状态 | 否 | 是(短期) | 否(最终落库用于审计) |
123| 会话亲和性绑定 | 否 | 是 | 否 |
124| 调用审计 / 计费 | 否 | 否 | 是 |
125| 钱包余额 / 流水 | 否 | 否 | 是 |
126| 租户 / 主体额度 | 否 | 否 | 是 |
127| 管理操作审计 | 否 | 否 | 是 |
128
129### 4.2 Redis 键空间(会话亲和性)
130
131参考 [DeepSeek KV Cache 文档](https://api-docs.deepseek.com/zh-cn/guides/kv_cache):上游按“缓存前缀单元(cache prefix unit)”匹配,前缀必须**完整**命中才能复用 KV 缓存;命中情况通过响应 `usage.prompt_cache_hit_tokens` / `prompt_cache_miss_tokens` 反馈;缓存为“尽力而为”,几小时到几天被淘汰。
132
133Redis 键设计:
134
135| 键 | 类型 | 值 / TTL |
136| ----------------------------------------------- | ------ | ------------------------------------------------------- |
137| `lprovider:bind:{model_alias}:{session_key}` | STRING | JSON `{upstream_code, key_label, model, prefix_hash, bound_at, last_hit_tokens, hit_count}` |
138| TTL | | 默认 `default_ttl_seconds = 3600`,命中 ≥ 50% 续约一周期 |
139| `lprovider:bind_idx:upstream:{upstream_code}` | SET | 该上游下当前活跃绑定 session_key(用于上游失效时批量清理) |
140| `lprovider:bind_idx:key:{upstream_code}:{key_label}` | SET | 该 Key 下当前活跃绑定 session_key |
141
142失效与清理:
143
144- 后台定时任务(每分钟)扫描 `lprovider:bind:*` 并清理 `expires_at < now()` 的绑定。
145- 当某个 `(upstream_code, key_label)` 触发 cooldown 或被管理接口禁用时,按 `bind_idx` 批量删除对应绑定。
146- 管理接口 `invalidate_binding(model_alias, session_key)` 强制失效。
147
148### 4.3 数据库表(审计、计费、钱包、额度)
149
150#### 4.3.1 调用审计表 `llm_call_log`
151
152| 字段 | 类型 | 说明 |
153| ------------------- | -------------- | ------------------------------------------------- |
154| `id` | BIGSERIAL PK | |
155| `request_id` | UUID UNIQUE | |
156| `trace_id` | UUID | 上游 / 网关关联 trace |
157| `tenant_id` | BIGINT | 租户维度,必填 |
158| `api_key_id` | BIGINT | 调用方 API Key id(来自平台网关 / 用户体系) |
159| `subject_type` | TEXT | 钱包所有者类型:`user` / `space`(冗余字段,便于不联表聚合) |
160| `subject_id` | BIGINT | 钱包所有者 id(冗余字段) |
161| `wallet_id` | BIGINT | 实际扣减的 wallet_id;匿名请求为 NULL |
162| `anonymous` | BOOLEAN | 是否匿名请求(仅内部 RPC AI 允许为 true) |
163| `subject_path` | TEXT | 主体层级路径(如 `tenant/42/space/7/user/1001`),便于跨级聚合 |
164| `session_key` | TEXT | 亲和会话键 |
165| `model_alias` | TEXT NOT NULL | 平台统一模型别名 |
166| `upstream_code` | TEXT NOT NULL | 实际路由到的上游(auto 模式下由选择器挑选) |
167| `requested_model` | TEXT | 请求方填写的 `model` 字段(通常为 `auto` 或具体 upstream_code) |
168| `chosen_strategy` | TEXT | auto 模式下使用的策略:`BALANCED`/`COST`/`QUALITY`/`LATENCY`/`EXPLICIT` |
169| `tier_cost` | INT | 选中上游的 cost_tier |
170| `tier_quality` | INT | quality_tier |
171| `tier_latency` | INT | latency_tier |
172| `key_label` | TEXT | 实际路由到的 Key 标签 |
173| `caller` | TEXT | `http` / `grpc:{service_name}` |
174| `caller_service` | TEXT | 仅 RPC 路径:来源服务名 |
175| `priority` | INT | 仅 RPC 路径:调用方声明的优先级 |
176| `streamed` | BOOLEAN | |
177| `status` | TEXT | `success` / `error` |
178| `error_kind` | TEXT | |
179| `error_message` | TEXT | 截断后的错误描述 |
180| `prompt_tokens` | INT | |
181| `completion_tokens` | INT | |
182| `cache_hit_tokens` | INT | `prompt_cache_hit_tokens` |
183| `cache_miss_tokens` | INT | `prompt_cache_miss_tokens` |
184| `billable_input_tokens` | INT | 计费输入(命中按折扣计) |
185| `billable_output_tokens` | INT | 计费输出 |
186| `points_input` | BIGINT | 输入消耗积分(按命中折扣后) |
187| `points_output` | BIGINT | 输出消耗积分 |
188| `points_cache_hit` | BIGINT | 命中部分消耗积分 |
189| `points_total` | BIGINT | 本次调用总消耗积分 = points_input + points_output + points_cache_hit |
190| `points_rate_input` | BIGINT | 当时的输入积分单价(积分/1K token,避免配置变更后无法审计历史) |
191| `points_rate_output` | BIGINT | 当时的输出积分单价 |
192| `points_rate_cache_hit` | BIGINT | 当时的命中积分单价 |
193| `request_started_at`| TIMESTAMPTZ | |
194| `response_completed_at` | TIMESTAMPTZ | |
195| `latency_ms` | INT | |
196| `client_ip` | INET | |
197| `user_agent` | TEXT | |
198| `created_at` | TIMESTAMPTZ | |
199
200索引:
201
202```sql
203CREATE INDEX ON llm_call_log (tenant_id, created_at);
204CREATE INDEX ON llm_call_log (model_alias, created_at);
205CREATE INDEX ON llm_call_log (upstream_code, key_label, created_at);
206CREATE INDEX ON llm_call_log (status, created_at);
207```
208
209按月分区或归档,保留期不少于 180 天(可配置)。
210
211#### 4.3.2 计费聚合表 `llm_billing_aggregate`
212
213后台任务按周期(默认按小时)从 `llm_call_log` 聚合:
214
215| 字段 | 类型 | 说明 |
216| ------------------- | -------------- | ------------------------------------------------- |
217| `id` | BIGSERIAL PK | |
218| `tenant_id` | BIGINT NOT NULL| |
219| `subject_type` | TEXT NOT NULL | 主体类型(与 `llm_call_log` 一致) |
220| `subject_id` | BIGINT NOT NULL| 主体 id |
221| `period_start` | TIMESTAMPTZ | |
222| `period_end` | TIMESTAMPTZ | |
223| `period_kind` | TEXT | `hour` / `day` / `month` |
224| `model_alias` | TEXT | |
225| `upstream_code` | TEXT | |
226| `call_count` | BIGINT | |
227| `success_count` | BIGINT | |
228| `error_count` | BIGINT | |
229| `total_input_tokens`| BIGINT | |
230| `total_output_tokens`| BIGINT | |
231| `total_cache_hit_tokens` | BIGINT | |
232| `total_points` | BIGINT | 周期内总消耗积分 |
233| `created_at`/`updated_at` | TIMESTAMPTZ | |
234
235唯一约束:`(tenant_id, subject_type, subject_id, period_start, period_kind, model_alias, upstream_code)`。
236
237#### 4.3.3 积分钱包表 `llm_wallet`
238
239积分钱包归属于 `User` 或 `Space`;调用方在请求时显式选择钱包,lprovider 校验状态并扣减余额。
240
241| 字段 | 类型 | 说明 |
242| ----------------- | --------------- | --------------------------------------------------- |
243| `wallet_id` | BIGINT PK | 钱包 id(应用层生成或序列) |
244| `owner_type` | TEXT NOT NULL | `user` / `space` |
245| `owner_id` | BIGINT NOT NULL | 所属用户或空间 id |
246| `tenant_id` | BIGINT NOT NULL | 所属租户,便于跨钱包聚合 |
247| `status` | TEXT NOT NULL | `active` / `disabled` / `frozen` / `overdrawn` |
248| `balance_points` | BIGINT NOT NULL | 当前余额(积分) |
249| `total_topped_up` | BIGINT NOT NULL | 累计充值 |
250| `total_consumed` | BIGINT NOT NULL | 累计消费 |
251| `total_refunded` | BIGINT NOT NULL | 累计退款 |
252| `last_topup_at` | TIMESTAMPTZ | |
253| `last_consume_at` | TIMESTAMPTZ | |
254| `created_at`/`updated_at` | TIMESTAMPTZ | |
255
256唯一约束:`(owner_type, owner_id, wallet_id)`;查询时优先按 `wallet_id` 命中。
257
258状态迁移(仅管理 RPC 可变更状态,请求路径不改状态):
259
260- `active → frozen`(`FreezeWallet`)、`frozen → active`(`UnfreezeWallet`)。
261- `active → disabled`(`DisableWallet`,永久停用,不可恢复)。
262- `active/frozen → overdrawn`(仅管理员经 `AdjustWallet` 后手工标记;实扣使用条件更新 `balance_points >= $actual`,数据库层保证余额不会为负,运行时不会自动进入该状态)。
263
264#### 4.3.4 钱包流水表 `llm_wallet_ledger`
265
266钱包每次余额变更(含请求扣减、充值、退款、人工调整)都需落流水,确保可重放与对账。
267
268| 字段 | 类型 | 说明 |
269| ----------------- | --------------- | --------------------------------------------------- |
270| `id` | BIGSERIAL PK | |
271| `wallet_id` | BIGINT NOT NULL | |
272| `request_id` | UUID | 关联请求(消费/退款时填写) |
273| `delta_points` | BIGINT NOT NULL | 变更量(消费为负、退款/充值为正) |
274| `balance_after` | BIGINT NOT NULL | 变更后余额 |
275| `kind` | TEXT NOT NULL | `consume` / `topup` / `refund` / `adjust` |
276| `idempotency_key` | TEXT | 充值 / 人工调整的调用方幂等键(consume/refund 用 `request_id`,此列为 NULL) |
277| `reason` | TEXT | 人可读说明(充值渠道、人工调整原因等) |
278| `operator` | TEXT | 操作人 / 系统(`system:{service}` / `admin:{user}`) |
279| `created_at` | TIMESTAMPTZ | |
280
281幂等与并发安全:
282
283- 消费 / 退款:唯一约束 `(request_id, kind)` 防止重复落账(`request_id` 非 NULL)。
284- 充值 / 调整:部分唯一索引 `UNIQUE (wallet_id, kind, idempotency_key) WHERE idempotency_key IS NOT NULL` 防止重复充值;管理 RPC 必须携带 `idempotency_key`。
285- 并发安全通过 SQL 条件 `balance_points >= ABS(delta_points)` 与 `UPDATE ... RETURNING balance_after` 实现。
286
287#### 4.3.5 钱包冻结/风控记录表 `llm_wallet_freeze_log`
288
289| 字段 | 类型 | 说明 |
290| ----------------- | --------------- | --------------------------------------------------- |
291| `id` | BIGSERIAL PK | |
292| `wallet_id` | BIGINT NOT NULL | |
293| `action` | TEXT NOT NULL | `freeze` / `unfreeze` / `disable` |
294| `reason` | TEXT NOT NULL | 风控原因(异常消耗、可疑 IP、人工) |
295| `operator` | TEXT | |
296| `created_at` | TIMESTAMPTZ | |
297
298额度检查在请求进入路由前完成,超额请求直接返回 `QuotaExceeded` 错误。
299
300#### 4.3.6 租户 / 主体额度表 `llm_tenant_quota`
301
302额度数据**落库**(区别于上游配置不落库),由 `LproviderAdmin.UpsertTenantQuota` 管理:
303
304| 字段 | 类型 | 说明 |
305| ---------------------------- | -------------- | ------------------------------------------------- |
306| `id` | BIGSERIAL PK | |
307| `tenant_id` | BIGINT NOT NULL| |
308| `subject_type` | TEXT NOT NULL | `user` / `space` / `tenant`(租户级额度) |
309| `subject_id` | BIGINT NOT NULL| `subject_type='tenant'` 时为 `tenant_id` |
310| `monthly_quota_points` | BIGINT NOT NULL| 月积分额度(0 = 不限) |
311| `monthly_quota_input_tokens` | BIGINT NOT NULL| 月输入 token 额度(0 = 不限) |
312| `monthly_quota_output_tokens`| BIGINT NOT NULL| 月输出 token 额度(0 = 不限) |
313| `hard_limit` | BOOLEAN NOT NULL| true = 超额直接拒绝;false = 仅告警 |
314| `enabled` | BOOLEAN NOT NULL| |
315| `created_at`/`updated_at` | TIMESTAMPTZ | |
316
317唯一约束:`(tenant_id, subject_type, subject_id)`。额度消耗量实时来自 `llm_billing_aggregate`(当月周期)或 `llm_call_log` 增量查询,不单独维护计数器表。
318
319#### 4.3.7 管理操作审计表 `lprovider_admin_audit`
320
321`LproviderAdmin` 的所有写操作必须落库一行:
322
323| 字段 | 类型 | 说明 |
324| ---------------- | -------------- | ------------------------------------------------- |
325| `id` | BIGSERIAL PK | |
326| `operator` | TEXT NOT NULL | 管理员身份(mTLS 证书 CN / SAN 派生) |
327| `rpc_method` | TEXT NOT NULL | 如 `UpdateConfig` / `TopupWallet` |
328| `instance_id` | TEXT | 处理该请求的实例 |
329| `target` | TEXT | 操作对象(wallet_id / upstream code / 配置文件路径等) |
330| `change_summary` | TEXT | 变更摘要(脱敏后的 diff / 参数) |
331| `config_sha256` | TEXT | 配置类操作:变更后文件 SHA256 |
332| `effect_mode` | TEXT | `reload` / `restart` / `none` |
333| `result` | TEXT NOT NULL | `success` / `error` |
334| `error_message` | TEXT | 截断脱敏后的错误描述 |
335| `created_at` | TIMESTAMPTZ | |
336
337索引:`CREATE INDEX ON lprovider_admin_audit (created_at)`、`CREATE INDEX ON lprovider_admin_audit (operator, created_at)`。跨租户钱包访问拒绝事件也写入本表(`rpc_method` 记为来源 RPC,`result='error'`)。
338
339### 4.4 上游配置(不落库)
340
341完整上游定义在配置文件中,包括多 Key、限流和能力。运行时只读配置 + 写入 Redis 状态。
342
343```yaml
344lprovider:
345 enabled: true
346 bind: 0.0.0.0
347 port: 8080
348 max_context_tokens: 200000
349 max_output_tokens: 16384
350 request_timeout_ms: 60000
351 auth:
352 bearer_token: ${LLM_PROVIDER_TOKEN}
353 redis:
354 # 复用 lib/cache 已有的 redis 配置段
355 key_prefix: "lprovider"
356 session_affinity_ttl_seconds: 3600
357 min_prefix_tokens: 64
358 prefix_token_budget: 512
359 db:
360 # 复用 lib/db 已有的连接配置;仅用于审计/计费持久化
361 secret:
362 # KMS / envelope master key 引用
363 pricing:
364 # 整体计费采用积分制,1 point = 1 积分(整数,避免浮点精度问题)
365 # 单价单位:积分 / 1K token;调用时按实际 token 数换算为积分(向上取整,见 §8.3)
366 # key 是实际路由到的上游 code(upstreams[*].code),不是 alias;
367 # auto 模式下由选择器选中的上游直接作为 pricing key,确保计费维度与路由维度一致。
368 upstreams:
369 openai-gpt4o:
370 points_per_1k_input_tokens: 100
371 points_per_1k_output_tokens: 300
372 points_per_1k_cache_hit_tokens: 10 # 命中折扣,可设为 0 表示命中不计费
373 openai-mini:
374 points_per_1k_input_tokens: 10
375 points_per_1k_output_tokens: 30
376 points_per_1k_cache_hit_tokens: 1
377 anthropic-sonnet:
378 points_per_1k_input_tokens: 250
379 points_per_1k_output_tokens: 750
380 points_per_1k_cache_hit_tokens: 25
381 # 每个模型能力在 models[*].capabilities 与 upstreams[*].capabilities 中完整声明,
382 # 路由筛选、能力自动选择完全依赖配置文件。
383 models:
384 # 固定保留别名 `auto`:所有请求(HTTP / RPC)默认走 auto,调用方无需关心具体模型
385 - alias: auto
386 description: "自动选择最优上游模型"
387 default_strategy: BALANCED
388 # 可选:按调用方 priority 覆盖默认策略(两级阈值,与决策 #27 一致)
389 priority_strategy_overrides:
390 # priority >= 10 → QUALITY
391 # priority < -10 → COST
392 # 其他 → BALANCED(默认)
393 upstreams:
394 - code: openai-gpt4o
395 provider_type: openai
396 base_url: https://api.openai.com
397 default_model: gpt-4o
398 capabilities: [streaming, tools, vision, structured, reasoning]
399 context_window: 128000
400 max_output: 16384
401 cost_tier: 3 # 1-5,越高越贵
402 quality_tier: 4 # 1-5,越高越强
403 latency_tier: 3 # 1-5,越高越慢
404 tags: [premium, vision]
405 credentials:
406 - label: team-A-key-1
407 credential_ref: env:OPENAI_KEY_1
408 qps_limit: 100
409 rpm_limit: 3500
410 - label: team-A-key-2
411 credential_ref: env:OPENAI_KEY_2
412 qps_limit: 80
413 rpm_limit: 3000
414 - code: openai-mini
415 provider_type: openai
416 base_url: https://api.openai.com
417 default_model: gpt-4o-mini
418 capabilities: [streaming, tools, structured]
419 context_window: 128000
420 max_output: 16384
421 cost_tier: 1
422 quality_tier: 2
423 latency_tier: 2
424 tags: [economy, fast]
425 credentials:
426 - label: backup-key-1
427 credential_ref: env:OPENAI_BACKUP_KEY
428 - code: anthropic-sonnet
429 provider_type: anthropic
430 base_url: https://api.anthropic.com
431 default_model: claude-3-5-sonnet
432 capabilities: [streaming, tools, vision, structured, reasoning]
433 context_window: 200000
434 max_output: 8192
435 cost_tier: 4
436 quality_tier: 5
437 latency_tier: 4
438 tags: [premium, long-context]
439```
440
441凭据通过 `credential_ref` 引用环境变量、Secret 或 KMS,运行时由 `lib/secret` 解密;绝不明文落盘或不加脱敏地输出日志。
442
443## 5. 对外 HTTP API(OpenAI-compatible,仅外部客户端使用)
444
445对外 HTTP 仅服务**外部客户端**(第三方网关、浏览器直接调用、用户脚本等),协议与 OpenAI Chat Completions API 兼容;平台内部服务统一使用 §6.1 的 `LproviderAI` gRPC。
446
447### 5.1 模型请求接口
448
449```text
450POST /v1/chat/completions
451```
452
453接口支持:
454
455- 普通 JSON 响应。
456- `stream=true` 时返回 SSE 流式响应。
457- `model` 使用平台统一模型别名;**默认且推荐填写 `auto`**,由 lprovider 自动选择最优上游;HTTP 路径省略 `model` 也按 `auto` 处理。
458- 请求内容转换为现有 `agent/llm` 的 `ChatRequest`。
459- 普通响应转换为 OpenAI-compatible JSON。
460- 内部 `StreamEvent` 转换为兼容 SSE 事件,并发送结束标记。
461- 转发上游响应中的 `usage.prompt_cache_hit_tokens` / `prompt_cache_miss_tokens`。
462- 客户端 header `X-Session-Key` 可选显式覆盖亲和键;不提供时按前缀哈希生成。
463- 客户端 header `X-Tenant-Id` 必填。
464- **客户端 header `X-Wallet-Id` 必填**(整数 wallet_id),HTTP 路径匿名一律拒绝;服务端从 DB 校验钱包状态并扣减。
465- **鉴权**:`Authorization: Bearer <lprovider.auth.bearer_token>`(决策 #4);缺失或不匹配返回 401。注意:自定义 `X-*` header 与 Bearer Token 要求意味着标准 OpenAI SDK 需以扩展 header 方式接入,文档中需向调用方明示。
466
467### 5.2 健康与运行状态接口
468
469```text
470GET /health/live
471GET /health/ready
472GET /v1/models
473```
474
475- `/health/live`:进程存活检查,不依赖后端,可用于 K8s livenessProbe;返回 200 表示进程仍在运行。
476- `/health/ready`:**仅反映进程级健康**:配置有效、Redis 可达、数据库可写、配置快照已加载;返回 503 表示实例暂时不接流量;用于 K8s readinessProbe / LB 健康检查。**数据库可写是 ready 的必要条件**:HTTP 路径强制钱包实扣,DB 不可用时无法完成余额校验与扣减,摘除流量是正确行为(与 §8.1「审计失败不阻断响应」不冲突——审计可降级重试,计费扣减不可降级)。
477- `/v1/models`:返回平台暴露的统一模型别名及能力摘要,不泄露敏感凭据。
478- 集群模式下 L4/L7 LB 应基于 `/health/ready` 摘除异常实例。
479- **上游可用性独立于 `/health/ready`**:所有上游同时进入 cooldown 不应让 LB 摘除全部实例;上游可用性通过单独的 `RuntimeStatus.upstream_summary`(gRPC 查询)与告警机制反映。
480
481## 6. 内部 RPC 接口(gRPC)
482
483内部 RPC 通过 **gRPC(tonic)** 暴露,**按服务分端口**与对外 HTTP 完全隔离:
484
485| service | 默认绑定 | 鉴权 | 调用方 |
486| --------------------- | ----------------- | ---------------- | ------------------------------- |
487| `LproviderAI` | `0.0.0.0:9090` | **无鉴权**(信任集群内部网络;§10 网络分段做防御) | 平台内部其他服务(`agent/*` / `chat/*` / `app/*`) |
488| `LproviderAdmin` | `0.0.0.0:9443` | **强制 mTLS** | 运维平台 / 配置中心 |
489
490分端口的原因:mTLS 在 TLS 握手层生效,作用于整个连接,无法在同一端口对不同 gRPC service 差异化鉴权;因此「AI 不鉴权 + Admin mTLS」必须拆端口实现。
491
492### 6.1 AI 推理服务 `LproviderAI`
493
494提供与对外 HTTP 等价的 AI 推理能力,但具有强类型、原生流式、显式租户等增强特性。平台内部服务(如 `agent/*`、`chat/*`、`app/*`)应优先使用本服务而非 HTTP。
495
496```proto
497syntax = "proto3";
498package lprovider.ai.v1;
499
500import "google/protobuf/struct.proto";
501
502service LproviderAI {
503 // 单次推理(非流式)
504 rpc Chat(ChatRequestMsg) returns (ChatResponseMsg);
505
506 // 服务端流式推理
507 rpc ChatStream(ChatRequestMsg) returns (stream StreamEventMsg);
508
509 // 客户端流式推理(多轮增量输入)
510 // request_id 语义:与整个流绑定(取流中第一条消息的 request_id),
511 // 同一流内后续消息不得变更 request_id;Cancel 按该 request_id 终止整个流。
512 rpc ChatStreamBidirectional(stream ChatRequestMsg) returns (stream StreamEventMsg);
513
514 // Token 估算(不实际调用上游)
515 rpc CountTokens(CountTokensRequest) returns (CountTokensResponse);
516
517 // 取消进行中的流式推理(通过 metadata 中的 request_id 标识)
518 rpc Cancel(CancelRequest) returns (CancelResponse);
519
520 // 健康检查(gRPC 标准 health.v1 服务)
521 // 由 tonic-health 自动提供
522}
523
524message ChatRequestMsg {
525 // 与内部 ChatRequest 一一对应(强类型 proto)
526 string model_alias = 1;
527 repeated ChatMessageMsg messages = 2;
528 repeated ToolMsg tools = 3;
529 optional int32 max_output_tokens = 4;
530 optional double temperature = 5;
531 optional double top_p = 6;
532 optional ResponseFormatMsg response_format = 7;
533 repeated string stop_sequences = 8;
534 google.protobuf.Struct provider_options = 9;
535
536 // 内部增强字段(HTTP 协议无对应项)
537 fixed64 tenant_id = 20; // 必填;HTTP 路径从 X-Tenant-Id 派生
538 int64 api_key_id = 21; // 调用方 API Key id(与 llm_call_log.api_key_id BIGINT 一致;0 = 未提供)
539 string caller_service = 22; // 来源服务名(用于审计与配额维度)
540 string request_id = 23; // 调用方生成的请求 id,用于取消与审计关联;
541 // 留空时由服务端生成(HTTP 路径总是服务端生成)
542 int32 priority = 24; // 0=默认,越高越优先(同一租户内部)
543 string trace_id = 25; // 全链路 trace
544 map<string, string> labels = 26; // 自定义标签,用于按维度路由或过滤
545
546 // 计费模式:钱包扣减或匿名(仅内部 RPC AI 允许匿名)
547 WalletSelector wallet_selector = 27; // 必填;HTTP 路径通过 X-Wallet-Id 等价
548
549 // 模型选择(auto 路由策略)
550 ModelSelection model_selection = 28; // 详见 ModelSelection;默认 BALANCED
551
552 // 会话亲和性
553 string session_key_override = 30; // 显式亲和键(校验后使用)
554}
555
556// 模型选择策略;固定别名 `auto` 时由内嵌选择器使用
557message ModelSelection {
558 // 策略类型
559 enum Strategy {
560 STRATEGY_UNSPECIFIED = 0; // 服务端使用 BALANCED
561 BALANCED = 1; // 默认:综合 cost / quality / latency 评分
562 COST = 2; // 优先单价最低
563 QUALITY = 3; // 优先能力 / 质量最高
564 LATENCY = 4; // 优先响应最快
565 EXPLICIT = 5; // 调用方在 allowed_models 中点名
566 }
567 Strategy strategy = 1;
568
569 // 强制候选范围:留空表示所有已启用上游
570 repeated string allowed_upstreams = 2; // 例如 ["openai-gpt4o", "openai-mini"]
571
572 // 强制排除:例如拒绝使用特定高成本上游
573 repeated string excluded_upstreams = 3;
574
575 // 质量 / 速度偏置(仅 BALANCED 生效):正数偏向 quality,负数偏向 cost
576 int32 balance_bias = 4; // 默认 0
577
578 // 强制最小能力(即使请求没声明也必须满足)
579 repeated string required_capabilities = 5;
580}
581
582// 计费模式选择:钱包扣减或匿名
583// 规则:
584// - internal RPC AI:wallet_selector 必填,可选 Wallet 或 Anonymous
585// - HTTP :wallet_selector 必须为 Wallet,Anonymous 一律拒绝
586// - internal RPC mgmt:wallet_selector 必须为 Wallet(即使操作只读也需可追溯租户)
587message WalletSelector {
588 oneof mode {
589 WalletRef wallet = 1; // 选择具体钱包扣减
590 AnonymousRef anonymous = 2; // 匿名(仅 internal RPC AI 允许)
591 }
592}
593
594message WalletRef {
595 int64 wallet_id = 1; // 调用方显式选择的钱包 id
596 // 服务端会校验(与 §7.5.1 / §10.2 R-1 口径一致):
597 // 1. wallet 存在且 status='active'
598 // 2. owner 属于调用方 tenant_id(或调用方为 admin)
599 // 3. 最坏情况预校验:balance_points >= estimated_max_cost
600 // (estimated_max_cost 按 input 估算 + max_output_tokens 与候选上游最高单价计算)
601 // 余额不足立即返回 InsufficientBalance,不发起上游调用
602 // 不做预留 / 预扣;调用成功后按实际 points_total 一次性原子扣减;
603 // 扣减失败(并发耗尽)返回 WalletUnavailable 并记 error_kind='wallet_debit_failed'
604}
605
606message AnonymousRef {
607 // 显式空体;服务端识别后:
608 // - 不做钱包扣减
609 // - llm_call_log.anonymous=true 且 wallet_id=NULL
610 // - 不计费、不占用任何额度
611 // - 仍记录 usage / token / 缓存命中 / 路由信息用于成本可视化
612}
613
614message ChatMessageMsg {
615 string role = 1; // system / user / assistant / tool
616 oneof content {
617 string text = 2;
618 ContentPartsMsg parts = 3; // 多模态 / 多 part
619 }
620 string tool_call_id = 4; // tool 消息必填
621 repeated ToolCallMsg tool_calls = 5; // assistant 消息可能包含
622 string name = 6;
623 string reasoning_text = 7; // assistant 推理内容
624}
625
626message ContentPartsMsg {
627 message Part {
628 string type = 1; // text / image_url / input_audio / ...
629 string text = 2;
630 string url = 3;
631 bytes data = 4;
632 string mime_type = 5;
633 }
634 repeated Part parts = 1;
635}
636
637message ToolMsg {
638 string tool_type = 1;
639 google.protobuf.Struct function = 2;
640}
641
642message ToolCallMsg {
643 string id = 1;
644 string tool_type = 2;
645 google.protobuf.Struct function = 3;
646}
647
648message ResponseFormatMsg {
649 string type = 1; // text / json_object / json_schema
650 string json_schema = 2;
651}
652
653message ChatResponseMsg {
654 string id = 1;
655 string model_alias = 2;
656 string upstream_code = 3;
657 string key_label = 4;
658 string content = 5;
659 string reasoning_text = 6;
660 repeated ToolCallMsg tool_calls = 7;
661 string finish_reason = 8; // stop / tool_use / max_tokens / content_filter / error
662 UsageMsg usage = 9;
663 google.protobuf.Struct raw_provider_response = 10;
664 int64 latency_ms = 11;
665 string request_id = 12;
666}
667
668message StreamEventMsg {
669 string event_type = 1; // text_delta / reasoning_delta / tool_call_delta / usage / done / error
670 string text_delta = 2;
671 string reasoning_delta = 3;
672 ToolCallDeltaMsg tool_call_delta = 4;
673 UsageMsg usage = 5;
674 string finish_reason = 6;
675 string error_kind = 7;
676 string error_message = 8;
677 string request_id = 9;
678 string session_key = 10;
679}
680
681message ToolCallDeltaMsg {
682 string id = 1;
683 int32 index = 2;
684 string tool_type = 3;
685 string name_delta = 4;
686 string arguments_delta = 5;
687}
688
689message UsageMsg {
690 int32 prompt_tokens = 1;
691 int32 completion_tokens = 2;
692 int32 total_tokens = 3;
693 int32 cache_hit_tokens = 4; // prompt_cache_hit_tokens
694 int32 cache_miss_tokens = 5; // prompt_cache_miss_tokens
695 int64 points_input = 6;
696 int64 points_output = 7;
697 int64 points_cache_hit = 8;
698 int64 points_total = 9;
699}
700
701message CountTokensRequest {
702 string model_alias = 1;
703 repeated ChatMessageMsg messages = 2;
704 repeated ToolMsg tools = 3;
705}
706
707message CountTokensResponse {
708 int32 estimated_input_tokens = 1;
709 int32 estimated_output_tokens = 2;
710 int32 max_context_tokens = 3;
711 int32 max_output_tokens = 4;
712 bool fits_context = 5;
713}
714
715message CancelRequest { string request_id = 1; }
716message CancelResponse { bool cancelled = 1; }
717```
718
719要点:
720
721- `ChatRequestMsg` 是 `agent/llm::ChatRequest` 的强类型 proto 映射,**不是** OpenAI 协议的复刻,避免内部被外部协议绑架。
722- HTTP 路径在边缘做 OpenAI ↔ `ChatRequestMsg` 转换;RPC 路径直接使用 `ChatRequestMsg`,降低序列化损耗与歧义。
723- 取消通过独立的 `Cancel` RPC + `request_id` 实现,避免依赖 gRPC context cancellation(流断开时上游可能已写入响应)。
724- 流式响应使用 tonic 原生 server streaming,客户端可使用 tonic-client 的 `tokio_stream` 包装,无需自己解析 SSE。
725- `priority` 字段允许同一租户内部不同调用方抢占配额(仅在配额允许时生效)。
726
727### 6.2 管理服务 `LproviderAdmin`
728
729管理接口通过 **gRPC(tonic)** 暴露,绑定到 `0.0.0.0:9443`,强制 mTLS,与对外 HTTP / AI 推理 RPC 完全分离。
730
731```proto
732syntax = "proto3";
733package lprovider.admin.v1;
734
735service LproviderAdmin {
736 // 配置文件与运行生命周期(核心能力)
737 rpc GetConfig(GetConfigRequest) returns (GetConfigResponse);
738 rpc UpdateConfig(UpdateConfigRequest) returns (UpdateConfigResponse); // 修改本地配置文件
739 rpc ReloadConfig(ReloadConfigRequest) returns (ReloadConfigResponse); // 热加载(无需重启)
740 rpc RestartService(RestartServiceRequest) returns (RestartServiceResponse); // 优雅重启
741
742 // 上游(与配置文件镜像,仅查询,不落库)
743 rpc ListUpstreams(ListUpstreamsRequest) returns (ListUpstreamsResponse);
744 rpc GetUpstream(GetUpstreamRequest) returns (Upstream);
745
746 // 上游 Key(仅查询,不落库)
747 rpc ListUpstreamKeys(ListUpstreamKeysRequest) returns (ListUpstreamKeysResponse);
748 rpc GetUpstreamKey(GetUpstreamKeyRequest) returns (UpstreamKey);
749
750 // 会话亲和性
751 rpc ListBindings(ListBindingsRequest) returns (ListBindingsResponse);
752 rpc InvalidateBinding(InvalidateBindingRequest) returns (InvalidateBindingResponse);
753
754 // 审计与计费
755 rpc QueryAudit(QueryAuditRequest) returns (QueryAuditResponse);
756 rpc AggregateBilling(AggregateBillingRequest) returns (AggregateBillingResponse);
757 rpc ListTenantQuotas(ListTenantQuotasRequest) returns (ListTenantQuotasResponse);
758 rpc UpsertTenantQuota(UpsertTenantQuotaRequest) returns (TenantQuota);
759
760 // 钱包管理
761 rpc GetWallet(GetWalletRequest) returns (Wallet);
762 rpc ListWallets(ListWalletsRequest) returns (ListWalletsResponse);
763 rpc TopupWallet(TopupWalletRequest) returns (Wallet);
764 rpc RefundWallet(RefundWalletRequest) returns (Wallet);
765 rpc AdjustWallet(AdjustWalletRequest) returns (Wallet);
766 rpc FreezeWallet(FreezeWalletRequest) returns (Wallet);
767 rpc UnfreezeWallet(UnfreezeWalletRequest) returns (Wallet);
768 rpc DisableWallet(DisableWalletRequest) returns (Wallet);
769 rpc ListWalletLedger(ListWalletLedgerRequest) returns (ListWalletLedgerResponse);
770 rpc ListWalletFreezeLog(ListWalletFreezeLogRequest) returns (ListWalletFreezeLogResponse);
771
772 // 健康与运行
773 rpc GetRuntimeStatus(GetRuntimeStatusRequest) returns (RuntimeStatus);
774 rpc ListInstances(ListInstancesRequest) returns (ListInstancesResponse); // 集群模式下查看所有实例
775}
776
777message Upstream {
778 string code = 1;
779 string provider_type = 2;
780 string base_url = 3;
781 string default_model = 4;
782 repeated string capabilities = 5;
783 int32 context_window = 6;
784 int32 max_output = 7;
785 bool enabled = 8;
786 int32 cost_tier = 9;
787 int32 quality_tier = 10;
788 int32 latency_tier = 11;
789 repeated string tags = 12;
790}
791message UpstreamKey {
792 string upstream_code = 1;
793 string label = 2;
794 string credential_ref = 3; // 仅引用,绝不回显明文(§6.5)
795 string key_fingerprint = 4;
796 string status = 5; // active / disabled / cooldown
797 int32 qps_limit = 6;
798 int32 rpm_limit = 7;
799 int64 cooldown_until_unix = 8;
800 KeyStats stats = 9; // 成功 / 失败 / 限流 / in-flight 计数
801}
802message KeyStats {
803 int64 success_count = 1;
804 int64 error_count = 2;
805 int64 rate_limited_count = 3;
806 int32 in_flight = 4;
807}
808message SessionBinding {
809 string model_alias = 1;
810 string session_key = 2;
811 string upstream_code = 3;
812 string key_label = 4;
813 string prefix_hash = 5;
814 int64 bound_at_unix = 6;
815 int64 expires_at_unix = 7;
816 int32 last_hit_tokens = 8;
817 int64 hit_count = 9;
818}
819message CallRecord {
820 // llm_call_log 全字段一一映射(字段名与表列一致,时间用 unix 毫秒)
821 int64 id = 1;
822 string request_id = 2;
823 string trace_id = 3;
824 int64 tenant_id = 4;
825 int64 api_key_id = 5;
826 string subject_type = 6;
827 int64 subject_id = 7;
828 int64 wallet_id = 8; // 0 表示匿名(NULL)
829 bool anonymous = 9;
830 string subject_path = 10;
831 string session_key = 11;
832 string model_alias = 12;
833 string upstream_code = 13;
834 string requested_model = 14;
835 string chosen_strategy = 15;
836 int32 tier_cost = 16;
837 int32 tier_quality = 17;
838 int32 tier_latency = 18;
839 string key_label = 19;
840 string caller = 20;
841 string caller_service = 21;
842 int32 priority = 22;
843 bool streamed = 23;
844 string status = 24;
845 string error_kind = 25;
846 string error_message = 26;
847 int32 prompt_tokens = 27;
848 int32 completion_tokens = 28;
849 int32 cache_hit_tokens = 29;
850 int32 cache_miss_tokens = 30;
851 int32 billable_input_tokens = 31;
852 int32 billable_output_tokens = 32;
853 int64 points_input = 33;
854 int64 points_output = 34;
855 int64 points_cache_hit = 35;
856 int64 points_total = 36;
857 int64 points_rate_input = 37;
858 int64 points_rate_output = 38;
859 int64 points_rate_cache_hit = 39;
860 int64 request_started_at_ms = 40;
861 int64 response_completed_at_ms = 41;
862 int32 latency_ms = 42;
863 string client_ip = 43;
864 string user_agent = 44;
865 int64 created_at_ms = 45;
866}
867message BillingAggregate {
868 // llm_billing_aggregate 全字段一一映射
869 int64 id = 1;
870 int64 tenant_id = 2;
871 string subject_type = 3;
872 int64 subject_id = 4;
873 int64 period_start_ms = 5;
874 int64 period_end_ms = 6;
875 string period_kind = 7;
876 string model_alias = 8;
877 string upstream_code = 9;
878 int64 call_count = 10;
879 int64 success_count = 11;
880 int64 error_count = 12;
881 int64 total_input_tokens = 13;
882 int64 total_output_tokens = 14;
883 int64 total_cache_hit_tokens = 15;
884 int64 total_points = 16;
885 int64 updated_at_ms = 17;
886}
887message Wallet {
888 int64 wallet_id = 1;
889 string owner_type = 2; // user / space
890 int64 owner_id = 3;
891 int64 tenant_id = 4;
892 string status = 5; // active / disabled / frozen / overdrawn
893 int64 balance_points = 6;
894 int64 total_topped_up = 7;
895 int64 total_consumed = 8;
896 int64 total_refunded = 9;
897 int64 created_at_unix = 10;
898 int64 updated_at_unix = 11;
899}
900message GetWalletRequest { int64 wallet_id = 1; }
901message ListWalletsRequest {
902 int64 tenant_id = 1;
903 string owner_type = 2;
904 int64 owner_id = 3;
905}
906message ListWalletsResponse { repeated Wallet wallets = 1; }
907message TopupWalletRequest {
908 int64 wallet_id = 1;
909 int64 delta_points = 2; // 必须为正
910 string reason = 3;
911 string operator = 4;
912 string idempotency_key = 5; // 必填;防止重复充值(落 llm_wallet_ledger.idempotency_key)
913}
914message RefundWalletRequest {
915 int64 wallet_id = 1;
916 int64 delta_points = 2; // 必须为正
917 string request_id = 3; // 关联原始消费请求
918 string reason = 4;
919 string operator = 5;
920}
921message AdjustWalletRequest {
922 int64 wallet_id = 1;
923 int64 delta_points = 2; // 可正可负
924 string reason = 3;
925 string operator = 4;
926 string idempotency_key = 5; // 必填;防止重复调整
927}
928message FreezeWalletRequest { int64 wallet_id = 1; string reason = 2; string operator = 3; }
929message UnfreezeWalletRequest { int64 wallet_id = 1; string reason = 2; string operator = 3; }
930message DisableWalletRequest { int64 wallet_id = 1; string reason = 2; string operator = 3; }
931message WalletLedgerEntry {
932 int64 id = 1;
933 int64 wallet_id = 2;
934 string request_id = 3;
935 int64 delta_points = 4;
936 int64 balance_after = 5;
937 string kind = 6;
938 string reason = 7;
939 string operator = 8;
940 int64 created_at_unix = 9;
941}
942message ListWalletLedgerRequest { int64 wallet_id = 1; int64 from_unix = 2; int64 to_unix = 3; }
943message ListWalletLedgerResponse { repeated WalletLedgerEntry entries = 1; }
944message ListWalletFreezeLogRequest { int64 wallet_id = 1; }
945message WalletFreezeLogEntry {
946 int64 id = 1;
947 int64 wallet_id = 2;
948 string action = 3; // freeze / unfreeze / disable
949 string reason = 4;
950 string operator = 5;
951 int64 created_at_unix = 6;
952}
953message ListWalletFreezeLogResponse { repeated WalletFreezeLogEntry entries = 1; }
954
955// 租户 / 主体额度
956message TenantQuota {
957 int64 tenant_id = 1;
958 string subject_type = 2;
959 int64 subject_id = 3;
960 int64 monthly_quota_points = 4;
961 int64 monthly_quota_input_tokens = 5;
962 int64 monthly_quota_output_tokens = 6;
963 bool hard_limit = 7;
964 bool enabled = 8;
965 int64 updated_at_unix = 9;
966}
967message ListTenantQuotasRequest {
968 int64 tenant_id = 1;
969 string subject_type = 2;
970 int64 subject_id = 3;
971}
972message ListTenantQuotasResponse { repeated TenantQuota quotas = 1; }
973message UpsertTenantQuotaRequest { TenantQuota quota = 1; }
974message RuntimeStatus {
975 string snapshot_version = 1; // 运行时快照版本(配置 sha256 短码)
976 int32 upstream_total = 2;
977 int32 upstream_active = 3;
978 int32 upstream_cooldown = 4;
979 bool redis_ok = 5;
980 bool db_ok = 6;
981 AffinityStats affinity = 7; // 亲和命中统计
982 UpstreamSummary upstream_summary = 8; // 上游可用性汇总(§5.2 告警数据源)
983}
984message AffinityStats {
985 int64 bind_total = 1;
986 int64 hit_count = 2;
987 int64 miss_count = 3;
988}
989message UpstreamSummary {
990 string upstream_code = 1;
991 bool any_key_available = 2;
992 int32 keys_active = 3;
993 int32 keys_cooldown = 4;
994 double error_rate_5m = 5;
995}
996
997// 配置管理
998message GetConfigRequest {
999 optional bool mask_secrets = 1; // 未设置 = true(默认脱敏);显式 false 需额外审计记录
1000}
1001message GetConfigResponse {
1002 string config_path = 1;
1003 string sha256 = 2;
1004 int64 file_size_bytes = 3;
1005 int64 loaded_at_unix = 4;
1006 string content_yaml = 5;
1007 repeated string warnings = 6;
1008}
1009
1010message UpdateConfigRequest {
1011 oneof payload {
1012 string full_content_yaml = 1;
1013 ConfigPatch patch = 2;
1014 ConfigSectionPatch section_patch = 3;
1015 }
1016 bool reload_after_write = 4;
1017 bool restart_after_write = 5;
1018 string operator = 6;
1019}
1020message ConfigPatch { repeated ConfigSectionPatch sections = 1; }
1021message ConfigSectionPatch {
1022 string path = 1;
1023 google.protobuf.Struct value = 2;
1024}
1025message UpdateConfigResponse {
1026 string sha256 = 1;
1027 string backup_path = 2;
1028 bool reloaded = 3;
1029 bool restarted = 4;
1030 repeated string warnings = 5;
1031}
1032
1033message ReloadConfigRequest { string operator = 1; }
1034message ReloadConfigResponse {
1035 string previous_sha256 = 1;
1036 string current_sha256 = 2;
1037 repeated string warnings = 3;
1038 RuntimeStatus status = 4;
1039}
1040
1041message RestartServiceRequest {
1042 string operator = 1;
1043 int32 drain_timeout_seconds = 2;
1044 bool self_reexec = 3;
1045 bool rolling = 4; // 集群滚动模式:仅本实例重启
1046 string target_instance_id = 5; // 为空表示发往当前实例
1047}
1048message RestartServiceResponse { bool accepted = 1; string reason = 2; string instance_id = 3; }
1049
1050// 集群实例
1051message InstanceInfo {
1052 string instance_id = 1;
1053 string cluster_name = 2;
1054 string listen_addr = 3;
1055 int64 started_at_unix = 4;
1056 int32 pid = 5;
1057 string config_sha256 = 6;
1058 bool is_config_writer = 7;
1059}
1060message ListInstancesRequest { string cluster_name = 1; }
1061message ListInstancesResponse { repeated InstanceInfo instances = 1; }
1062```
1063
1064### 6.3 配置文件修改与热加载/重启
1065
1066管理接口的**核心能力**是修改本地配置文件并触发生效,流程:
1067
1068```text
1069UpdateConfig → 校验 patch → 原子写盘 (tmp + fsync + rename) → 备份 .bak
1070 → reload_after_write=true? → ReloadConfig → 校验 → 替换快照
1071 → restart_after_write=true? → RestartService → drain → 退出/自 exec
1072```
1073
1074约束:
1075
1076- **写盘原子性**:先写 `config.yaml.tmp`,fsync 后 `rename` 为 `config.yaml`;旧文件备份为 `config.yaml.bak-{unix_ts}`。
1077- **写盘前必须校验**:使用与启动相同的 `validate()` 流程;校验失败 → 拒绝写盘并返回错误,不留半成品文件。
1078- **能力声明唯一来源**:所有模型能力、路由、积分单价完全来自配置文件,不允许 RPC 绕过配置文件直接修改运行时结构。
1079- **ReloadConfig 与 RestartService 选择**:
1080 - 改动 `models` / `upstreams` / `pricing` / `auth` 等业务段:优先 ReloadConfig。
1081 - 改动 `bind` / `port` / `redis` / `db` / `secret` 等基础设施段:必须 RestartService。
1082 - 当段归属无法判定或客户端显式 `restart_after_write=true` 时执行 RestartService。
1083- **ReloadConfig 失败回滚**:校验失败时旧快照继续生效,并在响应 `warnings` 中说明。
1084- **RestartService 实现**:Linux 优先 SIGHUP(外部管理器如 systemd / k8s 负责拉起),也可选 self-exec(保存 CLI 参数,`execvp` 自身);drain 阶段停止接收新请求并等待进行中请求在 `drain_timeout_seconds` 内结束。
1085- **生效审计**:每次成功的 UpdateConfig / ReloadConfig / RestartService 必须落库 `lprovider_admin_audit`,记录 operator、文件 SHA256、变更摘要、生效方式(reload/restart)。
1086
1087### 6.4 鉴权与暴露范围
1088
1089- **AI 推理 RPC(`LproviderAI`)监听 `0.0.0.0:9090`,不要求调用方鉴权**(信任集群内部网络,依赖 §10 网络分段作为防御深度);调用方由请求中 `tenant_id` + `caller_service` 标识,跨租户访问在 owner 校验阶段拒绝。
1090- **管理 RPC(`LproviderAdmin`)监听 `0.0.0.0:9443`,强制 mTLS**;客户端证书由 `lib/secret` 注入。
1091- gRPC 端口与对外 HTTP 端口**独立**,可施加不同的网络安全策略。
1092- 所有写操作(UpdateConfig / ReloadConfig / RestartService / InvalidateBinding / UpsertTenantQuota / Wallet 管理 RPC)必须记录管理员身份、操作时间、操作内容到独立审计表 `lprovider_admin_audit`(表结构见 §4.3.7)。
1093
1094### 6.5 凭据回显规则
1095
1096`GetUpstreamKey` 响应**只返回** `credential_ref` 和 `key_fingerprint`,绝不返回明文或可解密的密文。`GetConfig` 默认脱敏(`mask_secrets=true`),所有 `credential_ref` 引用的真实值在响应中替换为 `***`。轮换密钥必须通过 `RotateUpstreamKey` RPC 完成(首版可选;密钥轮换可由外部 secret manager 触发)。
1097
1098### 6.6 集群模式下的配置与重启协调
1099
1100集群模式下管理 RPC 的写操作需要协调,避免多实例并发写盘 / 同时重启导致配置漂移:
1101
1102- **配置写盘选主**:基于 Redis 的分布式锁实现(例如 `lprovider:lock:config_writer`,TTL 30s,自动续约)。任一实例获得锁后方可处理 `UpdateConfig`;其他实例同时收到写请求时返回 `RESOURCE_LOCKED` 错误。
1103- **热加载(ReloadConfig)**:仅本实例生效;调用方应在集群模式下对每个实例依次调用(或通过外部编排系统按 `--instance-id` 路由)。
1104- **优雅重启(RestartService)**:
1105 - 默认行为:仅本实例重启;调用方应串行对每个实例执行,或借助 K8s `RollingUpdate` / systemd 分批重启。
1106 - 可选 `rolling=true`:本实例返回 `accepted=true` 后仅自己重启;客户端按 `--instance-id` 列表逐个触发。
1107 - `drain_timeout_seconds` 默认 30,期间停止接收新请求,等待进行中请求结束。
1108- **配置漂移防护**:每个实例启动时记录 `loaded_sha256` 到 Redis `lprovider:config_sha:{instance_id}`;运维平台可对比所有实例的 SHA,发现漂移时触发统一重载。
1109- **重启标识**:实例启动时将 `(instance_id, started_at, pid, listen_addr)` 写入 Redis `lprovider:instances:{cluster_name}`(TTL 24h),用于 `ListInstances` 查询与存活检测。
1110
1111## 7. 路由选择与会话亲和性
1112
1113### 7.0 HTTP 与 RPC 共用同一选择器与运行时
1114
1115- 对外 HTTP(OpenAI-compatible)与内部 RPC(`LproviderAI`)共用 §7.1 ~ §7.7 的能力筛选 / 加权轮转 / 亲和绑定 / 降级轮转 / 配额账本,避免双实现。
1116- HTTP 路径在边缘做 OpenAI ↔ `ChatRequestMsg` 转换、JSON ↔ SSE 适配;RPC 路径直接消费 proto 类型。
1117- HTTP 与 RPC 路径的请求都进入同一 `llm_call_log` 与配额账本,但 `caller` 维度(`http` / `service_name`)分开统计,便于内部成本归因。
1118- 亲和绑定以 `model_alias` 为键,跨 HTTP / RPC 共享;同一租户不同入口的会话可复用上游 KV 缓存。
1119- **默认 `model_alias = auto`**,由 §7.1 选择器自动挑选上游;调用方也可在 `model` 字段直接填写某个已声明的上游 `code`(如 `openai-gpt4o`)跳过自动选择。
1120
1121### 7.1 自动模型选择器(`auto`)
1122
1123`auto` 是保留的内部模型别名,触发如下自动选择流程:
1124
1125```text
1126请求 (model_alias / model_selection / priority / 钱包余额)
1127 │
1128 ▼
11291. 解析 required_capabilities(来自请求 + ModelSelection.required_capabilities)
1130 + 估算 input_tokens + max_output_tokens
1131 │
1132 ▼
11332. 候选过滤:
1134 enabled && status=='active' && cooldown_until<now
1135 && 能力 ⊇ required_capabilities
1136 && context_window ≥ input_tokens + max_output_tokens
1137 && (allowed_upstreams ⊇ 上游 或 allowed_upstreams 为空)
1138 && 上游 ∉ excluded_upstreams
1139 │
1140 ▼
11413. 策略选择:
1142 - COST → 按 cost_tier 升序、quality_tier 降序、latency_tier 升序
1143 - QUALITY → 按 quality_tier 降序、cost_tier 升序、latency_tier 升序
1144 - LATENCY → 按 latency_tier 升序、quality_tier 降序、cost_tier 升序
1145 - BALANCED → score = w_q * quality_tier - w_c * cost_tier - w_l * latency_tier
1146 + balance_bias(>0 偏 quality,<0 偏 cost),取最高分
1147 - EXPLICIT → 按 allowed_upstreams 顺序选择第一个通过步骤 2 过滤的
1148 │
1149 ▼
11504. 策略覆盖优先级(按优先级顺序,后者覆盖前者):
1151 a. 请求显式 ModelSelection.strategy → 直接采用
1152 b. 调用方 priority 命中 priority_strategy_overrides → 采用对应策略
1153 c. 钱包余额低于策略保护阈值(默认余额 < 1000 积分)→ 强制 COST
1154 d. 默认 BALANCED
1155 │
1156 ▼
11575. 在选中的上游内通过 §7.4 加权 Round Robin 选出具体 Key
1158 │
1159 ▼
11606. 调用 + 亲和绑定(§7.6)+ 降级轮转(§7.7)
1161```
1162
1163要点:
1164
1165- `cost_tier` / `quality_tier` / `latency_tier`(1-5)在 `upstreams` 配置中声明;lprovider 不感知具体模型价格与质量,仅消费该抽象,便于新增上游时由运维手工评分。
1166- BALANCED 默认权重建议 `w_q=0.5, w_c=0.3, w_l=0.2`;可通过配置文件微调。
1167- 钱包余额保护阈值避免钱包快耗尽时被强制扣到昂贵模型上(防止积分快速归零)。
1168- 调用方在 `model` 字段直接填 `openai-gpt4o` 时跳过自动选择,但仍走能力筛选、加权 Round Robin、亲和、降级轮转。
1169- 自动选择结果(`chosen_strategy` / `score_breakdown` / `tier_*`)写入 `llm_call_log`,便于事后审计「为什么这次请求被路由到这个上游」。
1170
1171### 7.2 统一模型别名 → 上游池
1172
1173候选实例属性:
1174
1175- 上游能力。
1176- 实际模型名与 base URL。
1177- 凭据引用与 fingerprint。
1178- 权重(来自配置)。
1179- 请求超时。
1180- 最大上下文与最大输出能力。
1181- 当前 in-flight 数、连续失败次数、冷却截止时间(Redis 短期状态 + 定期回写到内存快照)。
1182
1183### 7.3 运行时快照
1184
1185采用 `Arc<tokio::sync::RwLock<...>>` 管理运行时状态:
1186
1187- 启动时从配置构建不可变快照。
1188- Redis 中的冷却 / 限流计数通过 Lua 原子更新,不直接修改快照;快照在每次请求读取时按 Redis 状态过滤。
1189- 配置变更时构造完整新快照并整体替换。
1190- 请求持有当前快照的 `Arc`,配置热更新不影响已开始的请求。
1191
1192### 7.4 能力自动筛选
1193
1194每次路由前根据请求推导所需能力集合:
1195
1196```text
1197required_capabilities =
1198 (stream ? streaming : null)
1199 ∪ (tool_choice/tool_calls ? tools : null)
1200 ∪ (response_format=json_schema ? structured : null)
1201 ∪ (images in messages ? vision : null)
1202 ∪ (thinking=true ? reasoning : null)
1203```
1204
1205筛选规则:
1206
12071. 剔除 `enabled=false` 或 Key `status='disabled'` 的候选。
12082. 剔除 Redis 中 `cooldown_until > now` 的 Key。
12093. 剔除不满足 `required_capabilities` 的候选。
12104. 剔除 `context_window < 输入估算 token + max_output_tokens` 的候选。
12115. 剩余候选按加权 Round Robin 选取;权重相等时取 in-flight 最小者打破平局。
12126. 候选为空时返回 `ProviderUnavailable` 错误。
1213
1214### 7.5 计费:钱包校验与扣减
1215
1216请求进入路由前根据 `WalletSelector` 模式分流:
1217
1218#### 7.5.1 HTTP 路径(强制钱包)
1219
12201. 从 `X-Wallet-Id` header 解析 `wallet_id`;缺失或非法立即返回 `WalletRequired`(HTTP 401/402),**HTTP 不允许匿名**。
12212. 查询 `llm_wallet`,校验 `status='active'`、`owner` 属于调用方 `tenant_id`(admin 调用方跳过 owner 校验)。
12223. **最坏情况预校验**(与 §10.2 R-1 口径一致):`estimated_max_cost = (estimated_input_tokens + max_output_tokens) * max(points_per_1k_input/output) / 1000`,按候选上游中最高单价计算;`balance_points < estimated_max_cost` 时立即返回 `InsufficientBalance`(HTTP 402),**不发起上游调用**。注意此处不是仅校验 `balance_points > 0`,避免余额不足时白烧上游成本。
12234. 调用完成后**仅实扣**:成功请求按实际 `points_total` 一次性原子扣减(不预扣、不补扣);失败请求**不扣减**、不计费。
12245. 实扣 SQL:`UPDATE llm_wallet SET balance_points = balance_points - $actual, total_consumed = total_consumed + $actual, last_consume_at = now() WHERE wallet_id=$id AND status='active' AND balance_points >= $actual RETURNING balance_after`;扣减失败(并发耗尽余额)记录 `llm_call_log.status='error'` 与 `error_kind='wallet_debit_failed'`,不再二次重试,上游成本损失按 R-1 告警人工对账。
1225
1226#### 7.5.2 内部 RPC AI 路径(钱包或匿名)
1227
12281. 从 `ChatRequestMsg.wallet_selector` 解析模式:
1229 - `Anonymous`:跳到 §7.5.3。
1230 - `Wallet`:按 HTTP 相同流程校验与扣减。
12312. 匿名时同样校验 `tenant_id` 一致,但**不查询 `llm_wallet`、不扣减**。
12323. 内部 RPC **不做调用方鉴权**(信任集群内部网络);调用方由请求中 `tenant_id` + `caller_service` 标识,跨租户访问在 owner 校验阶段拒绝。
1233
1234#### 7.5.3 匿名模式(仅内部 RPC AI)
1235
1236- 不扣减、不计费、不校验钱包。
1237- 在 `llm_call_log` 写 `anonymous=true`、`wallet_id=NULL`、`points_total=0`、`points_rate_*` 仍记录用于成本可视化。
1238- 上游真实消耗仍计入平台总成本;运营通过单独报表(`total_cost_by_upstream`)观察匿名流量占比。
1239- **无独立速率限制**(信任内部调用方);仅依赖 `subject_path` 告警 + 全平台配额告警。
1240
1241#### 7.5.4 钱包 owner 校验
1242
1243- 调用方为 admin 时跳过 owner 校验但仍校验 status 与余额。
1244- 调用方非 admin 时,必须满足 `wallet.tenant_id == caller.tenant_id` **且** (`wallet.owner_type == 'user' && wallet.owner_id == caller.user_id` **或** `wallet.owner_type == 'space' && caller 是该 space 的成员 / 管理员,通过 `lib/space` 实时校验`)。
1245- 跨租户访问钱包一律拒绝并记录 `lprovider_admin_audit` 与 `llm_call_log` 错误。
1246
1247#### 7.5.5 并发安全
1248
1249- 钱包扣减完全依赖 SQL 条件 `balance_points >= $delta` + `RETURNING balance_after`,由数据库原子保证;不在应用层做分布式锁。
1250- 同一 `request_id` 的 `consume` 流水通过 `llm_wallet_ledger (request_id, kind)` 唯一约束防止重复落账。
1251
1252### 7.6 会话亲和性(基于上游 KV 缓存 + Redis)
1253
1254参考 [DeepSeek KV Cache](https://api-docs.deepseek.com/zh-cn/guides/kv_cache) 语义:上游按“缓存前缀单元”匹配,请求前缀必须**完整**命中才能复用 KV 缓存;命中情况通过响应 `usage.prompt_cache_hit_tokens` 反馈;缓存为“尽力而为”,数小时到数天被淘汰。
1255
1256#### 7.6.1 会话键 `session_key` 计算
1257
1258```text
1259session_key = SHA256(
1260 model_alias
1261 ‖ prefix_tokens(messages, prefix_token_budget)
1262)
1263```
1264
1265要点:
1266
1267- `prefix_tokens` 取 `system` 消息 + 最早的若干 `user` 消息,按 `prefix_token_budget`(默认 512)截断。
1268- 当 `prefix_tokens` 长度 < `min_prefix_tokens`(默认 64)时**不建立绑定**,走普通轮转。
1269- 客户端可通过 `X-Session-Key` header 显式覆盖亲和键;覆盖值必须做格式校验(长度、字符集),避免注入。
1270
1271#### 7.6.2 命中流程
1272
12731. 收到请求后计算 `session_key`,并按模型别名构造候选集(见 §7.4)。
12742. 在 Redis `lprovider:bind:{model_alias}:{session_key}` 查询:
1275 - **命中且 `(upstream_code, key_label)` 仍在候选集**:复用。
1276 - **命中但实例已不可用**:回退普通轮转,并异步删除该绑定。
1277 - **未命中**:走 §7.4 的加权轮转;调用成功后建立绑定。
12783. 调用完成后依据响应 `prompt_cache_hit_tokens` 调整绑定(见 §7.6.4)。
1279
1280#### 7.6.3 绑定建立(Redis Lua 原子)
1281
1282```lua
1283-- KEYS[1] = lprovider:bind:{model_alias}:{session_key}
1284-- KEYS[2] = lprovider:bind_idx:upstream:{upstream_code}
1285-- KEYS[3] = lprovider:bind_idx:key:{upstream_code}:{key_label}
1286-- ARGV = payload_json, ttl_seconds, expire_at_unix, session_key
1287redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
1288redis.call('SADD', KEYS[2], ARGV[4])
1289redis.call('SADD', KEYS[3], ARGV[4])
1290return 1
1291```
1292
1293#### 7.6.4 命中续约
1294
1295调用完成后读取上游响应 `usage.prompt_cache_hit_tokens`:
1296
1297- `cache_hit_tokens / (cache_hit_tokens + cache_miss_tokens) >= 0.5`:
1298 - 续约 TTL 到 `now() + default_ttl_seconds`。
1299 - `last_hit_tokens = cache_hit_tokens`,`hit_count += 1`。
1300- `cache_hit_tokens == 0` 且 `cache_miss_tokens > min_prefix_tokens`:
1301 - 视为缓存已被淘汰,缩短 TTL 至 `default_ttl_seconds / 4`,下一次请求重新轮转。
1302- 持续命中失败且 `hit_count < 3` 时,由后台清理任务直接删除该绑定。
1303
1304#### 7.6.5 失效与清理
1305
1306- 后台定时任务(每分钟)清理 `expires_at < now()` 的绑定。
1307- 当 `(upstream_code, key_label)` 触发 cooldown 或被管理接口禁用时,按 `bind_idx` 批量删除对应绑定。
1308- 管理接口 `invalidate_binding(model_alias, session_key)` 强制失效。
1309
1310### 7.7 降级轮转
1311
1312- 非流式请求在首次响应前发生 retryable 错误时,切换到 §7.4 候选集的下一个实例,并把失败 Key 的 Redis 状态写为 `cooldown_until = now() + cooldown_seconds`(默认 30s)。
1313- 重试应限制最大尝试次数(默认 2 次额外尝试)。
1314- 仅对 `LlmError` 判定为 retryable 的错误执行切换:网络、限流和部分 5xx。
1315- 认证错误、请求参数错误、模型不存在、上下文超限、租户超额不能盲目重试。
1316- 流式请求在尚未向客户端发送任何 event 前发生连接/Provider 错误,可以切换实例并重建绑定。
1317- 流式请求一旦已输出内容,不能透明切换并重新发送完整结果;应结束流并返回可诊断错误。
1318- 可复用现有 `FallbackProvider` 的 retryable 判断和错误语义,实例选择、冷却、权重和亲和由 lprovider 自己管理。
1319
1320## 8. 审计与计费(严格实现)
1321
1322### 8.1 审计范围
1323
1324每次对外 HTTP 请求**必须**落库 `llm_call_log` 一行,无论成功失败、流式还是非流式。RPC 写操作必须落库 `lprovider_admin_audit` 一行。审计写入与业务响应并行执行(不阻塞),失败时记录 `tracing` 错误并由后台重试,但不允许因此阻断用户响应。
1325
1326### 8.2 审计字段保证
1327
1328- `request_id`:HTTP 路径在服务端入口生成并贯穿全链路(写入响应 header `X-Request-Id`);RPC 路径优先使用调用方提供的 `request_id`,留空时服务端生成。
1329- `tenant_id` 为**必填**维度:HTTP 路径取自 `X-Tenant-Id` header(缺失拒绝),RPC 路径取自 `ChatRequestMsg.tenant_id`(零值拒绝)。**不存在「匿名租户」回退**——`anonymous=true` 仅出现在内部 RPC AI 显式选择 `AnonymousRef` 的场景(§7.5.3),且此时 `wallet_id=NULL`、`tenant_id` 仍必须提供。
1330- 错误信息按 `error_kind` 分类存储,`error_message` 截断到 512 字符并脱敏,禁止包含 API Key 或 session_key。
1331- 上游响应原始体**不写入审计表**,只写入聚合指标与 token 数。
1332
1333### 8.3 计费规则(积分制)
1334
1335- 计费模型由 `lprovider.pricing.upstreams[upstream_code]` 配置:输入、输出、缓存命中各自的积分单价(单位:积分 / 1K token)。key 与路由维度(`upstreams[*].code`)一一对应,确保 auto 模式选中上游即可查到单价。
1336- 1 point = 1 积分,整数计算;**向上取整**(避免小数 token 被免费化):
1337
1338```text
1339billable_input_tokens = prompt_tokens - cache_hit_tokens
1340billable_output_tokens = completion_tokens
1341
1342points_input = (billable_input_tokens * points_per_1k_input_tokens + 999) / 1000
1343points_output = (billable_output_tokens * points_per_1k_output_tokens + 999) / 1000
1344points_cache_hit = (cache_hit_tokens * points_per_1k_cache_hit_tokens + 999) / 1000
1345points_total = points_input + points_output + points_cache_hit
1346```
1347
1348- 单价变更后历史请求仍按请求发生时的快照单价计费(审计表中持久化 `points_rate_*`)。
1349- **计费在请求成功完成时计算(流式取最后一个 `usage` 事件);失败请求不扣减、不计入 `points_total`,但计入 `error_count`,便于后续统计浪费率**。
1350- 计费完成后**仅一次性原子扣减**(见 §7.5.1);不预扣、不补扣。
1351- 业务侧可配置 `points_per_1k_cache_hit_tokens = 0` 实现"命中不计费"。
1352- 钱包余额(`llm_wallet.balance_points`)通过原子扣减实现;超额请求按余额是否充足直接拒绝(HTTP 401/402);匿名请求不消耗钱包。
1353
1354### 8.4 聚合与对账
1355
1356- 后台 worker 默认每 5 分钟从 `llm_call_log` 增量聚合到 `llm_billing_aggregate`,按 `(tenant_id, subject_type, subject_id, period_start, period_kind, model_alias, upstream_code)`(与 §4.3.2 唯一约束一致),天然支持按 Space / User / 跨级聚合。
1357- 每日 00:30 触发全量对账任务,比对 `llm_call_log` 与 `llm_billing_aggregate`,差异超过阈值(默认 0.1%)时上报 `tracing` 严重错误。
1358- 审计保留期 180 天可配置;超过保留期的明细按月归档或清理;聚合记录保留期 2 年。
1359
1360### 8.5 额度与告警
1361
1362- 单租户单小时钱包消耗(成功扣减)超过预设阈值(默认 100K 积分)时,发送告警(通过 `lib/obs`)。
1363- 单钱包单小时余额骤降超过阈值(默认余额 20%)时单独告警,提示可能被盗刷。
1364- 上游整体错误率超过 5% 时,发送告警并记录到审计。
1365- 匿名调用占比超过阈值(默认总流量 30%)时单独告警,提示可能被滥用。
1366
1367## 9. 统一 200K 上下文策略
1368
1369### 9.1 网关上限
1370
1371平台统一模型的网关硬上限设为:
1372
1373```text
1374200,000 tokens
1375```
1376
1377入口请求需要校验:
1378
1379```text
1380输入 token 数 + max_output_tokens <= 200,000
1381```
1382
1383无法满足时,在请求发送到后端之前返回上下文超限错误。
1384
1385### 9.2 后端真实能力
1386
1387实际请求能力取以下值的最小值:
1388
1389```text
1390平台网关上限
1391后端实例 max_context_tokens
1392```
1393
1394因此底层模型能力低于 200K 时:
1395
1396- 不对外伪装为 200K。
1397- 路由到该实例前按该实例能力做校验。
1398- 超过实例能力的请求不发送到该后端。
1399- 同一统一模型下不同实例的能力差异需要体现在模型状态或路由筛选中。
1400
1401### 9.3 Token 估算
1402
1403优先复用 `agent/llm` 当前 token 估算和能力检查逻辑。若底层 Provider 能提供更精确的 tokenizer,应在 Provider 层补充。
1404
1405需要明确输入 token 估算误差、安全余量以及 `max_tokens` 未提供时的默认值,避免边界请求在后端被拒绝。
1406
1407## 10. 安全与运维
1408
1409### 10.1 网络分段(防御深度)
1410
1411由于决策 #18 内部 RPC AI 推理不鉴权,`tenant_id` / `wallet_selector` 由请求方自报,整个信任边界依赖网络分段:
1412
1413- **强制要求**:L4/L7 LB / K8s NetworkPolicy / Service Mesh 必须限制 `0.0.0.0:9090`(AI 推理)仅允许集群内部已知服务网段访问,禁止暴露到公网或办公网。
1414- **推荐**:在 service mesh 中按 `caller_service` 标识声明 ACL,仅允许清单内服务调用 `LproviderAI`;不在清单内的服务即使在内网也被拒。
1415- **强制要求**:`0.0.0.0:9443`(Admin mTLS)仅允许运维平台 / 配置中心客户端访问。
1416- **强制要求**:对外 HTTP `0.0.0.0:8080` 与上述两个 gRPC 端口不在同一网络域。
1417
1418### 10.2 已知风险(v1 接受,后续版本处理)
1419
1420下列风险在 v1 中**显式接受**,需要在生产环境监控并规划后续版本处理:
1421
1422- **R-1:钱包余额不足时上游成本已发生**(§7.5.1 仅实扣 + 上游已完成)。v1 缓解措施:
1423 - 请求入口做**最坏情况预校验**:`estimated_max_cost = (estimated_input_tokens + max_output_tokens) * max(points_per_1k_*) / 1000`,超过余额立即 `InsufficientBalance` 返回;不发起上游调用(已纳入 §7.5.1 步骤 3 为强制流程,非可选缓解)。
1424 - 真正的硬性预留(pre-debit + 实际结算)纳入 v2,需要事务、补偿流与钱包锁的完整设计。
1425 - 上游成本无法回收时记录 `llm_call_log.error_kind='wallet_debit_failed'`,触发告警并由人工对账。
1426- **R-2:内部 RPC 身份伪造**(决策 #18)。调用方可在请求里填任意 `tenant_id` + 任意 `wallet_id`。v1 缓解措施:
1427 - 仅依赖网络分段(§10.1)。
1428 - 由 `caller_service_tenant_acl` 配置项(白名单)做应用层校验:每个 `caller_service` 允许代表的 `tenant_id` 列表;非法组合返回 `PermissionDenied`。
1429 - 真正的密码学绑定(mTLS 客户端证书 → tenant 映射)纳入 v2,需客户端证书 CN/SAN 体系。
1430- **R-3:auto 模式选中的上游不在 pricing 表**。v1 缓解措施:
1431 - `pricing.upstreams` 必须覆盖所有 `enabled` 上游,启动时校验;缺失时启动失败。
1432 - `auto` 选中后查不到单价时 fallback 到 `default_per_1k` 全局兜底(也可配)。
1433- **R-4:Cancel 与计费交互**(决策 #20)。由于不主动中断上游请求,被取消的请求若上游仍完整生成完毕,视为成功调用、正常扣积分。客户端若中途断开:
1434 - 上游已完成 → 按成功扣积分(用户在响应中断开仍需付费)。
1435 - 上游未完成(连接中断、Provider 错误)→ 不扣积分。
1436 - 客户端 header `X-Cancel-Reason: client_aborted` 不减免扣费,避免恶意刷分。
1437
1438### 10.3 安全与运维基本项
1439
1440首版至少包含:
1441
1442- 对外 HTTP 可选 Bearer Token 认证。
1443- 管理 RPC 强制 mTLS。
1444- 认证失败返回统一错误,不泄露 Provider 凭据。
1445- API Key 通过 `lib/secret` 在运行时按引用解密,绝不明文落盘或日志输出。
1446- 日志中只输出 `key_fingerprint`。
1447- 调用日志按月归档或分区,保留期不少于 180 天。
1448- 请求 ID、统一模型别名、后端实例标识、耗时和错误类型可观测。
1449- 对后端实例记录成功、失败、限流、冷却和恢复状态。
1450- `/health/live` 与 `/health/ready` 语义区分(详见 §5.2)。
1451- 支持 Ctrl-C/SIGTERM graceful shutdown。
1452- 关闭时停止接收新请求,等待正在运行的请求在超时内结束。
1453- **长流式 / 双向流的优雅关闭**:drain 阶段对 `ChatStream` 单向流,等待事件自然结束或客户端断开;对 `ChatStreamBidirectional` 双向流,drain 时立即返回错误事件 `StreamEventMsg { event_type="server_draining" }`,通知客户端主动重连到其他实例(客户端基于 `X-Instance-Id` header 或服务发现重路由);超过 `drain_timeout_seconds`(默认 30s)后强制断开。
1454
1455限流、配额、计费按 §8 实现;其他细粒度租户认证复用平台能力。
1456
1457## 11. 计划修改的文件
1458
1459首版实现预计涉及以下文件:
1460
1461### lprovider 模块
1462
1463- `agent/lprovider/Cargo.toml`(新增 tonic、prost、tonic-health、lib/cache、lib/db、lib/secret 依赖与 build-dependencies)
1464- `agent/lprovider/build.rs` # tonic-build 编译 ai.proto / admin.proto(必需,遗漏将导致 proto 无法生成)
1465- `agent/lprovider/lib.rs`
1466- `agent/lprovider/main.rs`
1467- `agent/lprovider/config.rs`
1468- `agent/lprovider/credentials.rs` # 凭据引用解析与缓存(委托 lib/secret)
1469- `agent/lprovider/upstream.rs` # 上游与 Key 状态(内存 + Redis)
1470- `agent/lprovider/selector.rs` # 能力筛选 + 加权轮转 + auto 策略打分
1471- `agent/lprovider/affinity.rs` # 会话键计算与 Redis 绑定管理
1472- `agent/lprovider/rotation.rs` # 冷却、限流、降级
1473- `agent/lprovider/pool.rs` # Provider 实例池与运行时快照
1474- `agent/lprovider/router.rs` # 对外 HTTP(OpenAI-compatible)
1475- `agent/lprovider/ai_grpc.rs` # gRPC AI 推理服务(LproviderAI)
1476- `agent/lprovider/admin_grpc.rs` # gRPC 管理服务(LproviderAdmin)
1477- `agent/lprovider/wallet.rs` # 钱包校验、原子扣减、流水与幂等
1478- `agent/lprovider/quota.rs` # 租户/主体额度检查
1479- `agent/lprovider/ai.proto` # AI 推理 Proto 定义
1480- `agent/lprovider/admin.proto` # 管理 Proto 定义
1481- `agent/lprovider/error.rs`
1482- `agent/lprovider/audit.rs` # 审计与计费写入
1483- `agent/lprovider/billing.rs` # 计费规则与聚合任务
1484- 数据库表(走 `lib/db` 现有 sea-orm schema-sync 机制:entity 定义 + `schema_extras.sql`,由 `app/migrate` 执行;仓库无独立 migrations 目录):
1485 - `llm_call_log`
1486 - `llm_billing_aggregate`
1487 - `llm_wallet`
1488 - `llm_wallet_ledger`
1489 - `llm_wallet_freeze_log`
1490 - `llm_tenant_quota`
1491 - `lprovider_admin_audit`
1492
1493### 公共配置与依赖
1494
1495- `lib/config/src/app.rs`(新增 `lprovider` 段)
1496- 必要时更新配置示例文件
1497- 根 `Cargo.toml`(新增 `tonic`、`prost`、`tonic-build`、`tonic-health` 工作区依赖)
1498- **新建 `lib/secret` crate**:`credential_ref` 解析(env / KMS / envelope 加密)、运行时解密 API Key、mTLS 客户端证书注入;当前 workspace 不存在该模块,为 lprovider 的前置依赖
1499
1500### 测试
1501
1502- 配置源单元测试。
1503- 配置文件、环境变量覆盖和非法配置测试。
1504- 凭据引用解析与 fingerprint 测试。
1505- 能力筛选、加权轮转、in-flight 与冷却测试。
1506- 会话键计算、Redis 绑定建立、命中续约与失效测试。
1507- 降级轮转与流式错误语义测试。
1508- 200K 上下文边界测试。
1509- 普通 JSON 和 SSE 响应测试。
1510- gRPC `LproviderAI` 单轮 / 流式 / 双向流 / CountTokens / Cancel 语义测试(含 request_id 生成与流绑定)。
1511- gRPC 管理接口 CRUD、鉴权、健康检查测试。
1512- 审计写入、计费计算、聚合任务测试。
1513- 租户额度检查与告警测试。
1514- 优雅关闭和并发请求测试。
1515
1516## 12. 实施阶段
1517
1518### 阶段一:配置、依赖与服务骨架
1519
1520- **新建 `lib/secret` crate 骨架**(credential_ref 解析 + envelope 解密 + mTLS 证书注入),作为后续凭据与 Admin 鉴权的前置。
1521- 增加 lprovider 配置类型和校验(含上游、Key、定价)。
1522- 在根 `Cargo.toml` 增加 tonic、prost、tonic-build、tonic-health 工作区依赖。
1523- 建立独立 Axum 可执行入口。
1524- 接入配置文件、环境变量、监听地址和 graceful shutdown。
1525- 接入 Redis 连接复用 `lib/cache::RedisCache` / `RedisConn`。
1526- 接入 DB 连接复用 `lib/db`,仅创建审计与计费相关表。
1527- 增加 live/ready 基础健康接口与 `/v1/models`。
1528
1529### 阶段二:上游与凭据
1530
1531- 实现 `credentials.rs` 的凭据引用解析(env / secret)。
1532- 实现 `upstream.rs` 的内存快照 + Redis 冷却状态。
1533- 实现加权 Round Robin、能力筛选、in-flight。
1534- 加入 200K 上下文边界校验。
1535- 首版接入现有 OpenAI Provider。
1536
1537### 阶段三:会话亲和性
1538
1539- 实现 `affinity.rs` 的会话键计算。
1540- 实现 Redis 绑定建立、命中续约、失效清理。
1541- 实现降级轮转与流式错误语义。
1542
1543### 阶段四:HTTP API + gRPC(AI + 管理)+ 审计计费
1544
1545- 实现 OpenAI-compatible `/v1/chat/completions`(JSON + SSE)。
1546- 实现 gRPC `LproviderAI` 服务(Chat / ChatStream / ChatStreamBidirectional / CountTokens / Cancel + tonic-health),与 HTTP 共用同一路由选择器、钱包账本与亲和绑定。
1547- 实现 gRPC `LproviderAdmin` 服务(配置读写与热加载/重启、上游与 Key **只读查询**、绑定查询与失效、审计查询、聚合查询、租户额度 CRUD、钱包管理全量 RPC),写操作全部落 `lprovider_admin_audit`。
1548- 实现 `wallet.rs`(原子扣减、幂等流水、owner 实时校验)与 `quota.rs`(额度前置检查)。
1549- 实现 `audit.rs` 与 `billing.rs`,保证每次调用必落库。
1550- 实现聚合 worker 与对账任务。
1551- 转发上游 `prompt_cache_hit_tokens` 到响应与审计。
1552
1553### 阶段五:安全、测试与运行验证
1554
1555- 接入对外 HTTP Bearer Token 与 gRPC 鉴权。
1556- 完成能力筛选、会话亲和、降级轮转、上下文边界、并发、计费准确性测试。
1557- 验证健康检查、优雅关闭和流式错误语义。
1558- 执行 `cargo fmt`、`cargo check`、测试和 Clippy。
1559
1560## 13. 验证标准
1561
1562实现完成后至少满足:
1563
1564- 配置文件可以启动服务并加载有效上游集合。
1565- 环境变量可以覆盖对应配置项。
1566- 非法配置不会生成运行时快照并启动服务。
1567- 业务段配置(models / upstreams / pricing / auth)经 `ReloadConfig` 热加载**立即生效**且不中断在途请求;基础设施段(bind / port / redis / db / secret)经 `RestartService` 优雅重启生效。
1568- 同一统一模型的多个候选实例能够按权重、in-flight 与能力筛选分配请求。
1569- `auto` 模式下选择器按策略(COST/QUALITY/LATENCY/BALANCED)正确打分并命中预期上游;钱包余额低时自动降级到 COST。
1570- 调用方直接指定上游 `code` 时跳过自动选择,但仍走能力筛选、加权 Round Robin、亲和、降级。
1571- Redis 中冷却中的实例不会被继续选择,达到阈值后自动进入 cooldown。
1572- retryable 错误可在首个响应前切换实例;非 retryable 错误立即返回。
1573- 流式响应在已产生内容后不会进行透明重试。
1574- 请求超过统一 200K 或后端真实能力时,会在发送前被拒绝。
1575- 会话键基于前缀 token 哈希,短前缀不建立绑定。
1576- 命中既有绑定时优先复用对应 `(upstream_code, key_label)`,绑定失效时回退普通轮转。
1577- 响应中的 `prompt_cache_hit_tokens` 反馈用于续约或缩短绑定 TTL。
1578- 普通 JSON 和 SSE 响应均符合 OpenAI-compatible 协议。
1579- 每次调用必落库 `llm_call_log`,包含完整积分计费(含当时单价快照)与缓存命中字段。
1580- 后台 worker 按周期产出 `llm_billing_aggregate`,对账任务无差异告警。
1581- 内部 RPC AI 推理(`LproviderAI`)与 HTTP 走同一路由选择器、配额账本和亲和绑定。
1582- 内部 RPC AI 推理调用必落库 `llm_call_log`,`caller_service` / `priority` 等字段被记录。
1583- gRPC 管理接口鉴权启用,未授权调用被拒绝。
1584- gRPC 写操作必落 `lprovider_admin_audit`。
1585- 凭据仅以 `credential_ref` + `key_fingerprint` 形式出现在响应与日志。
1586- 内部 RPC AI 必传 `wallet_selector`;HTTP 必传 `X-Wallet-Id`,匿名请求一律拒绝。
1587- 钱包**仅实扣**(调用完成后按实际 `points_total` 一次性扣减);失败请求不计费不扣减。
1588- 钱包原子扣减依赖 SQL 条件与 `RETURNING balance_after`,并发安全由数据库保证;同一 request_id 的 consume/refund 不可重复落账。
1589- 钱包 owner 校验:非 admin 调用方必须满足 tenant + owner 一致;跨租户访问一律拒绝。
1590- 匿名仅允许内部 RPC AI;审计记录 `anonymous=true`、`wallet_id=NULL`、`points_total=0`,仍记录 usage / token / 缓存命中用于成本可视化。
1591- 匿名无独立速率限制(信任内部调用方);仅依赖 wallet 余额扣减与告警。
1592- 内部 RPC AI 推理调用**不要求鉴权**(信任集群内部网络);调用方由 tenant_id + caller_service 标识即可。
1593- 审计与聚合按 `(owner_type, owner_id, wallet_id)` 与 `subject_path` 维度产出报告。
1594- live/ready 检查语义正确,ready 包含 Redis 与 DB 连通性。
1595- 服务可以响应终止信号并优雅退出。
1596- `cargo check`、测试、格式检查和 Clippy 通过。
1597
1598## 14. 决策记录(已确认)
1599
1600以下决策已在 2026-08-12 由架构评审确认,作为实现基准:
1601
1602| # | 决策点 | 结论 |
1603| -- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
1604| 1 | 运行形态 | **独立集群进程**;不嵌入 apihost/webapi |
1605| 2 | 对外协议 | **仅 OpenAI-compatible**;内部服务走 `LproviderAI` gRPC |
1606| 3 | 首版 Provider | **仅 OpenAI**;架构预留 Provider trait 扩展 |
1607| 4 | 对外 HTTP 鉴权 | **lprovider 自带 Bearer Token**(`lprovider.auth.bearer_token` 配置) |
1608| 5 | 200K 上下文语义 | **网关硬 200K + 后端取最小真实能力**;底层低于 200K 时不伪装 |
1609| 6 | 流式首事件前故障转移 | **接受**;已发送内容后不透明重试 |
1610| 7 | 亲和性 TTL 策略 | **接受默认**:TTL=3600s;命中率 ≥ 50% 续约;命中率 0 缩短至 1/4 TTL |
1611| 8 | gRPC 框架 | **仅 tonic**(+ prost) |
1612| 9 | gRPC 端口与鉴权 | **分端口**:`LproviderAI` → `0.0.0.0:9090`(无鉴权);`LproviderAdmin` → `0.0.0.0:9443`(强制 mTLS) |
1613| 10 | 审计保留期 | **接受**:明细 180 天 / 聚合 2 年 |
1614| 11 | 配置文件修改粒度 | **保留两种**:full_content_yaml 整体替换 + ConfigSectionPatch 局部修改 |
1615| 12 | 重启方式 | **SIGHUP + 可选 self-exec**;不引入配置中心驱动 |
1616| 13 | 积分制计价精度 | **1 point = 1 积分,每模型独立声明 1K token 单价**(`points_per_1k_*`) |
1617| 14 | 凭据轮换 | **外部 secret manager** 负责;lprovider 仅消费 `credential_ref` |
1618| 15 | 集群部署形态 | **无 Operator**;同二进制多实例 + Redis/DB 共享 + LB + 分布式锁 |
1619| 16 | 写盘选主锁 | **接受**:TTL=30s 自动续约;锁粒度为整体配置文件 |
1620| 17 | 滚动重启 | **每批 1 实例、固定间隔 30s**;客户端按 instance_id 列表逐个触发 |
1621| 18 | 内部 RPC AI 鉴权 | **不鉴权**;信任集群内部网络;调用方由 tenant_id + caller_service 标识 |
1622| 19 | priority 抢占 | **仅影响 auto 策略**;不参与配额抢占 |
1623| 20 | Cancel 语义 | **仅丢弃客户端流**;上游继续执行但结果被丢弃 |
1624| 21 | 钱包 owner 类型 | **仅 User + Space** |
1625| 22 | 钱包 owner 校验 | **实时校验**(调用 `lib/space` / `lib/user`) |
1626| 23 | 钱包扣减方式 | **仅实扣**(调用完成后一次性扣减);失败请求不计费 |
1627| 24 | 匿名速率限制 | **无需**;信任内部调用方;仅依赖告警 |
1628| 25 | auto 默认权重 | **接受**:`w_q=0.5 / w_c=0.3 / w_l=0.2`,balance_bias 默认 0 |
1629| 26 | tier 评分机制 | **运维手工评分**(1-5);动态调整纳入后续版本 |
1630| 27 | priority 阈值粒度 | **两级**:priority ≥ 10 → QUALITY;priority < -10 → COST;其他 → BALANCED |
1631| 28 | 余额保护阈值 | **全局默认 1000 积分**;不按 owner 类型分别配置 |
No newline at end of file