From 6d41dc2f89befed37489df1339dc89b32eb8075b Mon Sep 17 00:00:00 2001 From: suzmii Date: Thu, 3 Sep 2026 19:03:40 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=87=E6=8D=A2=20LLM=20Router=20=E5=87=AD?= =?UTF-8?q?=E6=8D=AE=E5=88=B0=E4=B8=93=E7=94=A8=E8=B4=A6=E5=8F=B7=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 Router 账号、API Key 元数据和密文统一保存到 llm_router_account - 增加 Router API Key 的进程内缓存并清理旧 external_api_key 兼容路径 - 更新 SpacetimeDB 绑定、后端架构文档和决策记录 --- .../shared-memory/decision-log.md | 8 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 8 +- ...构】外部OpenAPI与APIKey接入方案-2026-06-19.md | 8 +- .../api-server/src/external_api_keys.rs | 915 ++++-------------- server-rs/crates/api-server/src/llm/mod.rs | 31 +- .../src/active/mapper/llm_router_account.rs | 28 +- .../llm_router_account_snapshot_type.rs | 7 +- .../llm_router_account_type.rs | 24 +- .../llm_router_account_upsert_input_type.rs | 7 +- .../src/llm_router_account.rs | 50 +- 10 files changed, 317 insertions(+), 769 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9138f5f4d..96e9a95bf 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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 Key;api-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` 的无限额度 Token(Token/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` 的无限额度 Token(Token/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 地址、模型、协议名或任何密钥/凭据字段,内部固定路由仅留在运行时配置与服务端代理中。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index f448eb648..3aefaa859 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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` diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index 586d46abb..43208c976 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -215,12 +215,14 @@ SpacetimeDB procedure: ### LLM Router 账号 Key 与 Router 代理 -普通 AGC 发行版不把 Router 当作客户端可配置 Provider,也不把 Router API Key 下发到桌面端。注册成功视为账号已有可用余额;认证成功后,api-server 异步尽力准备该用户对应的 Router 账号和 API Key,Router 控制面故障不得阻塞主站登录;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` 的 Token(Token/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 Key,Router 控制面故障不得阻塞主站登录;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` 的 Token(Token/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 Proxy(Bearer=平台 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/responses(Bearer=) ``` @@ -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。数据库记录缺失时,稳定短用户名/密码先尝试恢复远端账号;只有确认远端不存在才注册,避免跨部署数据库为空时重复创建用户。 ## 素材生成与落库 diff --git a/server-rs/crates/api-server/src/external_api_keys.rs b/server-rs/crates/api-server/src/external_api_keys.rs index bfba95732..45217ccd6 100644 --- a/server-rs/crates/api-server/src/external_api_keys.rs +++ b/server-rs/crates/api-server/src/external_api_keys.rs @@ -23,6 +23,7 @@ use spacetime_client::{ }; use std::collections::HashMap; use std::sync::{Arc, OnceLock}; +use std::time::{Duration, Instant}; use tokio::sync::Mutex; use crate::{ @@ -56,11 +57,20 @@ const EXTERNAL_API_KEY_PURPOSE_EXTERNAL_EDITOR: &str = "external-editor"; const EXTERNAL_API_KEY_PURPOSE_LLM_ROUTER: &str = "llm-router"; const LLM_ROUTER_RECONCILIATION_ERROR_PREFIX: &str = "llm-router-reconciliation-required:"; const LLM_ROUTER_PROVISIONING_CREDENTIAL_VERSION: u32 = 1; +const LLM_ROUTER_CREDENTIAL_CACHE_TTL: Duration = Duration::from_secs(600); type HmacSha256 = Hmac; static LLM_ROUTER_PROVISION_LOCKS: OnceLock>>>> = OnceLock::new(); +static LLM_ROUTER_CREDENTIAL_CACHE: OnceLock>> = + OnceLock::new(); + +struct CachedRouterCredential { + expires_at: Instant, + api_key: String, + key_id: String, +} struct ProvisionedRouterCredential { provider_account_id: String, @@ -84,9 +94,8 @@ struct RouterTokenMatch { } /// The Router API key and the credentials used to obtain it are one private -/// server-side secret. The existing `secret_ciphertext` column stores this -/// bundle for LLM Router rows; legacy rows containing an encrypted raw key remain -/// readable through `decrypt_router_api_key`. +/// server-side secret. The dedicated `llm_router_account.credential_ciphertext` +/// column stores this bundle; plaintext never leaves api-server. #[derive(Debug, Deserialize, Serialize)] struct RouterCredentialSecret { version: u32, @@ -100,6 +109,10 @@ fn llm_router_provision_locks() -> &'static Mutex> LLM_ROUTER_PROVISION_LOCKS.get_or_init(|| Mutex::new(HashMap::new())) } +fn llm_router_credential_cache() -> &'static Mutex> { + LLM_ROUTER_CREDENTIAL_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalApiKeyCreateRequest { @@ -212,24 +225,24 @@ pub async fn ensure_llm_router_api_key( Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, AppError> { - let key = ensure_llm_router_account(&state, authenticated.claims().user_id()) + let account = ensure_llm_router_account(&state, authenticated.claims().user_id()) .await .map_err(|message| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message) })?; Ok(json_success_body( Some(&request_context), - json!({ "key": external_api_key_payload_from_record(key) }), + json!({ "key": external_api_key_payload_from_router_account(account) }), )) } /// Ensures that the authenticated account has one server-owned Router key. /// The plaintext key never leaves this function and is persisted only as -/// authenticated encryption ciphertext in the existing external_api_key row. +/// authenticated encryption ciphertext in the dedicated llm_router_account row. pub(crate) async fn ensure_llm_router_account( state: &AppState, owner_user_id: &str, -) -> Result { +) -> Result { let owner_user_id = owner_user_id.trim(); if owner_user_id.is_empty() { return Err("LLM Router 账号缺少 owner_user_id".to_string()); @@ -247,58 +260,47 @@ pub(crate) async fn ensure_llm_router_account( ) }; let _guard = provision_lock.lock().await; - let existing = state - .spacetime_client() - .list_external_api_keys(owner_user_id.to_string()) - .await - .map_err(|error| format!("读取 LLM Router 账号失败:{error}"))? - .into_iter() - .collect::>(); let configured_route = state.config.llm_router_base_url.trim_end_matches('/'); - let configured_model = state.config.llm_router_model.trim(); let encryption_secret = state .config .effective_llm_router_api_key_encryption_secret() .ok_or_else(|| "LLM Router 账号密钥加密配置缺失".to_string())?; - let existing_account = select_existing_llm_router_account( - &existing, - configured_route, - configured_model, - encryption_secret.as_str(), - )?; - if let Some(existing) = existing_account { - tracing::debug!( - owner_user_id, - key_id = %existing.key_id, - "复用已存在的 LLM Router external_api_key" - ); - ensure_existing_llm_router_account_contract(state, existing).await?; - return Ok(existing.clone()); - } let account_key = derive_llm_router_account_key(owner_user_id, configured_route); let mut persisted = state .spacetime_client() .get_llm_router_account(owner_user_id.to_string(), configured_route.to_string()) .await .map_err(|error| format!("读取 LLM Router 账号状态失败:{error}"))?; + if let Some(existing) = persisted.as_ref().filter(|account| { + account.status == "active" + && account.revoked_at.is_none() + && account + .credential_ciphertext + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + }) { + tracing::debug!( + owner_user_id, + key_id = %existing.key_id, + "复用已存在的 LLM Router 账号 Key" + ); + ensure_existing_llm_router_account_contract(state, existing).await?; + return Ok(existing.clone()); + } - // A previous process may have revoked the external row before it crashed - // while updating the saga row. Repair that local state from the authoritative - // key list before deciding whether the account is blocked or reusable. + // A previous process may have revoked the Router account before it crashed + // while updating the saga row. Repair that local state before deciding whether + // the account is blocked or reusable. if let Some(account) = persisted.as_ref() - && let Some(external_key_id) = account.external_api_key_id.as_deref() - && existing - .iter() - .any(|record| record.key_id == external_key_id && record.revoked_at.is_some()) + && account.revoked_at.is_some() { let repaired = upsert_llm_router_account_state( state, account.account_key.as_str(), owner_user_id, configured_route, - external_key_id, + account.key_id.as_str(), account.router_account_id.clone(), - None, account.credential_ciphertext.clone(), account.account_json.clone(), "retryable", @@ -361,8 +363,9 @@ pub(crate) async fn ensure_llm_router_account( let key_id = persisted .as_ref() - .and_then(|account| account.external_api_key_id.clone()) - .unwrap_or_else(|| derive_llm_router_key_id(owner_user_id, configured_route, &existing)); + .map(|account| account.key_id.clone()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| derive_llm_router_key_id(owner_user_id, configured_route)); let mut reusable_login = None; let mut generated_new_pending_credentials = false; @@ -370,21 +373,8 @@ pub(crate) async fn ensure_llm_router_account( // A historical `llm-router` row may predate the saga table. Reuse its // encrypted username/password when the old key was revoked; otherwise // a fresh Router user would be created for the same Genarrative owner. - if let Some(login) = find_reusable_router_account_login( - &existing, - configured_route, - configured_model, - encryption_secret.as_str(), - )? { - reusable_login = Some(login.clone()); - bundle = Some(RouterCredentialSecret { - version: 1, - api_key: String::new(), - username: Some(login.username), - password: Some(login.password), - access_token: None, - }); - } + // The dedicated row already owns the encrypted login bundle; no + // historical API-key row is consulted for Router recovery. } if bundle.is_none() { @@ -406,7 +396,6 @@ pub(crate) async fn ensure_llm_router_account( configured_route, &key_id, None, - None, Some(ciphertext), persisted .as_ref() @@ -427,23 +416,7 @@ pub(crate) async fn ensure_llm_router_account( .as_ref() .is_some_and(|value| matches!(value.status.as_str(), "key_issued" | "active")) { - let key = persist_llm_router_external_key( - state, - owner_user_id, - configured_route, - configured_model, - &key_id, - existing_bundle, - persisted - .as_ref() - .and_then(|value| value.router_account_id.clone()), - persisted - .as_ref() - .and_then(|value| value.account_json.clone()), - encryption_secret.as_str(), - ) - .await?; - upsert_llm_router_account_state( + let active_account = upsert_llm_router_account_state( state, &account_key, owner_user_id, @@ -452,10 +425,10 @@ pub(crate) async fn ensure_llm_router_account( persisted .as_ref() .and_then(|value| value.router_account_id.clone()), - Some(key_id.clone()), - persisted - .as_ref() - .and_then(|value| value.credential_ciphertext.clone()), + Some(encrypt_router_credential_secret( + existing_bundle, + encryption_secret.as_str(), + )?), persisted .as_ref() .and_then(|value| value.account_json.clone()), @@ -466,7 +439,8 @@ pub(crate) async fn ensure_llm_router_account( None, ) .await?; - return Ok(key); + ensure_existing_llm_router_account_contract(state, &active_account).await?; + return Ok(active_account); } } @@ -511,7 +485,6 @@ pub(crate) async fn ensure_llm_router_account( persisted .as_ref() .and_then(|value| value.router_account_id.clone()), - None, pending_ciphertext.clone(), persisted .as_ref() @@ -548,7 +521,6 @@ pub(crate) async fn ensure_llm_router_account( persisted .as_ref() .and_then(|value| value.router_account_id.clone()), - None, pending_ciphertext, persisted .as_ref() @@ -586,7 +558,6 @@ pub(crate) async fn ensure_llm_router_account( configured_route, &key_id, Some(provisioned.provider_account_id.clone()), - None, Some(secret_ciphertext.clone()), provisioned.provider_account_json.clone(), "key_issued", @@ -596,103 +567,29 @@ pub(crate) async fn ensure_llm_router_account( None, ) .await?; - let key = state - .spacetime_client() - .create_external_api_key(ExternalApiKeyCreateRecordInput { - key_id: key_id.clone(), - owner_user_id: owner_user_id.to_string(), - name: LLM_ROUTER_TOKEN_IDENTIFIER.to_string(), - key_prefix: external_api_key_prefix(provisioned.raw_key.as_str()), - key_hash: hash_llm_router_key( - owner_user_id, - key_id.as_str(), - provisioned.raw_key.as_str(), - ), - scopes: LLM_ROUTER_API_KEY_SCOPES - .iter() - .map(|scope| (*scope).to_string()) - .collect(), - now_micros: current_utc_micros(), - purpose: EXTERNAL_API_KEY_PURPOSE_LLM_ROUTER.to_string(), - secret_ciphertext: Some(secret_ciphertext.clone()), - provider_account_id: Some(provisioned.provider_account_id.clone()), - provider_base_url: Some(state.config.llm_router_base_url.clone()), - provider_model: Some(state.config.llm_router_model.clone()), - provider_account_json: provisioned.provider_account_json.clone(), - credential_version: 1, - }) - .await; - match key { - Ok(key) => { - upsert_llm_router_account_state( - state, - &account_key, - owner_user_id, - configured_route, - &key_id, - Some(provisioned.provider_account_id.clone()), - Some(key_id.clone()), - Some(secret_ciphertext), - provisioned.provider_account_json.clone(), - "active", - attempt_count, - None, - None, - None, - ) - .await?; - tracing::info!( - owner_user_id, - key_id = %key.key_id, - router_account_id = ?provisioned.provider_account_id, - "LLM Router 账号与 external_api_key provisioning 完成" - ); - Ok(key) - } - Err(error) => { - // Multiple api-server instances do not share the in-process mutex. - // If another instance won the insert race, reuse its validated row - // instead of reporting a spurious failure or provisioning again. - if let Ok(refreshed) = state - .spacetime_client() - .list_external_api_keys(owner_user_id.to_string()) - .await - { - if let Ok(Some(existing)) = select_existing_llm_router_account( - &refreshed, - configured_route, - configured_model, - encryption_secret.as_str(), - ) { - return Ok(existing.clone()); - } - } - // The remote key has already been issued and the local write uses a - // deterministic key id. Keep the saga at key_issued so the next - // authenticated request can safely retry the idempotent local write; - // reconciliation_required is reserved for an uncertain remote side - // effect (registration/login/token/key issuance). - let message = format!("LLM Router Key 已签发但本地落库未完成:{error}"); - let _ = upsert_llm_router_account_state( - state, - &account_key, - owner_user_id, - configured_route, - &key_id, - Some(provisioned.provider_account_id), - None, - Some(secret_ciphertext), - provisioned.provider_account_json, - "key_issued", - attempt_count, - None, - Some(current_utc_micros().saturating_add(60_000_000)), - Some(message.clone()), - ) - .await; - Err(message) - } - } + let account = upsert_llm_router_account_state( + state, + &account_key, + owner_user_id, + configured_route, + &key_id, + Some(provisioned.provider_account_id.clone()), + Some(secret_ciphertext), + provisioned.provider_account_json.clone(), + "active", + attempt_count, + None, + None, + None, + ) + .await?; + tracing::info!( + owner_user_id, + key_id = %account.key_id, + router_account_id = ?provisioned.provider_account_id, + "LLM Router 账号与 API Key provisioning 完成" + ); + Ok(account) } async fn upsert_llm_router_account_state( @@ -700,9 +597,8 @@ async fn upsert_llm_router_account_state( account_key: &str, owner_user_id: &str, route_origin: &str, - external_api_key_id: &str, + key_id: &str, router_account_id: Option, - persisted_external_api_key_id: Option, credential_ciphertext: Option, account_json: Option, status: &str, @@ -711,73 +607,59 @@ async fn upsert_llm_router_account_state( next_retry_at_micros: Option, last_error: Option, ) -> Result { + let key_material = credential_ciphertext.as_deref().and_then(|ciphertext| { + state + .config + .effective_llm_router_api_key_encryption_secret() + .and_then(|secret| decrypt_router_credential_secret(ciphertext, secret.as_str()).ok()) + .filter(|bundle| !bundle.api_key.trim().is_empty()) + .map(|bundle| { + let raw_key = bundle.api_key.trim().to_string(); + ( + external_api_key_prefix(raw_key.as_str()), + hash_llm_router_key(owner_user_id, key_id, raw_key.as_str()), + ) + }) + }); state .spacetime_client() .upsert_llm_router_account(LlmRouterAccountUpsertRecordInput { account_key: account_key.to_string(), owner_user_id: owner_user_id.to_string(), route_origin: route_origin.to_string(), - idempotency_key: external_api_key_id.to_string(), + idempotency_key: key_id.to_string(), router_account_id, - external_api_key_id: persisted_external_api_key_id, credential_ciphertext, account_json, status: status.to_string(), attempt_count, lease_until_micros, next_retry_at_micros, - last_error, + last_error: last_error.clone(), credential_version: 1, now_micros: current_utc_micros(), + key_id: key_id.to_string(), + key_name: LLM_ROUTER_TOKEN_IDENTIFIER.to_string(), + key_prefix: key_material.as_ref().map(|(prefix, _)| prefix.clone()), + key_hash: key_material.map(|(_, hash)| hash), + last_used_at_micros: None, + revoked_at_micros: last_error + .as_deref() + .filter(|value| { + value.contains("Router 返回 401/403") || value.contains("Router Key 已撤销") + }) + .map(|_| current_utc_micros()), }) .await .map_err(|error| format!("保存 LLM Router 账号状态失败:{error}")) } -async fn persist_llm_router_external_key( - state: &AppState, - owner_user_id: &str, - route_origin: &str, - model: &str, - key_id: &str, - bundle: &RouterCredentialSecret, - router_account_id: Option, - account_json: Option, - encryption_secret: &str, -) -> Result { - let raw_key = normalize_router_api_key(bundle.api_key.as_str())?; - let secret_ciphertext = encrypt_router_credential_secret(bundle, encryption_secret)?; - state - .spacetime_client() - .create_external_api_key(ExternalApiKeyCreateRecordInput { - key_id: key_id.to_string(), - owner_user_id: owner_user_id.to_string(), - name: LLM_ROUTER_TOKEN_IDENTIFIER.to_string(), - key_prefix: external_api_key_prefix(raw_key.as_str()), - key_hash: hash_llm_router_key(owner_user_id, key_id, raw_key.as_str()), - scopes: LLM_ROUTER_API_KEY_SCOPES - .iter() - .map(|scope| (*scope).to_string()) - .collect(), - now_micros: current_utc_micros(), - purpose: EXTERNAL_API_KEY_PURPOSE_LLM_ROUTER.to_string(), - secret_ciphertext: Some(secret_ciphertext), - provider_account_id: router_account_id, - provider_base_url: Some(route_origin.to_string()), - provider_model: Some(model.to_string()), - provider_account_json: account_json, - credential_version: 1, - }) - .await - .map_err(|error| format!("LLM Router Key 补写失败:{error}")) -} - /// Ensures the Router user has the fixed AGC subscription before the server /// exposes/reuses its API key. Subscription management is deliberately tied to /// authentication/key preparation rather than the streaming response path. async fn ensure_existing_llm_router_account_contract( state: &AppState, - record: &ExternalApiKeyRecord, + record: &LlmRouterAccountRecord, ) -> Result<(), String> { let Some(admin_token) = state .config @@ -796,7 +678,7 @@ async fn ensure_existing_llm_router_account_contract( .effective_llm_router_api_key_encryption_secret() .ok_or_else(|| "LLM Router 账号密钥加密配置缺失".to_string())?; let bundle = record - .secret_ciphertext + .credential_ciphertext .as_deref() .filter(|value| !value.trim().is_empty()) .map(|ciphertext| decrypt_router_credential_secret(ciphertext, encryption_secret.as_str())) @@ -820,7 +702,7 @@ async fn ensure_existing_llm_router_account_contract( .ok_or_else(|| { format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}无法通过用户名查询 Router 用户 ID") })?; - if let Some(recorded_router_user_id) = router_user_id_from_record(record) + if let Some(recorded_router_user_id) = router_user_id_from_account(record) && recorded_router_user_id != router_user_id { return Err(format!( @@ -1044,14 +926,14 @@ async fn ensure_router_subscription( Ok(()) } -fn router_user_id_from_record(record: &ExternalApiKeyRecord) -> Option { +fn router_user_id_from_account(record: &LlmRouterAccountRecord) -> Option { record - .provider_account_id + .router_account_id .as_deref() .and_then(|value| value.trim().parse::().ok()) .or_else(|| { record - .provider_account_json + .account_json .as_deref() .and_then(|value| serde_json::from_str::(value).ok()) .and_then(|value| { @@ -1124,28 +1006,61 @@ pub(crate) async fn read_active_llm_router_credentials( return Err("LLM Router 账号缺少 owner_user_id".to_string()); } ensure_llm_router_target_allowed(state)?; + let cache_key = format!( + "{}|{}", + owner_user_id, + state.config.llm_router_base_url.trim_end_matches('/') + ); + let now = Instant::now(); + let mut cache = llm_router_credential_cache().lock().await; + if let Some(cached) = cache + .get(cache_key.as_str()) + .filter(|entry| entry.expires_at > now) + { + return Ok(Some(( + state + .config + .llm_router_base_url + .trim_end_matches('/') + .to_string(), + cached.api_key.clone(), + cached.key_id.clone(), + ))); + } + cache.remove(cache_key.as_str()); + drop(cache); let encryption_secret = state .config .effective_llm_router_api_key_encryption_secret() .ok_or_else(|| "LLM Router 账号密钥加密配置缺失".to_string())?; - let records = state + let Some(account) = state .spacetime_client() - .list_external_api_keys(owner_user_id.to_string()) + .get_llm_router_account( + owner_user_id.to_string(), + state + .config + .llm_router_base_url + .trim_end_matches('/') + .to_string(), + ) .await - .map_err(|error| format!("读取 LLM Router 账号失败:{error}"))?; - let Some(record) = select_existing_llm_router_account( - records.as_slice(), - state.config.llm_router_base_url.trim_end_matches('/'), - state.config.llm_router_model.trim(), - encryption_secret.as_str(), - )? + .map_err(|error| format!("读取 LLM Router 账号失败:{error}"))? + .filter(|account| account.status == "active" && account.revoked_at.is_none()) else { return Ok(None); }; - let ciphertext = record.secret_ciphertext.as_deref().ok_or_else(|| { + let ciphertext = account.credential_ciphertext.as_deref().ok_or_else(|| { format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}active Router Key 密文缺失") })?; let api_key = decrypt_router_api_key(ciphertext, encryption_secret.as_str())?; + llm_router_credential_cache().lock().await.insert( + cache_key, + CachedRouterCredential { + expires_at: Instant::now() + LLM_ROUTER_CREDENTIAL_CACHE_TTL, + api_key: api_key.clone(), + key_id: account.key_id.clone(), + }, + ); Ok(Some(( state .config @@ -1153,150 +1068,26 @@ pub(crate) async fn read_active_llm_router_credentials( .trim_end_matches('/') .to_string(), api_key, - record.key_id.clone(), + account.key_id, ))) } -/// Validates the persisted LLM Router state before provisioning another Router key. -/// -/// The table intentionally has no composite unique constraint because legacy -/// external-editor rows and historical deployments must remain readable. We -/// therefore enforce the LLM Router invariant at the api-server boundary: at most one -/// active row, exact route/model metadata, and decryptable ciphertext. -fn select_existing_llm_router_account<'a>( - records: &'a [ExternalApiKeyRecord], - configured_route: &str, - configured_model: &str, - encryption_secret: &str, -) -> Result, String> { - let active = records - .iter() - .filter(|record| { - is_llm_router_purpose(record.purpose.as_str()) && record.revoked_at.is_none() - }) - .collect::>(); - if active.len() > 1 { - return Err(format!( - "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}账号存在多个 active Router Key,请联系支持完成对账" - )); - } - let Some(record) = active.first().copied() else { - return Ok(None); - }; - - let metadata_matches = record - .provider_base_url - .as_deref() - .map(|value| value.trim_end_matches('/')) - == Some(configured_route) - && record.provider_model.as_deref().map(str::trim) == Some(configured_model) - && record - .provider_account_id - .as_deref() - .is_some_and(|value| !value.trim().is_empty()); - let Some(ciphertext) = record - .secret_ciphertext - .as_deref() - .filter(|value| !value.trim().is_empty()) - else { - return Err(format!( - "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}active Router Key 密文缺失,请联系支持完成对账" - )); - }; - if !metadata_matches { - return Err(format!( - "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}active Router Key 路由元数据不一致,请联系支持完成对账" - )); - } - decrypt_router_api_key(ciphertext, encryption_secret).map_err(|error| { - format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}active Router Key 密文无法校验:{error}") - })?; - Ok(Some(record)) -} - -/// Finds the one historical Router account whose encrypted bundle still -/// contains the username/password needed to sign a replacement API key after -/// the previous key was revoked. Current deterministic credentials are derived -/// directly when the local row is missing; this helper only covers legacy rows -/// that still need their stored username/password for recovery. -fn find_reusable_router_account_login( - records: &[ExternalApiKeyRecord], - configured_route: &str, - configured_model: &str, - encryption_secret: &str, -) -> Result, String> { - let mut candidates = Vec::new(); - for record in records.iter().filter(|record| { - is_llm_router_purpose(record.purpose.as_str()) && record.revoked_at.is_some() - }) { - let route_matches = record - .provider_base_url - .as_deref() - .map(|value| value.trim_end_matches('/')) - == Some(configured_route) - && record.provider_model.as_deref().map(str::trim) == Some(configured_model); - if !route_matches { - continue; - } - let Some(ciphertext) = record - .secret_ciphertext - .as_deref() - .filter(|value| !value.trim().is_empty()) - else { - continue; - }; - let bundle = - decrypt_router_credential_secret(ciphertext, encryption_secret).map_err(|error| { - format!( - "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}历史 Router 账号凭据无法校验:{error}" - ) - })?; - let (Some(username), Some(password)) = (bundle.username, bundle.password) else { - continue; - }; - candidates.push(RouterAccountLogin { username, password }); - } - - match candidates.len() { - 0 => Ok(None), - 1 => Ok(candidates.pop()), - _ => Err(format!( - "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}存在多个可复用的历史 Router 账号凭据,请联系支持完成对账" - )), - } -} - -fn is_llm_router_purpose(value: &str) -> bool { - value.trim() == EXTERNAL_API_KEY_PURPOSE_LLM_ROUTER -} - pub(crate) async fn revoke_llm_router_account( state: &AppState, owner_user_id: &str, key_id: &str, ) -> Result<(), String> { - state - .spacetime_client() - .revoke_llm_router_api_key(ExternalApiKeyRevokeRecordInput { - key_id: key_id.to_string(), - owner_user_id: owner_user_id.to_string(), - revoked_at_micros: current_utc_micros(), - }) - .await - .map_err(|error| format!("标记 LLM Router 账号 Key 失效失败:{error}"))?; - - // Keep the durable saga in a retryable state after a deterministic Router - // 401/403. The encrypted username/password bundle is retained so the next - // request can log in again and issue a replacement key without registering - // a second Router account. Clearing external_api_key_id also forces a new - // deterministic generation id for the replacement row. let route_origin = state.config.llm_router_base_url.trim_end_matches('/'); + llm_router_credential_cache() + .lock() + .await + .remove(format!("{}|{}", owner_user_id, route_origin).as_str()); if let Some(account) = state .spacetime_client() .get_llm_router_account(owner_user_id.to_string(), route_origin.to_string()) .await .map_err(|error| format!("读取 LLM Router 账号失效状态失败:{error}"))? - .filter(|account| account.external_api_key_id.as_deref() == Some(key_id)) + .filter(|account| account.key_id == key_id) { upsert_llm_router_account_state( state, @@ -1305,7 +1096,6 @@ pub(crate) async fn revoke_llm_router_account( route_origin, key_id, account.router_account_id.clone(), - None, account.credential_ciphertext.clone(), account.account_json.clone(), "retryable", @@ -2258,22 +2048,12 @@ fn provider_value_to_string(value: &Value) -> Option { .filter(|value| !value.is_empty()) } -fn derive_llm_router_key_id( - owner_user_id: &str, - route_origin: &str, - records: &[ExternalApiKeyRecord], -) -> String { - let generation = records - .iter() - .filter(|record| is_llm_router_purpose(record.purpose.as_str())) - .count() - .saturating_add(1); +fn derive_llm_router_key_id(owner_user_id: &str, route_origin: &str) -> String { let fingerprint = Sha256::digest( format!( - "{}\n{}\n{}", + "{}\n{}", owner_user_id.trim(), - route_origin.trim_end_matches('/'), - generation + route_origin.trim_end_matches('/') ) .as_bytes(), ); @@ -2321,59 +2101,6 @@ fn normalize_new_api_relay_api_key(value: &str) -> Result { normalize_router_api_key(value) } -fn sanitize_provider_account_json(value: &Value) -> Option { - let sanitized = sanitize_provider_account_value(value); - (!sanitized.is_null()).then(|| sanitized.to_string()) -} - -fn sanitize_provider_account_value(value: &Value) -> Value { - match value { - Value::Object(object) => Value::Object( - object - .iter() - .filter(|(key, _)| !is_sensitive_provider_field(key)) - .map(|(key, value)| (key.clone(), sanitize_provider_account_value(value))) - .collect(), - ), - Value::Array(values) => { - Value::Array(values.iter().map(sanitize_provider_account_value).collect()) - } - other => other.clone(), - } -} - -fn is_sensitive_provider_field(key: &str) -> bool { - matches!( - key.chars() - .filter(|character| character.is_ascii_alphanumeric()) - .collect::() - .to_ascii_lowercase() - .as_str(), - "apikey" - | "accesstoken" - | "refreshtoken" - | "authorization" - | "secret" - | "password" - | "credential" - | "credentials" - ) -} - -pub(crate) fn encrypt_router_api_key(raw_key: &str, secret: &str) -> Result { - encrypt_router_secret_payload( - &serde_json::to_vec(&RouterCredentialSecret { - version: 1, - api_key: normalize_router_api_key(raw_key)?, - username: None, - password: None, - access_token: None, - }) - .map_err(|error| format!("LLM Router API Key 序列化失败:{error}"))?, - secret, - ) -} - fn encrypt_router_credential_secret( secret_payload: &RouterCredentialSecret, secret: &str, @@ -2469,18 +2196,6 @@ pub async fn revoke_external_api_key( Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, AppError> { - let records = state - .spacetime_client() - .list_external_api_keys(authenticated.claims().user_id().to_string()) - .await - .map_err(map_external_api_key_error)?; - if records - .iter() - .any(|record| record.key_id == key_id && is_llm_router_purpose(record.purpose.as_str())) - { - return Err(AppError::from_status(StatusCode::FORBIDDEN) - .with_message("LLM Router Key 由服务端管理,不能通过个人 API Key 接口撤销")); - } let key = state .spacetime_client() .revoke_external_api_key(ExternalApiKeyRevokeRecordInput { @@ -2539,6 +2254,25 @@ fn external_api_key_payload_from_record(record: ExternalApiKeyRecord) -> Externa } } +fn external_api_key_payload_from_router_account( + account: LlmRouterAccountRecord, +) -> ExternalApiKeyPayload { + ExternalApiKeyPayload { + key_id: account.key_id, + name: account.key_name, + key_prefix: account.key_prefix.unwrap_or_default(), + scopes: LLM_ROUTER_API_KEY_SCOPES + .iter() + .map(|scope| (*scope).to_string()) + .collect(), + purpose: EXTERNAL_API_KEY_PURPOSE_LLM_ROUTER.to_string(), + created_at: account.created_at, + last_used_at: account.last_used_at, + revoked_at: account.revoked_at, + updated_at: account.updated_at, + } +} + pub(crate) fn map_external_api_key_error(error: SpacetimeClientError) -> AppError { match error { SpacetimeClientError::Procedure(message) @@ -2581,35 +2315,6 @@ mod tests { use crate::{config::AppConfig, state::AppState}; - fn llm_router_record( - key_id: &str, - route: Option<&str>, - model: Option<&str>, - ciphertext: Option<&str>, - revoked: bool, - ) -> ExternalApiKeyRecord { - ExternalApiKeyRecord { - key_id: key_id.to_string(), - owner_user_id: "user-1".to_string(), - name: LLM_ROUTER_TOKEN_IDENTIFIER.to_string(), - key_prefix: "tnr_sk_fixture".to_string(), - scopes: vec!["llm:responses".to_string()], - purpose: EXTERNAL_API_KEY_PURPOSE_LLM_ROUTER.to_string(), - created_at: "2026-08-29T00:00:00Z".to_string(), - last_used_at: None, - revoked_at: revoked.then(|| "2026-08-29T01:00:00Z".to_string()), - updated_at: "2026-08-29T00:00:00Z".to_string(), - secret_ciphertext: ciphertext.map(|value| value.to_string()), - provider_account_id: Some("llm-router-account-fixture".to_string()), - provider_base_url: route.map(|value| value.to_string()), - provider_model: model.map(|value| value.to_string()), - provider_account_json: Some( - json!({"accountId":"llm-router-account-fixture"}).to_string(), - ), - credential_version: 1, - } - } - #[test] fn generated_external_api_key_uses_public_prefix_only_for_display() { let raw_key = generate_external_api_key_secret(); @@ -2993,19 +2698,30 @@ mod tests { "fixture-encryption-secret", ) .expect("credential should encrypt"); - let mut record = llm_router_record( - "key-active", - Some(base_url.as_str()), - Some("gpt-5.6-sol"), - Some(ciphertext.as_str()), - false, - ); - record.owner_user_id = owner_user_id.to_string(); - record.provider_account_id = Some("42".to_string()); - record.provider_account_json = - Some(json!({"routerUserId": 42, "username": username}).to_string()); - - ensure_existing_llm_router_account_contract(&state, &record) + let account = LlmRouterAccountRecord { + account_key: "fixture-account".to_string(), + owner_user_id: owner_user_id.to_string(), + route_origin: base_url.trim_end_matches('/').to_string(), + idempotency_key: "key-active".to_string(), + router_account_id: Some("42".to_string()), + credential_ciphertext: Some(ciphertext), + account_json: Some(json!({"routerUserId": 42, "username": username}).to_string()), + status: "active".to_string(), + attempt_count: 0, + lease_until_micros: None, + next_retry_at_micros: None, + last_error: None, + credential_version: 1, + created_at: "2026-08-29T00:00:00Z".to_string(), + updated_at: "2026-08-29T00:00:00Z".to_string(), + key_id: "key-active".to_string(), + key_name: LLM_ROUTER_TOKEN_IDENTIFIER.to_string(), + key_prefix: Some("tnr_sk_fixture".to_string()), + key_hash: None, + last_used_at: None, + revoked_at: None, + }; + ensure_existing_llm_router_account_contract(&state, &account) .await .expect("active Router contract should repair"); @@ -3105,159 +2821,6 @@ mod tests { ); } - #[test] - fn llm_router_key_id_is_stable_and_generation_scoped() { - let first_generation = - derive_llm_router_key_id("user-1", "https://router.example/v1/", &[]); - let same_generation = derive_llm_router_key_id("user-1", "https://router.example/v1", &[]); - assert_eq!(first_generation, same_generation); - - let first_row = llm_router_record( - &first_generation, - Some("https://router.example/v1"), - Some("gpt-5.6-sol"), - None, - true, - ); - let second_generation = - derive_llm_router_key_id("user-1", "https://router.example/v1", &[first_row]); - assert_ne!(first_generation, second_generation); - - let different_owner = derive_llm_router_key_id("user-2", "https://router.example/v1", &[]); - let different_route = derive_llm_router_key_id("user-1", "https://router.example/v2", &[]); - assert_ne!(first_generation, different_owner); - assert_ne!(first_generation, different_route); - } - - #[test] - fn existing_llm_router_account_requires_validated_single_active_row() { - let ciphertext = encrypt_router_api_key("tnr_sk_fixture_123", "test-secret") - .expect("fixture ciphertext"); - let record = llm_router_record( - "key-1", - Some("https://router.example/v1"), - Some("gpt-5.6-sol"), - Some(&ciphertext), - false, - ); - let single = [record.clone()]; - let selected = select_existing_llm_router_account( - &single, - "https://router.example/v1", - "gpt-5.6-sol", - "test-secret", - ) - .expect("valid LLM Router row should be reusable") - .expect("active LLM Router row"); - assert_eq!(selected.key_id, "key-1"); - - let duplicate_error = select_existing_llm_router_account( - &[ - record.clone(), - llm_router_record( - "key-2", - Some("https://router.example/v1"), - Some("gpt-5.6-sol"), - Some(&ciphertext), - false, - ), - ], - "https://router.example/v1", - "gpt-5.6-sol", - "test-secret", - ) - .expect_err("duplicate active rows must reconcile"); - assert!(duplicate_error.starts_with(LLM_ROUTER_RECONCILIATION_ERROR_PREFIX)); - } - - #[test] - fn existing_llm_router_account_rejects_missing_or_corrupt_state() { - let missing_ciphertext = select_existing_llm_router_account( - &[llm_router_record( - "key-1", - Some("https://router.example/v1"), - Some("gpt-5.6-sol"), - None, - false, - )], - "https://router.example/v1", - "gpt-5.6-sol", - "test-secret", - ) - .expect_err("missing ciphertext must reconcile"); - assert!(missing_ciphertext.starts_with(LLM_ROUTER_RECONCILIATION_ERROR_PREFIX)); - - let corrupt_ciphertext = select_existing_llm_router_account( - &[llm_router_record( - "key-1", - Some("https://router.example/v1"), - Some("gpt-5.6-sol"), - Some("not-a-ciphertext"), - false, - )], - "https://router.example/v1", - "gpt-5.6-sol", - "test-secret", - ) - .expect_err("corrupt ciphertext must reconcile"); - assert!(corrupt_ciphertext.starts_with(LLM_ROUTER_RECONCILIATION_ERROR_PREFIX)); - - let metadata_error = select_existing_llm_router_account( - &[llm_router_record( - "key-1", - Some("https://router.example/v2"), - Some("gpt-5.6-sol"), - Some("not-a-ciphertext"), - false, - )], - "https://router.example/v1", - "gpt-5.6-sol", - "test-secret", - ) - .expect_err("route drift must reconcile"); - assert!(metadata_error.starts_with(LLM_ROUTER_RECONCILIATION_ERROR_PREFIX)); - } - - #[test] - fn revoked_llm_router_account_is_not_reused() { - let ciphertext = encrypt_router_api_key("tnr_sk_fixture_123", "test-secret") - .expect("fixture ciphertext"); - let revoked = [llm_router_record( - "key-1", - Some("https://router.example/v1"), - Some("gpt-5.6-sol"), - Some(&ciphertext), - true, - )]; - let selected = select_existing_llm_router_account( - &revoked, - "https://router.example/v1", - "gpt-5.6-sol", - "test-secret", - ) - .expect("revoked rows should not block provisioning"); - assert!(selected.is_none()); - } - - #[test] - fn provider_account_metadata_drops_plaintext_credentials() { - let sanitized = sanitize_provider_account_json(&json!({ - "accountId": "router-account-1", - "displayName": "fixture", - "apiKey": "tnr_sk_should-not-persist-here", - "nested": { - "access_token": "secret", - "region": "cn" - }, - "keyId": "safe-key-id" - })) - .expect("metadata should remain after redaction"); - assert!(!sanitized.contains("tnr_sk_should-not-persist-here")); - assert!(!sanitized.contains("access_token")); - assert!(sanitized.contains("router-account-1")); - assert!(sanitized.contains("safe-key-id")); - } - #[test] fn router_credential_bundle_round_trip_keeps_password_server_side() { let bundle = RouterCredentialSecret { @@ -3281,74 +2844,6 @@ mod tests { assert!(!ciphertext.contains("Router-random-password")); } - #[test] - fn revoked_router_account_can_reuse_saved_login_credentials() { - let bundle = RouterCredentialSecret { - version: 1, - api_key: "sk-old-router-key".to_string(), - username: Some("router_user".to_string()), - password: Some("Router-random-password".to_string()), - access_token: None, - }; - let ciphertext = encrypt_router_credential_secret(&bundle, "test-secret") - .expect("credential bundle should encrypt"); - let record = llm_router_record( - "key-revoked", - Some("https://router.example/v1"), - Some("gpt-5.6-sol"), - Some(&ciphertext), - true, - ); - - let login = find_reusable_router_account_login( - &[record], - "https://router.example/v1", - "gpt-5.6-sol", - "test-secret", - ) - .expect("historical credentials should be inspectable") - .expect("historical credentials should be reusable"); - assert_eq!(login.username, "router_user"); - assert_eq!(login.password, "Router-random-password"); - } - - #[test] - fn multiple_reusable_router_accounts_require_reconciliation() { - let bundle = RouterCredentialSecret { - version: 1, - api_key: "sk-old-router-key".to_string(), - username: Some("router_user".to_string()), - password: Some("Router-random-password".to_string()), - access_token: None, - }; - let ciphertext = encrypt_router_credential_secret(&bundle, "test-secret") - .expect("credential bundle should encrypt"); - let records = [ - llm_router_record( - "key-revoked-1", - Some("https://router.example/v1"), - Some("gpt-5.6-sol"), - Some(&ciphertext), - true, - ), - llm_router_record( - "key-revoked-2", - Some("https://router.example/v1"), - Some("gpt-5.6-sol"), - Some(&ciphertext), - true, - ), - ]; - let error = find_reusable_router_account_login( - &records, - "https://router.example/v1", - "gpt-5.6-sol", - "test-secret", - ) - .expect_err("ambiguous historical credentials must reconcile"); - assert!(error.starts_with(LLM_ROUTER_RECONCILIATION_ERROR_PREFIX)); - } - #[test] fn router_control_origin_removes_responses_path() { assert_eq!( diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 07c357f26..d9b5fcf6f 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -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)] diff --git a/server-rs/crates/spacetime-client/src/active/mapper/llm_router_account.rs b/server-rs/crates/spacetime-client/src/active/mapper/llm_router_account.rs index d58c847c0..322df9061 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/llm_router_account.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/llm_router_account.rs @@ -7,7 +7,6 @@ pub struct LlmRouterAccountRecord { pub route_origin: String, pub idempotency_key: String, pub router_account_id: Option, - pub external_api_key_id: Option, pub credential_ciphertext: Option, pub account_json: Option, 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, + pub key_hash: Option, + pub last_used_at: Option, + pub revoked_at: Option, } #[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, - pub external_api_key_id: Option, pub credential_ciphertext: Option, pub account_json: Option, pub status: String, @@ -37,6 +41,12 @@ pub struct LlmRouterAccountUpsertRecordInput { pub last_error: Option, pub credential_version: u32, pub now_micros: i64, + pub key_id: String, + pub key_name: String, + pub key_prefix: Option, + pub key_hash: Option, + pub last_used_at_micros: Option, + pub revoked_at_micros: Option, } impl From @@ -49,7 +59,6 @@ impl From 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 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), } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_snapshot_type.rs index 737d80203..bcb6d3cec 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_snapshot_type.rs @@ -12,7 +12,6 @@ pub struct LlmRouterAccountSnapshot { pub route_origin: String, pub idempotency_key: String, pub router_account_id: Option, - pub external_api_key_id: Option, pub credential_ciphertext: Option, pub account_json: Option, 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, + pub key_hash: Option, + pub last_used_at_micros: Option, + pub revoked_at_micros: Option, } impl __sdk::InModule for LlmRouterAccountSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_type.rs index bd6dd9248..cf3d4f5b1 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_type.rs @@ -12,7 +12,6 @@ pub struct LlmRouterAccount { pub route_origin: String, pub idempotency_key: String, pub router_account_id: Option, - pub external_api_key_id: Option, pub credential_ciphertext: Option, pub account_json: Option, 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, + pub key_hash: Option, + 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, pub idempotency_key: __sdk::__query_builder::Col, pub router_account_id: __sdk::__query_builder::Col>, - pub external_api_key_id: __sdk::__query_builder::Col>, pub credential_ciphertext: __sdk::__query_builder::Col>, pub account_json: __sdk::__query_builder::Col>, pub status: __sdk::__query_builder::Col, @@ -49,6 +53,12 @@ pub struct LlmRouterAccountCols { pub credential_version: __sdk::__query_builder::Col, pub created_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, + pub key_id: __sdk::__query_builder::Col, + pub key_name: __sdk::__query_builder::Col, + pub key_prefix: __sdk::__query_builder::Col>, + pub key_hash: __sdk::__query_builder::Col>, + pub last_used_at: __sdk::__query_builder::Col>, + pub revoked_at: __sdk::__query_builder::Col>, } 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"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_upsert_input_type.rs index 8ab79b5b2..57faecffa 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_upsert_input_type.rs @@ -12,7 +12,6 @@ pub struct LlmRouterAccountUpsertInput { pub route_origin: String, pub idempotency_key: String, pub router_account_id: Option, - pub external_api_key_id: Option, pub credential_ciphertext: Option, pub account_json: Option, pub status: String, @@ -22,6 +21,12 @@ pub struct LlmRouterAccountUpsertInput { pub last_error: Option, pub credential_version: u32, pub now_micros: i64, + pub key_id: String, + pub key_name: String, + pub key_prefix: Option, + pub key_hash: Option, + pub last_used_at_micros: Option, + pub revoked_at_micros: Option, } impl __sdk::InModule for LlmRouterAccountUpsertInput { diff --git a/server-rs/crates/spacetime-module/src/llm_router_account.rs b/server-rs/crates/spacetime-module/src/llm_router_account.rs index 9bbb6bb12..d59cc6016 100644 --- a/server-rs/crates/spacetime-module/src/llm_router_account.rs +++ b/server-rs/crates/spacetime-module/src/llm_router_account.rs @@ -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, - pub external_api_key_id: Option, pub credential_ciphertext: Option, pub account_json: Option, 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, + pub key_hash: Option, + pub last_used_at: Option, + pub revoked_at: Option, } #[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, - pub external_api_key_id: Option, pub credential_ciphertext: Option, pub account_json: Option, pub status: String, @@ -54,6 +53,12 @@ pub struct LlmRouterAccountUpsertInput { pub last_error: Option, pub credential_version: u32, pub now_micros: i64, + pub key_id: String, + pub key_name: String, + pub key_prefix: Option, + pub key_hash: Option, + pub last_used_at_micros: Option, + pub revoked_at_micros: Option, } #[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, - pub external_api_key_id: Option, pub credential_ciphertext: Option, pub account_json: Option, 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, + pub key_hash: Option, + pub last_used_at_micros: Option, + pub revoked_at_micros: Option, } #[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()), } }