Router API Key 分组改为与账号同组的 taonier
Project CI / AI game creator shell Rust crates (push) Successful in 1m23s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m56s
Project CI / Backend tests (push) Successful in 5m40s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m43s
Project CI / Native shell tests (push) Successful in 7m21s
Project CI / Frontend tests (push) Successful in 2m40s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m45s
Project CI / Repository checks (push) Successful in 1m55s
Project CI / AI game creator shell web tests (push) Successful in 1m25s

- 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
This commit is contained in:
kdletters
2026-09-23 20:49:47 +08:00
parent 1e0aeb95b0
commit 985df53a4e
3 changed files with 26 additions and 22 deletions
@@ -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` 的无限额度 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 凭据只来源于这条正式账号流程。
- 每个 Genarrative 用户在认证成功后都必须幂等准备独立 Router 账号:api-server 使用管理员 Token 创建随机密码普通用户,查询用户 ID,设置用户 `group=taonier`,登录、创建或复用固定标识 `agc_auto_generate` 的无限额度 TokenToken/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 地址、模型、协议名或任何密钥/凭据字段,内部固定路由仅留在运行时配置与服务端代理中。
@@ -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 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 仍沿用创建接口明文只显示一次的正式链路。后续请求链路固定为:
普通 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 固定使用 `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 ProxyBearer=平台 access token
@@ -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);