切换 LLM Router 凭据到专用账号表
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Repository checks (pull_request) Failing after 16s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled

- 将 Router 账号、API Key 元数据和密文统一保存到 llm_router_account

- 增加 Router API Key 的进程内缓存并清理旧 external_api_key 兼容路径

- 更新 SpacetimeDB 绑定、后端架构文档和决策记录
This commit is contained in:
2026-09-03 19:03:40 +08:00
parent c12d744d91
commit 6d41dc2f89
10 changed files with 317 additions and 769 deletions
@@ -7974,17 +7974,17 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
## 2026-08-29 AGC 官方 LLM 代理与 Windows 私有路径修复
## 2026-08-29 AGC 官方 LLM 代理与 Windows 私有路径修复
- AGC 正式发行版不再让用户配置 Provider、Base URL、模型或 API Key。客户端只携带登录 access token 调用 `api-server``api-server` 按 access token 的 owner 查询 `external_api_key``purpose=llm-router` 的账号记录,解密服务端密文后调用固定 `https://router.genarrative.world/v1``gpt-5.6-sol` Responses 路由。真实 Router Key 不进入聊天、manifest、trace、日志、项目文件、Codex argv/环境变量或普通 IPC payload。
- AGC 正式发行版不再让用户配置 Provider、Base URL、模型或 API Key。客户端只携带登录 access token 调用 `api-server``api-server` 按 access token 的 owner 查询 `llm_router_account`,解密服务端密文后调用固定 `https://router.genarrative.world/v1``gpt-5.6-sol` Responses 路由。真实 Router Key 不进入聊天、manifest、trace、日志、项目文件、Codex argv/环境变量或普通 IPC payload。
- 注册成功后视为账号已有余额;当前不实现真实扣费,LLM 代理在 Router 成功返回后再记泥点,扣费失败只记录日志,不影响已经成功的响应。注册入口和首次 LLM 请求都会幂等确保账号 Router Keyapi-server 只通过 New API 管理员 Token 执行正式“创建用户 → 查询用户 ID → 设置分组 → 登录 → 创建无限额度 token → 签发 API Key”流程并加密落库,不再生成或使用任何 Router fallback token。远端结果不确定时写入 reconciliation 标记;本地签发落库失败可安全重试,不制造第二个账号。客户端不会退回手工 Key;真实管理员 Token、注册和生产 Router 联通仍待受控部署 smoke。
- `external_api_key` 继续复用一次性明文返回和 hash/prefix 元数据链路LLM Router 用途固定名称 `llm-router``llm:responses` scope,并额外保存加密密文、Router account id 和固定路由元数据。登出、切换账号或服务器只清理进程内 access token/Provider Proxy,不删除其它账号或设备的本地/远端 Key;Router 确定返回 401/403 时标记当前账号 Key revoked。
- `external_api_key` 继续复用一次性明文返回和 hash/prefix 元数据链路,仅承载普通外部 OpenAPI/MCP Key。LLM Router `llm-router` 用途`llm:responses` scope加密密文、Router account id 和固定路由元数据统一保存在 `llm_router_account`。登出、切换账号或服务器只清理进程内 access token/Provider Proxy,不删除其它账号或设备的本地/远端 Key;Router 确定返回 401/403 时标记当前账号 Key revoked。
- 后台只允许管理员通过专用 API Key 查询接口按 owner、公开用户编号、keyId、精确 prefix、名称、时间、状态和 purpose 筛选;未给出 owner/keyId/prefix 时拒绝无界扫描,永不返回 `key_hash`、密文或原始表行。通用 `external_api_key` 表浏览被拒绝。
- Windows 私有路径严格拒绝 reparse/symlink、非普通对象、路径类型冲突和候选路径冲突。只有本次调用新建的目录/临时文件可在普通进程内初始化 owner;owner 已正确但仅继承 ACL 不合规时,正式 prepare 入口先完成归属校验,再通过当前用户私有、禁止继承、单一 ACE 的 DACL 收紧。用户通过原生选择器明确选中的项目根/文件,或 AGC managed 路径,在发现 owner/DACL 权限不足时由一次性 UAC helper 将普通对象接管为当前 TokenUser 并复核;取消/失败保持失败关闭,未经过正式选择或项目根入口的内部路径不得触发任意提权。
- 规划、Runtime sidecar、UI workflow、资源桥、Skill 隔离目录和图片读取统一经过私有路径准备,并在原子写入后复核类型、owner/DACL 与文件身份。真实 Windows UAC、foreign owner 修复、继承 DACL 收紧、注册后 Router 签发和生产 `/v1/responses` 联通仍需在受控实机/部署环境验证。
## 2026-08-31 LLM Router 独立账号与后置扣费修订
- 每个 Genarrative 用户在认证成功后都必须幂等准备独立 Router 账号:api-server 使用管理员 Token 创建随机密码普通用户,查询用户 ID,设置用户 `group=taonier`,登录、创建或复用固定标识 `agc_auto_generate` 的无限额度 TokenToken/API Key 使用 `default` 分组;发现旧 Token 为其它分组时先更新为 `default`)并签发 API Key。Router 账号用户名、随机密码、access token(如需)和 API Key 作为一个服务端加密 bundle 保存到 `llm_router_account.credential_ciphertext`,脱敏账号信息保存到 `llm_router_account.account_json`,并在 `external_api_key` 中保存正式 `purpose=llm-router` 行与 `credential_version`;客户端和普通用户永远不可见 Router Key。管理员 Token 仅存在 api-server 私有配置,不写入数据库或日志;Router 凭据只来源于这条正式账号流程。
- 该账号 provisioning 使用持久 saga 状态:远端注册、登录、token 或 Key 签发结果不确定时进入 `unknown` / `reconciliation_required`,禁止重复注册;远端 Key 已确定签发但本地 `external_api_key` 写入失败时保持 `key_issued`,后续使用确定 key id 重试落库。Router 确定返回 401/403 时撤销当前 Key 并把账号状态置为 `retryable`,复用已保存的账号密码重新签发替代 Key。
- 每个 Genarrative 用户在认证成功后都必须幂等准备独立 Router 账号:api-server 使用管理员 Token 创建随机密码普通用户,查询用户 ID,设置用户 `group=taonier`,登录、创建或复用固定标识 `agc_auto_generate` 的无限额度 TokenToken/API Key 使用 `default` 分组;发现旧 Token 为其它分组时先更新为 `default`)并签发 API Key。Router 账号用户名、随机密码、access token(如需)和 API Key 作为一个服务端加密 bundle 保存到 `llm_router_account.credential_ciphertext`,脱敏账号信息和 API Key 核心字段保存到 `llm_router_account`;客户端和普通用户永远不可见 Router Key。管理员 Token 仅存在 api-server 私有配置,不写入数据库或日志;Router 凭据只来源于这条正式账号流程。
- 该账号 provisioning 使用持久 saga 状态:远端注册、登录、token 或 Key 签发结果不确定时进入 `unknown` / `reconciliation_required`,禁止重复注册;远端 Key 已确定签发但本地 `llm_router_account` 写入失败时保持 `key_issued`,后续使用确定 key id 重试落库。Router 确定返回 401/403 时撤销当前 Key 并把账号状态置为 `retryable`,复用已保存的账号密码重新签发替代 Key。
- AGC 调用固定为客户端 access token -> api-server -> Router。Router 成功返回后再扣泥点,按临时规则每开始 10,000 token 扣 1 点且至少 1 点;ledger id 由 `Idempotency-Key`(缺省请求 ID)哈希得到,Router 失败不扣费。余额足够时全额扣除;余额不足时按当前可消费余额扣光,差额记为赠送并继续返回已成功的 LLM 响应。该计费规则是过渡实现,待产品定价确认后替换,不能视为 Router 真实成本结算。
- AGC 状态面收口:Tauri `check_game_creator_llm_config``/llm-status``/llm-routes` 只返回账号凭据状态、官方路由锁定状态和运行参数;不序列化 Router 地址、模型、协议名或任何密钥/凭据字段,内部固定路由仅留在运行时配置与服务端代理中。
@@ -631,9 +631,9 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- Rust 结构体:`ExternalApiKey`
- 源码:`server-rs/crates/spacetime-module/src/external_api_key_storage.rs`
- 说明:外部 OpenAPI 与 LLM Router 使用的账号级 API Key 凭据表,公开元数据只包含 key prefix、作用域、用途、撤销状态和使用时间;服务端内部还保存 Router Key 的 AES-256-GCM 密文、Router 账号标识和固定上游元数据,明文永不通过登录态接口、SpacetimeDB 表查询或客户端返回。普通 External Editor Key 的明文只在 `/api/profile/api-keys` 创建接口返回一次,后端只保存 `key_hash``key_prefix`;正式 Router 账号用途固定为 `purpose=llm-router`,每次认证成功后由 api-server 幂等确保并保存,已有合法记录直接复用。Router 账号由 New API 管理 Token 完成创建、用户分组设置、登录、无限额度 token 和 API Key 签发:用户分组固定为 `taonier`AGC Token/API Key 固定为 `default` 分组;已有固定 Token 若分组不是 `default`,登录恢复时先通过 Token 更新接口纠正。Router 凭据只来源于这条正式账号流程。Router 密文加密优先使用专用 `GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 JWT secret 派生值。生产环境应显式配置专用 secret,轮换时先完成旧密文对账,不能静默创建第二个 active Key。`/api/profile/api-keys/llm-router` 只返回安全元数据,不返回 Router 明文 Key。API Key 管理接口不写入外部 OpenAPI JSON。`purpose=external-editor` 是默认用途,v1 默认作用域为 `editor:project``editor:canvas``editor:image-generate``editor:asset`;其中 `editor:project` 覆盖项目列表、最近项目、创建、读取、重命名和删除,`editor:canvas` 覆盖默认画布布局保存,`editor:image-generate` 覆盖编辑器现有图片生成、重绘 / 调整、去背景、规范图、宣发素材、图标 spritesheet 生成 / 拆分、UI 设计图素材拆分、角色动画、视频、音效和背景音乐生成,`editor:asset` 覆盖素材直传凭证、素材对象确认、签名读取、账号级素材库和项目画布资源记录操作。`purpose=llm-router` 是 LLM Router 正式账号 Key,作用域固定为 `llm:responses`
- 说明:本表只承载普通外部 OpenAPI/MCP API Key。明文只在 `/api/profile/api-keys` 创建接口返回一次,服务端保存 `key_hash``key_prefix`、作用域、用途和撤销状态;本需求不向该表增加 Router 字段,也不写入 `purpose=llm-router` 数据。`purpose=external-editor` 是默认用途,v1 默认作用域为 `editor:project``editor:canvas``editor:image-generate``editor:asset`
- 索引:`by_external_api_key_owner_user_id` 用于登录态 API Key 列表;`key_hash` 唯一索引用于外部 API 鉴权。
- 2026-08-31 修订:LLM Router 行在上述字段末尾追加 `provider_account_json`Router 账号 JSON,普通 External Editor Key 为 `NULL`)与 `credential_version`(默认 `1`)。每次认证成功由 api-server 按稳定用户名/密码先查询并恢复远端用户;确认不存在时再通过 New API 管理 Token 完成“创建用户 → 查询用户 ID → 设置 `taonier` 分组 → 登录 → 查询并复用固定标识 `agc_auto_generate` 的 Token(不存在才创建)→ 签发 API Key”,再把账号状态与加密凭据写入 `llm_router_account` / `external_api_key`;Router 凭据只来源于正式账号流程。公共 Router、独立数据库的部署必须共享同一版本 provisioning secret,数据库缺行时才能恢复同一远端账号。Responses 请求成功后再按 usage 写入钱包扣费流水,失败请求不扣费。
- 2026-08-31 修订:Router 账号状态、API Key 核心字段、账号元数据和加密凭据统一写入 `llm_router_account``external_api_key` 保持普通外部 OpenAPI/MCP Key 的原有链路不变。公共 Router、独立数据库的部署必须共享同一版本 provisioning secret,数据库缺行时才能恢复同一远端账号。Responses 请求成功后再按 usage 写入钱包扣费流水,失败请求不扣费。
- 2026-09-01 修订:Router provisioning 允许所有环境使用官方固定 Router 控制面,以便独立开发数据库通过完整 owner `user_id` 的稳定派生凭据和 `agc_auto_generate` Token 复用同一远端账号/Key。由于 New API 的 `username``password``display_name` 均限制 20 个字符,用户名固定为 `agc_user_` 加 11 位 URL-safe SHA-256 短码,密码为基于完整 owner `user_id` 与 provisioning secret 派生的 20 位 hex;完整 owner `user_id` 通过 New API 用户 `remark` 字段保存,并在本地 `llm_router_account.owner_user_id` 保留权威映射。非官方公网地址仍拒绝,loopback 仅用于本地 fixture。使用共享官方 Router 的非生产环境启动时告警,提醒会触及线上账号与额度。api-server 不再提供任何 fallback Key 路径;没有已完成 provisioning 的账号行时,LLM 请求必须在本地解析阶段失败关闭。
- 2026-09-01 修订:每次账号认证、Router Key 准备或 LLM 请求解析既有账号时,api-server 都会在同一 owner 级 provisioning 锁内检查固定套餐 `plan_id=1`。没有 active 订阅、订阅已过期或 `end_time` 距当前 Unix 秒不超过 24 小时时,使用管理员接口新建一条订阅;超过 24 小时则复用现有订阅。订阅检查不进入 Responses 流式 chunk,管理员 Token 缺失时仅保留启动告警并跳过续期检查。
- 2026-09-02 修订:LLM Router 成功返回后按“每开始 10,000 token 扣 1 点、至少 1 点”后置结算。余额足够时全额扣除;余额不足时在同一钱包事务内扣除当前可消费余额并记录 `waivedPoints` 差额,响应仍正常返回。该规则为过渡产品策略,待真实定价接入后替换。
@@ -642,9 +642,11 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
### `llm_router_account`
- 当前 AGC Router 需求由本表单独承载:API Key 核心字段、加密凭据、Router 账号元数据、生命周期与 provisioning 状态均在本表;不再依赖 `external_api_key`。api-server 首次需要上游调用时解密凭据,并按 owner + route 使用 10 分钟进程内缓存;轮换或撤销时清理缓存。
- Rust 结构体:`LlmRouterAccount`
- 源码:`server-rs/crates/spacetime-module/src/llm_router_account.rs`
- 作用:记录用户与官方 LLM Router 账号的稳定映射、账号生命周期、远端账号元数据、订阅检查状态、重试 / 对账状态和加密凭据版本。Router 明文 Key 仍只保存在 `external_api_key` 的加密字段中,账号读取和写入统一通过对应 procedure 与 `spacetime-client` facade 完成。
- 作用:记录用户与官方 LLM Router 账号的稳定映射、API Key 核心字段、账号生命周期、远端账号元数据、订阅检查状态、重试 / 对账状态和加密凭据版本。Router 明文 Key 只在 api-server 进程内短暂存在,数据库仅保存加密凭据;账号读取和写入统一通过对应 procedure 与 `spacetime-client` facade 完成。
- 索引:`by_llm_router_account_owner_route` 用于按 owner 与路由来源读取账号;`by_llm_router_account_status_next_retry` 用于按状态和下次重试时间扫描待对账账号。
### `error_report`
@@ -215,12 +215,14 @@ SpacetimeDB procedure
### LLM Router 账号 Key 与 Router 代理
普通 AGC 发行版不把 Router 当作客户端可配置 Provider,也不把 Router API Key 下发到桌面端。注册成功视为账号已有可用余额;认证成功后,api-server 异步尽力准备该用户对应的 Router 账号和 API KeyRouter 控制面故障不得阻塞主站登录;LLM 请求解析阶段仍要求本地存在合法的已完成 provisioning 凭据,正式记录使用 `purpose=llm-router` 并保存服务端加密凭据。当前按 New API 管理接口执行正式 provisioning:由于 `username``password``display_name` 均限制 20 个字符,用户名固定为 `agc_user_` 加 11 位 URL-safe SHA-256 短码,密码为基于完整 owner `user_id` 与部署侧受保护 provisioning secret 稳定派生的 20 位 hex,展示名与短用户名一致;完整 owner `user_id` 写入 New API 用户 `remark`,本地 `llm_router_account.owner_user_id` 仍是平台权威映射。服务端先查询远端用户:已存在则直接登录,不重复注册;确认不存在时才由管理员创建普通用户,查询用户 ID,设置 `remark=<完整 owner user_id>` 与用户分组 `taonier`,再登录、查询并复用固定标识 `agc_auto_generate` 的 TokenToken/API Key 固定使用 `default` 分组;已有固定 Token 若分组不是 `default`,登录恢复时先通过 Token 更新接口纠正),签发 API Key。每次新建或准备 API Key 时,服务端在签发前查询该 Router 用户的固定套餐 `plan_id=1`;无 active 订阅、订阅已过期或剩余时间不超过 24 小时时调用管理员订阅接口新建一条订阅,剩余超过 24 小时则复用现有订阅。订阅查询/创建只使用 api-server 私有管理员 Token,不进入客户端或 Router Key;检查锚点是显式 Router Key 准备接口和新 Key provisioning,不放在 Responses 流式 chunk 中。由于 Router 公共而各部署数据库独立,所有能操作同一 Router 的部署必须使用相同的 provisioning secret。若任一步外部结果不确定,记录进入 reconciliation 状态,禁止重复注册;本地 API Key 写入失败则保留 `key_issued` 状态并用确定的 key id 重试落库。Router 密文加密优先使用 `GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 `GENARRATIVE_JWT_SECRET` 派生密钥。Router Key 的明文只在 api-server 进程内短暂存在,`/api/profile/api-keys/llm-router` 只返回安全元数据;普通 External Editor Key 仍沿用创建接口明文只显示一次的正式链路。后续请求链路固定为:
本次 AGC Router 需求不改造原有 `external_api_key`。Router 账号、API Key 核心字段、生命周期和加密凭据统一保存在 `llm_router_account`;明文凭据只在 api-server 进程内短暂存在,并按 owner + route 进行 10 分钟内存缓存,轮换或撤销时立即清理。
普通 AGC 发行版不把 Router 当作客户端可配置 Provider,也不把 Router API Key 下发到桌面端。注册成功视为账号已有可用余额;认证成功后,api-server 异步尽力准备该用户对应的 Router 账号和 API KeyRouter 控制面故障不得阻塞主站登录;LLM 请求解析阶段只读取 `llm_router_account` 中合法的已完成 provisioning 凭据。当前按 New API 管理接口执行正式 provisioning:由于 `username``password``display_name` 均限制 20 个字符,用户名固定为 `agc_user_` 加 11 位 URL-safe SHA-256 短码,密码为基于完整 owner `user_id` 与部署侧受保护 provisioning secret 稳定派生的 20 位 hex,展示名与短用户名一致;完整 owner `user_id` 写入 New API 用户 `remark`,本地 `llm_router_account.owner_user_id` 仍是平台权威映射。服务端先查询远端用户:已存在则直接登录,不重复注册;确认不存在时才由管理员创建普通用户,查询用户 ID,设置 `remark=<完整 owner user_id>` 与用户分组 `taonier`,再登录、查询并复用固定标识 `agc_auto_generate` 的 TokenToken/API Key 固定使用 `default` 分组;已有固定 Token 若分组不是 `default`,登录恢复时先通过 Token 更新接口纠正),签发 API Key。每次新建或准备 API Key 时,服务端在签发前查询该 Router 用户的固定套餐 `plan_id=1`;无 active 订阅、订阅已过期或剩余时间不超过 24 小时时调用管理员订阅接口新建一条订阅,剩余超过 24 小时则复用现有订阅。订阅查询/创建只使用 api-server 私有管理员 Token,不进入客户端或 Router Key;检查锚点是显式 Router Key 准备接口和新 Key provisioning,不放在 Responses 流式 chunk 中。由于 Router 公共而各部署数据库独立,所有能操作同一 Router 的部署必须使用相同的 provisioning secret。若任一步外部结果不确定,记录进入 reconciliation 状态,禁止重复注册;本地 API Key 写入失败则保留 `key_issued` 状态并用确定的 key id 重试落库。Router 密文加密优先使用 `GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 `GENARRATIVE_JWT_SECRET` 派生密钥。Router Key 的明文只在 api-server 进程内短暂存在;缓存命中时不访问数据库,缓存未命中时从 `llm_router_account` 解密并写入 10 分钟进程内缓存;`/api/profile/api-keys/llm-router` 只返回安全元数据;普通 External Editor Key 仍沿用创建接口明文只显示一次的正式链路。后续请求链路固定为:
```text
AGC loopback Provider ProxyBearer=平台 access token
-> POST /api/llm/responses
-> api-server 从 access token 得到 userId,查询并解密 external_api_key
-> api-server 从 access token 得到 userId,查询并解密 llm_router_account
-> POST https://router.genarrative.world/v1/responsesBearer=<router-api-key>
```
@@ -230,7 +232,7 @@ Windows 私有路径由同一套正式准备入口复用:AGC 自有 AppData、
管理员 Token 仅由 api-server 私有配置 `GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN` 或对应文件提供,不进入客户端、数据库、日志或请求 payload。当前仓库无法验证真实管理员 Token、真实账号 Key 与生产 Router `/v1/responses` 联通;这些仍需在受控部署 smoke 中完成,客户端不得退回手工 Key。
运行环境隔离是 provisioning 的前置门禁:所有环境都允许使用官方固定 Router 地址,开发环境因此可以通过完整 owner `user_id` 派生的稳定短用户名、稳定密码和 `agc_auto_generate` Token 复用共享 Router 账号;完整 owner `user_id` 同步写入 New API 用户 `remark`,便于 Router 侧统计回溯平台账号。非官方公网地址仍被拒绝,loopback 地址仅用于本地 fixture。使用共享官方 Router 的开发环境会实际创建/使用线上账号与额度,启动时必须告警。测试代码也不再通过进程级 fallback Key 旁路数据库;只有显式、按 owner 绑定的“已完成 provisioning” fixture 才能模拟已有账号,未提供 fixture 时必须经过 `external_api_key`/`llm_router_account` 读取或正式 provisioning,不能访问 Router。数据库记录缺失时,稳定短用户名/密码先尝试恢复远端账号;只有确认远端不存在才注册,避免跨部署数据库为空时重复创建用户。
运行环境隔离是 provisioning 的前置门禁:所有环境都允许使用官方固定 Router 地址,开发环境因此可以通过完整 owner `user_id` 派生的稳定短用户名、稳定密码和 `agc_auto_generate` Token 复用共享 Router 账号;完整 owner `user_id` 同步写入 New API 用户 `remark`,便于 Router 侧统计回溯平台账号。非官方公网地址仍被拒绝,loopback 地址仅用于本地 fixture。使用共享官方 Router 的开发环境会实际创建/使用线上账号与额度,启动时必须告警。测试代码也不再通过进程级 fallback Key 旁路数据库;只有显式、按 owner 绑定的“已完成 provisioning” fixture 才能模拟已有账号,未提供 fixture 时必须经过 `llm_router_account` 读取或正式 provisioning,不能访问 Router。数据库记录缺失时,稳定短用户名/密码先尝试恢复远端账号;只有确认远端不存在才注册,避免跨部署数据库为空时重复创建用户。
## 素材生成与落库
File diff suppressed because it is too large Load Diff
+5 -26
View File
@@ -1006,37 +1006,16 @@ async fn resolve_llm_router_credentials(
}
// No fixture means the request must follow the same provisioning/read path
// as production. In particular, a missing external_api_key row must never
// be replaced by a process-wide test key. An already-provisioned row is
// read locally on the hot path; Router account repair and subscription
// checks run from the authentication/key-preparation anchor instead.
// as production. The dedicated llm_router_account row is authoritative.
if let Some(credentials) =
crate::external_api_keys::read_active_llm_router_credentials(state, owner_user_id).await?
{
return Ok(credentials);
}
let record = crate::external_api_keys::ensure_llm_router_account(state, owner_user_id).await?;
let ciphertext = record
.secret_ciphertext
.as_deref()
.ok_or_else(|| "LLM Router 账号密钥缺失,请重新登录后重试".to_string())?;
let encryption_secret = state
.config
.effective_llm_router_api_key_encryption_secret()
.ok_or_else(|| "LLM Router 账号密钥加密配置缺失".to_string())?;
let api_key =
crate::external_api_keys::decrypt_router_api_key(ciphertext, encryption_secret.as_str())?;
// The Router origin is an application invariant. Do not let mutable
// metadata in the key row turn into a user-controlled upstream URL.
Ok((
state
.config
.llm_router_base_url
.trim_end_matches('/')
.to_string(),
api_key,
record.key_id,
))
crate::external_api_keys::ensure_llm_router_account(state, owner_user_id).await?;
crate::external_api_keys::read_active_llm_router_credentials(state, owner_user_id)
.await?
.ok_or_else(|| "LLM Router 账号密钥缺失,请重新登录后重试".to_string())
}
#[cfg(test)]
@@ -7,7 +7,6 @@ pub struct LlmRouterAccountRecord {
pub route_origin: String,
pub idempotency_key: String,
pub router_account_id: Option<String>,
pub external_api_key_id: Option<String>,
pub credential_ciphertext: Option<String>,
pub account_json: Option<String>,
pub status: String,
@@ -18,6 +17,12 @@ pub struct LlmRouterAccountRecord {
pub credential_version: u32,
pub created_at: String,
pub updated_at: String,
pub key_id: String,
pub key_name: String,
pub key_prefix: Option<String>,
pub key_hash: Option<String>,
pub last_used_at: Option<String>,
pub revoked_at: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -27,7 +32,6 @@ pub struct LlmRouterAccountUpsertRecordInput {
pub route_origin: String,
pub idempotency_key: String,
pub router_account_id: Option<String>,
pub external_api_key_id: Option<String>,
pub credential_ciphertext: Option<String>,
pub account_json: Option<String>,
pub status: String,
@@ -37,6 +41,12 @@ pub struct LlmRouterAccountUpsertRecordInput {
pub last_error: Option<String>,
pub credential_version: u32,
pub now_micros: i64,
pub key_id: String,
pub key_name: String,
pub key_prefix: Option<String>,
pub key_hash: Option<String>,
pub last_used_at_micros: Option<i64>,
pub revoked_at_micros: Option<i64>,
}
impl From<LlmRouterAccountUpsertRecordInput>
@@ -49,7 +59,6 @@ impl From<LlmRouterAccountUpsertRecordInput>
route_origin: input.route_origin,
idempotency_key: input.idempotency_key,
router_account_id: input.router_account_id,
external_api_key_id: input.external_api_key_id,
credential_ciphertext: input.credential_ciphertext,
account_json: input.account_json,
status: input.status,
@@ -59,6 +68,12 @@ impl From<LlmRouterAccountUpsertRecordInput>
last_error: input.last_error,
credential_version: input.credential_version,
now_micros: input.now_micros,
key_id: input.key_id,
key_name: input.key_name,
key_prefix: input.key_prefix,
key_hash: input.key_hash,
last_used_at_micros: input.last_used_at_micros,
revoked_at_micros: input.revoked_at_micros,
}
}
}
@@ -72,7 +87,6 @@ fn map_snapshot(
route_origin: snapshot.route_origin,
idempotency_key: snapshot.idempotency_key,
router_account_id: snapshot.router_account_id,
external_api_key_id: snapshot.external_api_key_id,
credential_ciphertext: snapshot.credential_ciphertext,
account_json: snapshot.account_json,
status: snapshot.status,
@@ -83,6 +97,12 @@ fn map_snapshot(
credential_version: snapshot.credential_version,
created_at: format_timestamp_micros(snapshot.created_at_micros),
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
key_id: snapshot.key_id,
key_name: snapshot.key_name,
key_prefix: snapshot.key_prefix,
key_hash: snapshot.key_hash,
last_used_at: snapshot.last_used_at_micros.map(format_timestamp_micros),
revoked_at: snapshot.revoked_at_micros.map(format_timestamp_micros),
}
}
@@ -12,7 +12,6 @@ pub struct LlmRouterAccountSnapshot {
pub route_origin: String,
pub idempotency_key: String,
pub router_account_id: Option<String>,
pub external_api_key_id: Option<String>,
pub credential_ciphertext: Option<String>,
pub account_json: Option<String>,
pub status: String,
@@ -23,6 +22,12 @@ pub struct LlmRouterAccountSnapshot {
pub credential_version: u32,
pub created_at_micros: i64,
pub updated_at_micros: i64,
pub key_id: String,
pub key_name: String,
pub key_prefix: Option<String>,
pub key_hash: Option<String>,
pub last_used_at_micros: Option<i64>,
pub revoked_at_micros: Option<i64>,
}
impl __sdk::InModule for LlmRouterAccountSnapshot {
@@ -12,7 +12,6 @@ pub struct LlmRouterAccount {
pub route_origin: String,
pub idempotency_key: String,
pub router_account_id: Option<String>,
pub external_api_key_id: Option<String>,
pub credential_ciphertext: Option<String>,
pub account_json: Option<String>,
pub status: String,
@@ -23,6 +22,12 @@ pub struct LlmRouterAccount {
pub credential_version: u32,
pub created_at: __sdk::Timestamp,
pub updated_at: __sdk::Timestamp,
pub key_id: String,
pub key_name: String,
pub key_prefix: Option<String>,
pub key_hash: Option<String>,
pub last_used_at: Option<__sdk::Timestamp>,
pub revoked_at: Option<__sdk::Timestamp>,
}
impl __sdk::InModule for LlmRouterAccount {
@@ -38,7 +43,6 @@ pub struct LlmRouterAccountCols {
pub route_origin: __sdk::__query_builder::Col<LlmRouterAccount, String>,
pub idempotency_key: __sdk::__query_builder::Col<LlmRouterAccount, String>,
pub router_account_id: __sdk::__query_builder::Col<LlmRouterAccount, Option<String>>,
pub external_api_key_id: __sdk::__query_builder::Col<LlmRouterAccount, Option<String>>,
pub credential_ciphertext: __sdk::__query_builder::Col<LlmRouterAccount, Option<String>>,
pub account_json: __sdk::__query_builder::Col<LlmRouterAccount, Option<String>>,
pub status: __sdk::__query_builder::Col<LlmRouterAccount, String>,
@@ -49,6 +53,12 @@ pub struct LlmRouterAccountCols {
pub credential_version: __sdk::__query_builder::Col<LlmRouterAccount, u32>,
pub created_at: __sdk::__query_builder::Col<LlmRouterAccount, __sdk::Timestamp>,
pub updated_at: __sdk::__query_builder::Col<LlmRouterAccount, __sdk::Timestamp>,
pub key_id: __sdk::__query_builder::Col<LlmRouterAccount, String>,
pub key_name: __sdk::__query_builder::Col<LlmRouterAccount, String>,
pub key_prefix: __sdk::__query_builder::Col<LlmRouterAccount, Option<String>>,
pub key_hash: __sdk::__query_builder::Col<LlmRouterAccount, Option<String>>,
pub last_used_at: __sdk::__query_builder::Col<LlmRouterAccount, Option<__sdk::Timestamp>>,
pub revoked_at: __sdk::__query_builder::Col<LlmRouterAccount, Option<__sdk::Timestamp>>,
}
impl __sdk::__query_builder::HasCols for LlmRouterAccount {
@@ -60,10 +70,6 @@ impl __sdk::__query_builder::HasCols for LlmRouterAccount {
route_origin: __sdk::__query_builder::Col::new(table_name, "route_origin"),
idempotency_key: __sdk::__query_builder::Col::new(table_name, "idempotency_key"),
router_account_id: __sdk::__query_builder::Col::new(table_name, "router_account_id"),
external_api_key_id: __sdk::__query_builder::Col::new(
table_name,
"external_api_key_id",
),
credential_ciphertext: __sdk::__query_builder::Col::new(
table_name,
"credential_ciphertext",
@@ -80,6 +86,12 @@ impl __sdk::__query_builder::HasCols for LlmRouterAccount {
credential_version: __sdk::__query_builder::Col::new(table_name, "credential_version"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
key_id: __sdk::__query_builder::Col::new(table_name, "key_id"),
key_name: __sdk::__query_builder::Col::new(table_name, "key_name"),
key_prefix: __sdk::__query_builder::Col::new(table_name, "key_prefix"),
key_hash: __sdk::__query_builder::Col::new(table_name, "key_hash"),
last_used_at: __sdk::__query_builder::Col::new(table_name, "last_used_at"),
revoked_at: __sdk::__query_builder::Col::new(table_name, "revoked_at"),
}
}
}
@@ -12,7 +12,6 @@ pub struct LlmRouterAccountUpsertInput {
pub route_origin: String,
pub idempotency_key: String,
pub router_account_id: Option<String>,
pub external_api_key_id: Option<String>,
pub credential_ciphertext: Option<String>,
pub account_json: Option<String>,
pub status: String,
@@ -22,6 +21,12 @@ pub struct LlmRouterAccountUpsertInput {
pub last_error: Option<String>,
pub credential_version: u32,
pub now_micros: i64,
pub key_id: String,
pub key_name: String,
pub key_prefix: Option<String>,
pub key_hash: Option<String>,
pub last_used_at_micros: Option<i64>,
pub revoked_at_micros: Option<i64>,
}
impl __sdk::InModule for LlmRouterAccountUpsertInput {
@@ -1,11 +1,6 @@
use crate::*;
/// Durable state for the per-user Router provisioning saga.
///
/// The table stores only server-encrypted credential material. It is deliberately
/// separate from `external_api_key`: this row records the remote account lifecycle
/// and retry/reconciliation state, while `external_api_key` remains the formal API
/// key record used by request-time authentication and billing metadata.
/// Durable state for the per-user Router account and API Key.
#[spacetimedb::table(
accessor = llm_router_account,
index(
@@ -24,7 +19,6 @@ pub struct LlmRouterAccount {
pub route_origin: String,
pub idempotency_key: String,
pub router_account_id: Option<String>,
pub external_api_key_id: Option<String>,
pub credential_ciphertext: Option<String>,
pub account_json: Option<String>,
pub status: String,
@@ -35,6 +29,12 @@ pub struct LlmRouterAccount {
pub credential_version: u32,
pub created_at: Timestamp,
pub updated_at: Timestamp,
pub key_id: String,
pub key_name: String,
pub key_prefix: Option<String>,
pub key_hash: Option<String>,
pub last_used_at: Option<Timestamp>,
pub revoked_at: Option<Timestamp>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -44,7 +44,6 @@ pub struct LlmRouterAccountUpsertInput {
pub route_origin: String,
pub idempotency_key: String,
pub router_account_id: Option<String>,
pub external_api_key_id: Option<String>,
pub credential_ciphertext: Option<String>,
pub account_json: Option<String>,
pub status: String,
@@ -54,6 +53,12 @@ pub struct LlmRouterAccountUpsertInput {
pub last_error: Option<String>,
pub credential_version: u32,
pub now_micros: i64,
pub key_id: String,
pub key_name: String,
pub key_prefix: Option<String>,
pub key_hash: Option<String>,
pub last_used_at_micros: Option<i64>,
pub revoked_at_micros: Option<i64>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -69,7 +74,6 @@ pub struct LlmRouterAccountSnapshot {
pub route_origin: String,
pub idempotency_key: String,
pub router_account_id: Option<String>,
pub external_api_key_id: Option<String>,
pub credential_ciphertext: Option<String>,
pub account_json: Option<String>,
pub status: String,
@@ -80,6 +84,12 @@ pub struct LlmRouterAccountSnapshot {
pub credential_version: u32,
pub created_at_micros: i64,
pub updated_at_micros: i64,
pub key_id: String,
pub key_name: String,
pub key_prefix: Option<String>,
pub key_hash: Option<String>,
pub last_used_at_micros: Option<i64>,
pub revoked_at_micros: Option<i64>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -143,7 +153,6 @@ fn upsert_llm_router_account(
route_origin,
idempotency_key,
router_account_id: normalize_optional(input.router_account_id),
external_api_key_id: normalize_optional(input.external_api_key_id),
credential_ciphertext: normalize_optional(input.credential_ciphertext),
account_json: normalize_optional(input.account_json),
status,
@@ -154,6 +163,16 @@ fn upsert_llm_router_account(
credential_version: input.credential_version.max(1),
created_at: now,
updated_at: now,
key_id: required(input.key_id, "key_id")?,
key_name: required(input.key_name, "key_name")?,
key_prefix: normalize_optional(input.key_prefix),
key_hash: normalize_optional(input.key_hash),
last_used_at: input
.last_used_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
revoked_at: input
.revoked_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
};
if ctx
.db
@@ -183,7 +202,6 @@ fn llm_router_account_snapshot_from_row(row: LlmRouterAccount) -> LlmRouterAccou
route_origin: row.route_origin,
idempotency_key: row.idempotency_key,
router_account_id: row.router_account_id,
external_api_key_id: row.external_api_key_id,
credential_ciphertext: row.credential_ciphertext,
account_json: row.account_json,
status: row.status,
@@ -194,6 +212,16 @@ fn llm_router_account_snapshot_from_row(row: LlmRouterAccount) -> LlmRouterAccou
credential_version: row.credential_version,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
key_id: row.key_id,
key_name: row.key_name,
key_prefix: row.key_prefix,
key_hash: row.key_hash,
last_used_at_micros: row
.last_used_at
.map(|value| value.to_micros_since_unix_epoch()),
revoked_at_micros: row
.revoked_at
.map(|value| value.to_micros_since_unix_epoch()),
}
}