From 985df53a4e6935c36928ce3c8f043dfcffd91cc6 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:49:47 +0800 Subject: [PATCH] =?UTF-8?q?Router=20API=20Key=20=E5=88=86=E7=BB=84?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E4=B8=8E=E8=B4=A6=E5=8F=B7=E5=90=8C=E7=BB=84?= =?UTF-8?q?=E7=9A=84=20taonier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LLM_ROUTER_TOKEN_GROUP 由 default 改为 taonier,Router 用户与它名下固定 Token / API Key 现在同属 taonier 分组 - 保留固定契约归一:创建后无条件 PUT 完整契约(group / unlimited_quota / expired_time),复用与登录恢复路径同样 PUT,存量 default 分组 Token 会在下次 provisioning、登录恢复或显式准备 Key 时纠正 - 同步创建路径与复用路径的注释,保留“分组缺模型会失败为 model_not_found”的排查提示 - 用例同步:2 个用例重命名为 taonier 语义,6 处 group 断言改为 taonier - 文档同步:外部 OpenAPI 与 APIKey 接入方案写明 Token 与用户同组 taonier 及其前提;决策记录追加 2026-09-23 调整说明 - 验证:cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server external_api_keys(22 passed)、同命令 router 过滤(27 passed)、cargo fmt --all --check、npm run check:encoding、npm run check:doc-index、git diff --check --- .../shared-memory/decision-log.md | 3 +- ...构】外部OpenAPI与APIKey接入方案-2026-06-19.md | 2 +- .../api-server/src/external_api_keys.rs | 43 ++++++++++--------- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index cd5bdc2e0..6aee441ad 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8633,7 +8633,8 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 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`,脱敏账号信息和 API Key 核心字段保存到 `llm_router_account`;客户端和普通用户永远不可见 Router Key。管理员 Token 仅存在 api-server 私有配置,不写入数据库或日志;Router 凭据只来源于这条正式账号流程。 +- 每个 Genarrative 用户在认证成功后都必须幂等准备独立 Router 账号:api-server 使用管理员 Token 创建随机密码普通用户,查询用户 ID,设置用户 `group=taonier`,登录、创建或复用固定标识 `agc_auto_generate` 的无限额度 Token(Token/API Key 使用 `taonier` 分组;发现旧 Token 为其它分组时先更新为 `taonier`)并签发 API Key。Router 账号用户名、随机密码、access token(如需)和 API Key 作为一个服务端加密 bundle 保存到 `llm_router_account.credential_ciphertext`,脱敏账号信息和 API Key 核心字段保存到 `llm_router_account`;客户端和普通用户永远不可见 Router Key。管理员 Token 仅存在 api-server 私有配置,不写入数据库或日志;Router 凭据只来源于这条正式账号流程。 +- 2026-09-23 调整:Token/API Key 分组由 `default` 改为与 Router 用户同组的 `taonier`;创建后无条件 PUT、登录恢复同样 PUT,只要 Token 分组不是 `taonier` 就纠正回来(常量见 `external_api_keys.rs` 的 `LLM_ROUTER_TOKEN_GROUP`)。该契约要求 Router 侧 `taonier` 分组已挂载所需模型与套餐,分组缺模型时会失败为 `model_not_found`;存量已签发且分组为 `default` 的 Key 会在该账号下次 provisioning / 登录恢复 / 显式准备 Key 时被纠正。 - 该账号 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。计费读取账号 `used_quota`,每 50000 quota 扣 1 泥点,美元数值乘 10、不乘汇率。首次模型调用前以当前累计额度完整建立免追扣基线,之后调用前后同步;扣钱包、写 `llm_router_consume` 流水与推进已结算额度同事务完成。小数和余额不足未支付部分继续累计,失败或重复同步不推进已结算额度,不使用本地 WAL 或余数队列。完整合同见 `docs/technical/【技术方案】LLM累计额度结算-2026-09-05.md`。 - AGC 状态面收口:Tauri `check_game_creator_llm_config` 只返回账号凭据状态、官方路由锁定状态和运行参数;不序列化 Router 地址、模型、协议名或任何密钥/凭据字段,内部固定路由仅留在运行时配置与服务端代理中。 diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index fef7460d7..35d114123 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -217,7 +217,7 @@ SpacetimeDB procedure: 本次 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 仍沿用创建接口明文只显示一次的正式链路。后续请求链路固定为: +普通 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 固定使用 `taonier` 分组,与 Router 用户同组;创建后无条件 `PUT` 一次完整固定契约、登录恢复时同样 `PUT`,只要已有固定 Token 分组不是 `taonier` 就纠正回来;该契约的前提是 Router 侧 `taonier` 分组已挂载所需模型与套餐,分组缺少模型时请求会失败为 `model_not_found`,排查该错误应先核对 Token 与用户的实际分组,而不是只看本地 `llm_router_account`),签发 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) 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 dcc7b9ab1..3e70d8e5f 100644 --- a/server-rs/crates/api-server/src/external_api_keys.rs +++ b/server-rs/crates/api-server/src/external_api_keys.rs @@ -47,8 +47,9 @@ const EXTERNAL_API_KEY_SCOPES: [&str; 4] = [ /// New API token 标识。它只用于在每个 Router 用户账号内定位同一个 Token, /// 不承载产品展示语义;Router 用户本身通过完整 owner id 的稳定短哈希区分。 const LLM_ROUTER_TOKEN_IDENTIFIER: &str = "agc_auto_generate"; +/// Router 用户(账号)与它名下固定 Token / API Key 都归属同一分组 `taonier`。 const LLM_ROUTER_USER_GROUP: &str = "taonier"; -const LLM_ROUTER_TOKEN_GROUP: &str = "default"; +const LLM_ROUTER_TOKEN_GROUP: &str = "taonier"; const LLM_ROUTER_API_KEY_SCOPES: [&str; 1] = ["llm:responses"]; const LLM_ROUTER_SUBSCRIPTION_PLAN_ID: i64 = 1; const LLM_ROUTER_SUBSCRIPTION_RENEWAL_THRESHOLD_SECONDS: i64 = 24 * 60 * 60; @@ -1194,13 +1195,14 @@ async fn provision_router_account_via_new_api( { // Keep the fixed token contract authoritative even when the Router // returns an incomplete token summary (for example without `group`). - // The user remains in `taonier`; only this API token must be in the - // `default` group with unlimited quota and no expiry. + // The user and its fixed API token both belong to `taonier`, with + // unlimited quota and no expiry. // PUT the complete fixed token contract, not just the group. This - // repairs old tokens that were created with `taonier`, and also - // restores unlimited quota/permanent expiry if an operator changed - // either field. The request is cheap because this path only runs when - // provisioning or recovering an account, not for every LLM call. + // repairs tokens that were created with another group (for example the + // Router's `default`), and also restores unlimited quota/permanent + // expiry if an operator changed either field. The request is cheap + // because this path only runs when provisioning or recovering an + // account, not for every LLM call. ensure_router_token_contract( &client, origin.as_str(), @@ -1258,10 +1260,11 @@ async fn provision_router_account_via_new_api( ) })? }; - // New API may accept the create request while applying the user's - // default group. Normalize the freshly-created token before issuing a - // key; otherwise a successful POST can still produce a token routed - // through `taonier` and later fail with `model_not_found`. + // New API may accept the create request while applying another group + // (for example its own `default`) to the token. Normalize the + // freshly-created token before issuing a key so the Key always runs in + // the same `taonier` group as its Router account; a token stuck in a + // group without the required models fails with `model_not_found`. ensure_router_token_contract( &client, origin.as_str(), @@ -2291,13 +2294,13 @@ mod tests { } #[test] - fn new_api_token_request_is_unlimited_and_bound_to_default_group() { + fn new_api_token_request_is_unlimited_and_bound_to_taonier_group() { let payload = router_token_request(); assert_eq!(payload["name"], LLM_ROUTER_TOKEN_IDENTIFIER); assert_eq!(payload["expired_time"], -1); assert_eq!(payload["unlimited_quota"], true); - assert_eq!(payload["group"], "default"); + assert_eq!(payload["group"], "taonier"); assert!(payload.get("idempotencyKey").is_none()); } @@ -2363,14 +2366,14 @@ mod tests { } #[test] - fn existing_router_token_group_is_normalized_to_default() { + fn existing_router_token_group_is_normalized_to_taonier() { let payload = router_token_update_request(77); assert_eq!(payload["id"], 77); assert_eq!(payload["name"], LLM_ROUTER_TOKEN_IDENTIFIER); assert_eq!(payload["expired_time"], -1); assert_eq!(payload["unlimited_quota"], true); - assert_eq!(payload["group"], "default"); + assert_eq!(payload["group"], "taonier"); } #[test] @@ -2480,14 +2483,14 @@ mod tests { serde_json::from_str(request_body(&requests[8])).expect("token json"); assert_eq!(token_payload["unlimited_quota"], true); assert_eq!(token_payload["expired_time"], -1); - assert_eq!(token_payload["group"], "default"); + assert_eq!(token_payload["group"], "taonier"); let token_update_payload: Value = serde_json::from_str(request_body(&requests[9])).expect("token update json"); assert_eq!(token_update_payload["id"], 77); assert_eq!(token_update_payload["unlimited_quota"], true); assert_eq!(token_update_payload["expired_time"], -1); - assert_eq!(token_update_payload["group"], "default"); + assert_eq!(token_update_payload["group"], "taonier"); assert!( requests[7] @@ -2569,7 +2572,7 @@ mod tests { let token_update_payload: Value = serde_json::from_str(request_body(&requests[5])).expect("token update json"); assert_eq!(token_update_payload["id"], 77); - assert_eq!(token_update_payload["group"], "default"); + assert_eq!(token_update_payload["group"], "taonier"); assert!( !requests .iter() @@ -2583,7 +2586,7 @@ mod tests { } #[tokio::test] - async fn active_router_key_repair_keeps_user_taonier_and_token_default() { + async fn active_router_key_repair_keeps_user_and_token_in_taonier_group() { let owner_user_id = "owner-active-repair"; let username = router_username_for_owner(owner_user_id); let password = @@ -2655,7 +2658,7 @@ mod tests { let token_payload: Value = serde_json::from_str(request_body(&requests[4])).expect("token update json"); - assert_eq!(token_payload["group"], "default"); + assert_eq!(token_payload["group"], "taonier"); assert_eq!(token_payload["unlimited_quota"], true); assert_eq!(token_payload["expired_time"], -1);