From 700d7940a77e6febfe938a783ac6c1d10bb0c5c9 Mon Sep 17 00:00:00 2001 From: suzmii Date: Wed, 2 Sep 2026 12:06:31 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Router=20=E5=88=86?= =?UTF-8?q?=E7=BB=84=E4=B8=8E=E8=B4=A6=E5=8F=B7=E5=87=AD=E6=8D=AE=E5=A5=91?= =?UTF-8?q?=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 固定 Router 用户为 taonier、Token 为 default 并确保订阅 - 复用本地账号 Key,禁止无管理员令牌绕过订阅校验 - LLM 转发只读取服务端已验证的 Router 凭据并保持请求流式参数 --- .../api-server/src/external_api_keys.rs | 1637 ++++++++++++++--- server-rs/crates/api-server/src/llm/mod.rs | 9 +- 2 files changed, 1384 insertions(+), 262 deletions(-) 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 1a8acf08e..a3c5492c5 100644 --- a/server-rs/crates/api-server/src/external_api_keys.rs +++ b/server-rs/crates/api-server/src/external_api_keys.rs @@ -3,7 +3,11 @@ use axum::{ extract::{Extension, Path, State}, http::StatusCode, }; -use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD}; +use base64::{ + Engine as _, + engine::general_purpose::{STANDARD_NO_PAD, URL_SAFE_NO_PAD}, +}; +use hmac::{Hmac, Mac}; use platform_auth::hash_refresh_session_token; use ring::{ aead, @@ -40,11 +44,25 @@ const EXTERNAL_API_KEY_SCOPES: [&str; 4] = [ "editor:image-generate", "editor:asset", ]; -const LLM_ROUTER_API_KEY_NAME: &str = "陶泥儿 LLM Router 官方账号(服务端)"; +/// New API token 标识。它只用于在每个 Router 用户账号内定位同一个 Token, +/// 不承载产品展示语义;Router 用户本身通过完整 owner id 的稳定短哈希区分。 +const LLM_ROUTER_TOKEN_IDENTIFIER: &str = "agc_auto_generate"; +const LLM_ROUTER_USER_GROUP: &str = "taonier"; +const LLM_ROUTER_TOKEN_GROUP: &str = "default"; 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; 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; +// 临时方案:公共 Router、各部署数据库独立时,所有能操作同一 Router 的部署必须 +// 使用同一派生根。后续迁移到部署密钥管理时先兼容该 v1 派生根;若更换 secret, +// 必须递增版本并完成账号迁移,不能静默造成已有账号密码漂移。 +const LLM_ROUTER_PROVISIONING_SECRET_V1: &[u8] = + b"Jq67uCuRN1fs1l08e9ayImp4Uzc4TAY1IaP0UmHz5qTM7pQnEgwzFYhF_SPmK1Bm"; + +type HmacSha256 = Hmac; static LLM_ROUTER_PROVISION_LOCKS: OnceLock>>>> = OnceLock::new(); @@ -64,6 +82,12 @@ struct RouterAccountLogin { password: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +struct RouterTokenMatch { + id: String, + group: Option, +} + /// 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 @@ -198,7 +222,6 @@ pub async fn ensure_llm_router_api_key( .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) }), @@ -254,6 +277,7 @@ pub(crate) async fn ensure_llm_router_account( 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); @@ -293,7 +317,14 @@ pub(crate) async fn ensure_llm_router_account( } if let Some(account) = persisted.as_ref() { - if account.status == "reconciliation_required" || account.status == "unknown" { + let deterministic_credential_error = account.router_account_id.is_none() + && account + .last_error + .as_deref() + .is_some_and(is_deterministic_router_credential_error); + if (account.status == "reconciliation_required" || account.status == "unknown") + && !deterministic_credential_error + { return Err(format!( "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 账号状态需要对账:{}", account.last_error.as_deref().unwrap_or("未知错误") @@ -310,6 +341,25 @@ pub(crate) async fn ensure_llm_router_account( .transpose() .map_err(|error| format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}{error}"))?; + // A previous build could have persisted credentials rejected by New API's + // username/password length validator. When no remote account id exists, + // those credentials never identified a committed remote user and may be + // safely replaced with the current deterministic values. Unknown remote + // outcomes with a known account id remain reconciliation-blocked above. + let expected_username = router_username_for_owner(owner_user_id); + let expected_password = generate_router_account_password(owner_user_id)?; + let stale_pending_credentials = persisted.as_ref().is_some_and(|account| { + account.router_account_id.is_none() + && bundle.as_ref().is_some_and(|value| { + value.api_key.trim().is_empty() + && (value.username.as_deref() != Some(expected_username.as_str()) + || value.password.as_deref() != Some(expected_password.as_str())) + }) + }); + if stale_pending_credentials { + bundle = None; + } + let key_id = persisted .as_ref() .and_then(|account| account.external_api_key_id.clone()) @@ -340,8 +390,8 @@ pub(crate) async fn ensure_llm_router_account( if bundle.is_none() { generated_new_pending_credentials = true; - let username = router_username_for_owner(owner_user_id); - let password = generate_router_account_password()?; + let username = expected_username.clone(); + let password = expected_password.clone(); let pending = RouterCredentialSecret { version: 1, api_key: String::new(), @@ -424,7 +474,8 @@ pub(crate) async fn ensure_llm_router_account( // A persisted Router account id proves that the remote user was created; // its encrypted username/password may therefore be reused after a key // expiry or an interrupted login/token step. A freshly generated pending - // bundle has no remote account id and must still go through registration. + // bundle is also checked against the public Router first, so a missing local + // database row can recover an already-existing deterministic account. if !generated_new_pending_credentials && reusable_login.is_none() && persisted @@ -551,7 +602,7 @@ pub(crate) async fn ensure_llm_router_account( .create_external_api_key(ExternalApiKeyCreateRecordInput { key_id: key_id.clone(), owner_user_id: owner_user_id.to_string(), - name: LLM_ROUTER_API_KEY_NAME.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, @@ -702,7 +753,7 @@ async fn persist_llm_router_external_key( .create_external_api_key(ExternalApiKeyCreateRecordInput { key_id: key_id.to_string(), owner_user_id: owner_user_id.to_string(), - name: LLM_ROUTER_API_KEY_NAME.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 @@ -722,6 +773,299 @@ async fn persist_llm_router_external_key( .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, +) -> Result<(), String> { + let Some(admin_token) = state + .config + .llm_router_admin_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Err( + "LLM Router 管理员 Token 未配置,无法确保用户分组和 plan_id=1 订阅".to_string(), + ); + }; + + let encryption_secret = state + .config + .effective_llm_router_api_key_encryption_secret() + .ok_or_else(|| "LLM Router 账号密钥加密配置缺失".to_string())?; + let bundle = record + .secret_ciphertext + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(|ciphertext| decrypt_router_credential_secret(ciphertext, encryption_secret.as_str())) + .transpose() + .map_err(|error| format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}{error}"))? + .ok_or_else(|| { + format!( + "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}active Router Key 缺少 Router 账号凭据" + ) + })?; + let username = bundle.username.ok_or_else(|| { + format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}active Router Key 缺少 Router 用户名") + })?; + let password = bundle.password.ok_or_else(|| { + format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}active Router Key 缺少 Router 密码") + })?; + let origin = router_control_origin(state.config.llm_router_base_url.as_str())?; + let client = router_admin_client()?; + let router_user_id = search_router_user_id(&client, origin.as_str(), admin_token, &username) + .await? + .ok_or_else(|| { + format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}无法通过用户名查询 Router 用户 ID") + })?; + if let Some(recorded_router_user_id) = router_user_id_from_record(record) + && recorded_router_user_id != router_user_id + { + return Err(format!( + "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}本地 Router 用户 ID 与远端查询结果不一致" + )); + } + + // The paid group belongs to the Router user, not to the API token. This + // repair is intentionally run at account preparation/login time so an old + // active row created before the group split cannot keep using `taonier` as + // its token group. + update_router_user( + &client, + origin.as_str(), + admin_token, + router_user_id, + username.as_str(), + record.owner_user_id.as_str(), + ) + .await?; + + let login_token = login_router_account( + &client, + origin.as_str(), + username.as_str(), + password.as_str(), + ) + .await + .map_err(|error| { + format!( + "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}active Router 用户无法使用保存的凭据登录:{error}" + ) + })?; + let token = find_router_token_id(&client, origin.as_str(), login_token.as_str()) + .await? + .ok_or_else(|| { + format!( + "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}active Router Key 对应的固定 Token 不存在" + ) + })?; + ensure_router_token_contract( + &client, + origin.as_str(), + login_token.as_str(), + token.id.as_str(), + ) + .await?; + + ensure_router_subscription(&client, origin.as_str(), admin_token, router_user_id).await +} + +fn router_admin_client() -> Result { + reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("创建 LLM Router 管理客户端失败:{error}")) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct RouterSubscriptionSummary { + plan_id: i64, + status: String, + end_time: i64, +} + +fn subscription_requires_renewal( + subscriptions: &[RouterSubscriptionSummary], + now_unix_seconds: i64, +) -> bool { + let latest_active_end = subscriptions + .iter() + .filter(|subscription| { + subscription.plan_id == LLM_ROUTER_SUBSCRIPTION_PLAN_ID + && subscription.status.eq_ignore_ascii_case("active") + }) + .map(|subscription| subscription.end_time) + .max(); + latest_active_end.is_none_or(|end_time| { + end_time + <= now_unix_seconds.saturating_add(LLM_ROUTER_SUBSCRIPTION_RENEWAL_THRESHOLD_SECONDS) + }) +} + +fn extract_router_subscriptions(payload: &Value) -> Vec { + fn search(value: &Value, matches: &mut Vec) { + match value { + Value::Object(object) => { + let subscription = object.get("subscription").unwrap_or(value); + if let Value::Object(subscription) = subscription { + let plan_id = subscription + .get("plan_id") + .or_else(|| subscription.get("planId")) + .and_then(provider_value_to_i64); + let status = subscription + .get("status") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let end_time = subscription + .get("end_time") + .or_else(|| subscription.get("endTime")) + .and_then(provider_value_to_i64); + if let (Some(plan_id), Some(status), Some(end_time)) = + (plan_id, status, end_time) + { + matches.push(RouterSubscriptionSummary { + plan_id, + status: status.to_string(), + end_time, + }); + } + } + for child in object.values() { + search(child, matches); + } + } + Value::Array(values) => { + for child in values { + search(child, matches); + } + } + _ => {} + } + } + + let mut matches = Vec::new(); + search(payload, &mut matches); + matches.sort_by_key(|subscription| { + ( + subscription.plan_id, + subscription.status.to_ascii_lowercase(), + subscription.end_time, + ) + }); + matches.dedup(); + matches +} + +async fn ensure_router_subscription( + client: &reqwest::Client, + origin: &str, + admin_token: &str, + router_user_id: i64, +) -> Result<(), String> { + if router_user_id <= 0 { + return Err(format!( + "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 用户 ID 无效,无法检查订阅" + )); + } + let response = client + .get(format!( + "{origin}/api/subscription/admin/users/{router_user_id}/subscriptions" + )) + .bearer_auth(admin_token) + .send() + .await + .map_err(|error| { + format!( + "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 订阅查询未取得确定响应:{error}" + ) + })?; + let status = response.status(); + let payload = response_json_or_text(response).await?; + if !status.is_success() || provider_payload_failed(&payload) { + let message = format!( + "LLM Router 订阅查询失败:HTTP {}{}", + status.as_u16(), + provider_payload_error_suffix(&payload) + ); + return Err( + if !status.is_success() && router_http_failure_requires_reconciliation(status) { + format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}{message}") + } else { + message + }, + ); + } + + let subscriptions = extract_router_subscriptions(&payload); + let now_unix_seconds = current_utc_micros().div_euclid(1_000_000); + if !subscription_requires_renewal(&subscriptions, now_unix_seconds) { + return Ok(()); + } + + let response = client + .post(format!( + "{origin}/api/subscription/admin/users/{router_user_id}/subscriptions" + )) + .bearer_auth(admin_token) + .json(&json!({"plan_id": LLM_ROUTER_SUBSCRIPTION_PLAN_ID})) + .send() + .await + .map_err(|error| { + format!( + "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 订阅创建未取得确定响应:{error}" + ) + })?; + let status = response.status(); + let payload = response_json_or_text(response).await?; + if !status.is_success() || provider_payload_failed(&payload) { + let message = format!( + "LLM Router 订阅创建失败:HTTP {}{}", + status.as_u16(), + provider_payload_error_suffix(&payload) + ); + return Err( + if !status.is_success() && router_http_failure_requires_reconciliation(status) { + format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}{message}") + } else { + message + }, + ); + } + tracing::info!( + router_user_id, + plan_id = LLM_ROUTER_SUBSCRIPTION_PLAN_ID, + "LLM Router 订阅已开通或续期" + ); + Ok(()) +} + +fn router_user_id_from_record(record: &ExternalApiKeyRecord) -> Option { + record + .provider_account_id + .as_deref() + .and_then(|value| value.trim().parse::().ok()) + .or_else(|| { + record + .provider_account_json + .as_deref() + .and_then(|value| serde_json::from_str::(value).ok()) + .and_then(|value| { + value + .get("routerUserId") + .or_else(|| value.get("router_user_id")) + .or_else(|| value.get("userId")) + .or_else(|| value.get("user_id")) + .and_then(provider_value_to_i64) + }) + }) +} + /// Runs the idempotent Router-account preparation after every successful /// authentication. Callers must complete this step before issuing a session /// token, so a successfully authenticated user always has a persisted @@ -745,6 +1089,52 @@ pub(crate) async fn ensure_llm_router_account_after_auth_success( .map(|_| ()) } +/// Reads an already-provisioned Router credential without contacting the +/// Router control plane. Account registration, user-group repair and +/// subscription renewal belong to the authentication/key-preparation anchor; +/// the hot LLM forwarding path only needs this validated local row. +pub(crate) async fn read_active_llm_router_credentials( + state: &AppState, + owner_user_id: &str, +) -> Result, String> { + let owner_user_id = owner_user_id.trim(); + if owner_user_id.is_empty() { + return Err("LLM Router 账号缺少 owner_user_id".to_string()); + } + ensure_llm_router_target_allowed(state)?; + let encryption_secret = state + .config + .effective_llm_router_api_key_encryption_secret() + .ok_or_else(|| "LLM Router 账号密钥加密配置缺失".to_string())?; + let records = state + .spacetime_client() + .list_external_api_keys(owner_user_id.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(), + )? + else { + return Ok(None); + }; + let ciphertext = record.secret_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())?; + Ok(Some(( + state + .config + .llm_router_base_url + .trim_end_matches('/') + .to_string(), + api_key, + record.key_id.clone(), + ))) +} + /// Validates the persisted LLM Router state before provisioning another Router key. /// /// The table intentionally has no composite unique constraint because legacy @@ -804,10 +1194,9 @@ fn select_existing_llm_router_account<'a>( /// 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. A missing password is not enough to infer or -/// regenerate credentials, so that row is ignored and provisioning remains -/// blocked until a fresh account can be created through the configured admin -/// control plane. +/// 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, @@ -914,9 +1303,9 @@ async fn provision_router_account( reusable_login: Option<&RouterAccountLogin>, pending_login: Option<&RouterAccountLogin>, ) -> Result { - // 走 Router New API 的正式账号链路:管理员创建随机密码用户(或复用历史账号 - // 密码)、登录、创建无限额度 token、签发 API Key。网络/5xx/格式不确定等情况 - // 保持 reconciliation,避免重复注册。 + // 走 Router New API 的正式账号链路:先按稳定用户名/密码恢复已有用户,确认不存在 + // 后才注册,再登录、复用或创建无限额度 token、签发 API Key。网络/5xx/格式不确定 + // 等情况保持 reconciliation,避免重复注册。 provision_router_account_via_new_api(state, owner_user_id, reusable_login, pending_login).await } @@ -933,207 +1322,198 @@ async fn provision_router_account_via_new_api( .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|error| format!("创建 LLM Router 账号客户端失败:{error}"))?; - let (username, password) = if let Some(reusable_login) = reusable_login { - ( - reusable_login.username.clone(), - reusable_login.password.clone(), - ) - } else { - let admin_token = state - .config - .llm_router_admin_token - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "LLM Router 管理员 Token 未配置".to_string())?; - let (username, password) = pending_login - .map(|login| (login.username.clone(), login.password.clone())) - .unwrap_or(( - router_username_for_owner(owner_user_id), - generate_router_account_password()?, - )); - let register_response = client - .post(format!("{origin}/api/user/")) - .bearer_auth(admin_token) - .json(&json!({ - "username": username, - "password": password, - "display_name": router_user_display_name(username.as_str()), - "role": 1, - })) - .send() - .await - .map_err(|error| { - format!( - "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 注册未取得确定响应:{error}" - ) - })?; - let register_status = register_response.status(); - // New API's user-create endpoint is success-only; deployments may - // return an empty body or a plain-text success marker instead of JSON. - let register_payload = response_json_or_text(register_response).await?; - if !register_status.is_success() { - if router_http_failure_requires_reconciliation(register_status) { - return Err(format!( - "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}LLM Router 注册返回 HTTP {},结果可能未知{}", - register_status.as_u16(), - provider_payload_error_suffix(®ister_payload) - )); - } - return Err(format!( - "LLM Router 注册失败:HTTP {}{}", - register_status.as_u16(), - provider_payload_error_suffix(®ister_payload) - )); - } - if provider_payload_failed(®ister_payload) { - return Err(format!( - "LLM Router 注册失败:HTTP {}{}", - register_status.as_u16(), - provider_payload_error_suffix(®ister_payload) - )); - } - - let mut search_url = reqwest::Url::parse(format!("{origin}/api/user/search").as_str()) - .map_err(|error| format!("Router 用户查询地址无效:{error}"))?; - search_url - .query_pairs_mut() - .append_pair("keyword", username.as_str()); - let search_response = client - .get(search_url) - .bearer_auth(admin_token) - .send() - .await - .map_err(|error| { - format!( - "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 用户查询未取得确定响应:{error}" - ) - })?; - let search_status = search_response.status(); - let search_payload = response_json_or_empty(search_response).await?; - if !search_status.is_success() { - return Err(format!( - "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 用户查询返回 HTTP {}{}", - search_status.as_u16(), - provider_payload_error_suffix(&search_payload) - )); - } - let router_user_id = extract_router_user_id(&search_payload, username.as_str()) - .ok_or_else(|| { - format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 用户查询响应缺少用户 ID") - })?; - let update_response = client - .put(format!("{origin}/api/user/")) - .bearer_auth(admin_token) - .json(&router_user_update_request(router_user_id, username.as_str())) - .send() - .await - .map_err(|error| { - format!( - "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 用户分组设置未取得确定响应:{error}" - ) - })?; - let update_status = update_response.status(); - // User updates have the same success-only response contract as create. - let update_payload = response_json_or_text(update_response).await?; - if !update_status.is_success() || provider_payload_failed(&update_payload) { - return Err(format!( - "LLM Router 用户分组设置失败:HTTP {}{}", - update_status.as_u16(), - provider_payload_error_suffix(&update_payload) - )); - } - (username, password) - }; - - let login_response = client - .post(format!("{origin}/api/user/login")) - .json(&json!({"username": username, "password": password})) - .send() - .await - .map_err(|error| { - format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 登录未取得确定响应:{error}") - })?; - let login_status = login_response.status(); - let login_payload = response_json_or_text(login_response).await?; - if !login_status.is_success() { - if router_http_failure_requires_reconciliation(login_status) { - return Err(format!( - "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}LLM Router 登录返回 HTTP {},结果可能未知{}", - login_status.as_u16(), - provider_payload_error_suffix(&login_payload) - )); - } - return Err(format!( - "LLM Router 登录失败:HTTP {}{}", - login_status.as_u16(), - provider_payload_error_suffix(&login_payload) + let (username, password) = reusable_login + .map(|login| (login.username.clone(), login.password.clone())) + .or_else(|| pending_login.map(|login| (login.username.clone(), login.password.clone()))) + .unwrap_or(( + router_username_for_owner(owner_user_id), + generate_router_account_password(owner_user_id)?, )); - } - if provider_payload_failed(&login_payload) { - return Err(format!( - "LLM Router 登录失败:HTTP {}{}", - login_status.as_u16(), - provider_payload_error_suffix(&login_payload) - )); - } - let login_token = extract_provider_string( - &login_payload, - &["token", "accessToken", "access_token", "userToken"], - ) - .ok_or_else(|| { - format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 登录响应缺少 access token") + let admin_token = state + .config + .llm_router_admin_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let admin_token = admin_token.ok_or_else(|| { + "LLM Router 管理员 Token 未配置,无法完成用户分组、订阅和 API Key 签发".to_string() })?; - let token_response = client - .post(format!("{origin}/api/token/")) - .bearer_auth(login_token.as_str()) - .json(&router_token_request()) - .send() + let (login_token, router_user_id) = if reusable_login.is_some() { + let login_token = login_router_account( + &client, + origin.as_str(), + username.as_str(), + password.as_str(), + ) .await .map_err(|error| { format!( - "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router token 创建未取得确定响应:{error}" + "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}已存在的 Router 用户无法使用保存的凭据登录:{error}" ) })?; - let token_status = token_response.status(); - let token_payload = response_json_or_text(token_response).await?; - if !token_status.is_success() { - if router_http_failure_requires_reconciliation(token_status) { + let router_user_id = search_router_user_id( + &client, + origin.as_str(), + admin_token, + username.as_str(), + ) + .await? + .ok_or_else(|| { + format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}已登录的 Router 用户无法查询用户 ID") + })?; + (login_token, router_user_id) + } else { + let existing_router_user_id = + search_router_user_id(&client, origin.as_str(), admin_token, username.as_str()).await?; + + if let Some(remote_router_user_id) = existing_router_user_id { + // Existing deterministic accounts may have been created before the + // group-update step completed (for example after a process crash). + // Reapply the complete user update so the account is always in the + // paid `taonier` group. + update_router_user( + &client, + origin.as_str(), + admin_token, + remote_router_user_id, + username.as_str(), + owner_user_id, + ) + .await?; + let login_token = login_router_account(&client, origin.as_str(), username.as_str(), password.as_str()) + .await + .map_err(|error| { + format!( + "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 用户已存在但稳定密码无法登录:{error}" + ) + })?; + (login_token, remote_router_user_id) + } else { + let register_error = register_router_user( + &client, + origin.as_str(), + admin_token, + username.as_str(), + password.as_str(), + owner_user_id, + ) + .await + .err(); + + let remote_router_user_id = + search_router_user_id(&client, origin.as_str(), admin_token, username.as_str()) + .await? + .ok_or_else(|| { + register_error.unwrap_or_else(|| { + format!( + "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 注册成功后无法查询到用户 ID" + ) + }) + })?; + update_router_user( + &client, + origin.as_str(), + admin_token, + remote_router_user_id, + username.as_str(), + owner_user_id, + ) + .await?; + let login_token = login_router_account( + &client, + origin.as_str(), + username.as_str(), + password.as_str(), + ) + .await?; + (login_token, remote_router_user_id) + } + }; + + ensure_router_subscription(&client, origin.as_str(), admin_token, router_user_id).await?; + + let (token_id, token_payload) = if let Some(token) = + find_router_token_id(&client, origin.as_str(), login_token.as_str()).await? + { + // 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. + // 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. + ensure_router_token_contract( + &client, + origin.as_str(), + login_token.as_str(), + token.id.as_str(), + ) + .await?; + (token.id, json!({})) + } else { + let token_response = client + .post(format!("{origin}/api/token/")) + .bearer_auth(login_token.as_str()) + .json(&router_token_request()) + .send() + .await + .map_err(|error| { + format!( + "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router token 创建未取得确定响应:{error}" + ) + })?; + let token_status = token_response.status(); + let token_payload = response_json_or_text(token_response).await?; + if !token_status.is_success() { + if router_http_failure_requires_reconciliation(token_status) { + return Err(format!( + "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}LLM Router token 创建返回 HTTP {},结果可能未知{}", + token_status.as_u16(), + provider_payload_error_suffix(&token_payload) + )); + } return Err(format!( - "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}LLM Router token 创建返回 HTTP {},结果可能未知{}", + "LLM Router token 创建失败:HTTP {}{}", token_status.as_u16(), provider_payload_error_suffix(&token_payload) )); } - return Err(format!( - "LLM Router token 创建失败:HTTP {}{}", - token_status.as_u16(), - provider_payload_error_suffix(&token_payload) - )); - } - if provider_payload_failed(&token_payload) { - return Err(format!( - "LLM Router token 创建失败:HTTP {}{}", - token_status.as_u16(), - provider_payload_error_suffix(&token_payload) - )); - } - // New API's current AddToken response is success-only and does not include - // the generated token id. Resolve it through the authenticated token search - // endpoint before requesting the one-time full key. - let token_id = if let Some(token_id) = - extract_provider_string(&token_payload, &["id", "tokenId", "token_id"]) - { - token_id - } else { - find_router_token_id(&client, origin.as_str(), login_token.as_str()) - .await? - .ok_or_else(|| { - format!( - "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router token 查询响应缺少 token id" - ) - })? + if provider_payload_failed(&token_payload) { + return Err(format!( + "LLM Router token 创建失败:HTTP {}{}", + token_status.as_u16(), + provider_payload_error_suffix(&token_payload) + )); + } + let token_id = if let Some(token_id) = + extract_provider_string(&token_payload, &["id", "tokenId", "token_id"]) + { + token_id + } else { + find_router_token_id(&client, origin.as_str(), login_token.as_str()) + .await? + .map(|token| token.id) + .ok_or_else(|| { + format!( + "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router token 查询响应缺少 token id" + ) + })? + }; + // 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`. + ensure_router_token_contract( + &client, + origin.as_str(), + login_token.as_str(), + token_id.as_str(), + ) + .await?; + (token_id, token_payload) }; let key_response = client @@ -1184,6 +1564,7 @@ async fn provision_router_account_via_new_api( json!({ "version": 1, "accountId": account_id, + "routerUserId": router_user_id, "username": username, "routeOrigin": state.config.llm_router_base_url, "model": state.config.llm_router_model, @@ -1198,42 +1579,221 @@ async fn provision_router_account_via_new_api( }) } -fn router_token_request() -> Value { - json!({ - "name": LLM_ROUTER_API_KEY_NAME, - "expired_time": -1, - "unlimited_quota": true, - "group": "taonier", +async fn search_router_user_id( + client: &reqwest::Client, + origin: &str, + admin_token: &str, + username: &str, +) -> Result, String> { + let mut search_url = reqwest::Url::parse(format!("{origin}/api/user/search").as_str()) + .map_err(|error| format!("Router 用户查询地址无效:{error}"))?; + search_url + .query_pairs_mut() + .append_pair("keyword", username); + let response = client + .get(search_url) + .bearer_auth(admin_token) + .send() + .await + .map_err(|error| { + format!( + "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 用户查询未取得确定响应:{error}" + ) + })?; + let status = response.status(); + let payload = response_json_or_empty(response).await?; + if !status.is_success() { + return Err(format!( + "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 用户查询返回 HTTP {}{}", + status.as_u16(), + provider_payload_error_suffix(&payload) + )); + } + if provider_payload_failed(&payload) { + return Err(format!( + "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 用户查询失败{}", + provider_payload_error_suffix(&payload) + )); + } + Ok(extract_router_user_id(&payload, username)) +} + +async fn register_router_user( + client: &reqwest::Client, + origin: &str, + admin_token: &str, + username: &str, + password: &str, + owner_user_id: &str, +) -> Result<(), String> { + let response = client + .post(format!("{origin}/api/user/")) + .bearer_auth(admin_token) + .json(&json!({ + "username": username, + "password": password, + "display_name": router_user_display_name(username), + "remark": owner_user_id, + "role": 1, + })) + .send() + .await + .map_err(|error| { + format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 注册未取得确定响应:{error}") + })?; + let status = response.status(); + let payload = response_json_or_text(response).await?; + if !status.is_success() || provider_payload_failed(&payload) { + let message = format!( + "LLM Router 注册失败:HTTP {}{}", + status.as_u16(), + provider_payload_error_suffix(&payload) + ); + // A successful HTTP response with `success=false` is a deterministic + // business/validation failure. Only transport, redirect, and server + // side failures require reconciliation because the remote write may + // have committed before the response was lost. + return Err( + if !status.is_success() && router_http_failure_requires_reconciliation(status) { + format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}{message}") + } else { + message + }, + ); + } + Ok(()) +} + +async fn update_router_user( + client: &reqwest::Client, + origin: &str, + admin_token: &str, + router_user_id: i64, + username: &str, + owner_user_id: &str, +) -> Result<(), String> { + let response = client + .put(format!("{origin}/api/user/")) + .bearer_auth(admin_token) + .json(&router_user_update_request( + router_user_id, + username, + owner_user_id, + )) + .send() + .await + .map_err(|error| { + format!( + "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 用户分组设置未取得确定响应:{error}" + ) + })?; + let status = response.status(); + let payload = response_json_or_text(response).await?; + if !status.is_success() || provider_payload_failed(&payload) { + let message = format!( + "LLM Router 用户分组设置失败:HTTP {}{}", + status.as_u16(), + provider_payload_error_suffix(&payload) + ); + return Err(if !status.is_client_error() { + format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}{message}") + } else { + message + }); + } + Ok(()) +} + +async fn login_router_account( + client: &reqwest::Client, + origin: &str, + username: &str, + password: &str, +) -> Result { + let response = client + .post(format!("{origin}/api/user/login")) + .json(&json!({"username": username, "password": password})) + .send() + .await + .map_err(|error| { + format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 登录未取得确定响应:{error}") + })?; + let status = response.status(); + let payload = response_json_or_text(response).await?; + if !status.is_success() { + if router_http_failure_requires_reconciliation(status) { + return Err(format!( + "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}LLM Router 登录返回 HTTP {},结果可能未知{}", + status.as_u16(), + provider_payload_error_suffix(&payload) + )); + } + return Err(format!( + "LLM Router 登录失败:HTTP {}{}", + status.as_u16(), + provider_payload_error_suffix(&payload) + )); + } + if provider_payload_failed(&payload) { + return Err(format!( + "LLM Router 登录失败:HTTP {}{}", + status.as_u16(), + provider_payload_error_suffix(&payload) + )); + } + extract_provider_string( + &payload, + &["token", "accessToken", "access_token", "userToken"], + ) + .ok_or_else(|| { + format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 登录响应缺少 access token") }) } -fn router_user_update_request(user_id: i64, username: &str) -> Value { +fn router_token_request() -> Value { + json!({ + "name": LLM_ROUTER_TOKEN_IDENTIFIER, + "expired_time": -1, + "unlimited_quota": true, + "group": LLM_ROUTER_TOKEN_GROUP, + }) +} + +fn router_token_update_request(token_id: i64) -> Value { + json!({ + "id": token_id, + "name": LLM_ROUTER_TOKEN_IDENTIFIER, + "expired_time": -1, + "unlimited_quota": true, + "group": LLM_ROUTER_TOKEN_GROUP, + }) +} + +fn router_user_update_request(user_id: i64, username: &str, owner_user_id: &str) -> Value { json!({ "id": user_id, "username": username, "display_name": router_user_display_name(username), - "group": "taonier", + "remark": owner_user_id, + "group": LLM_ROUTER_USER_GROUP, "role": 1, }) } fn router_user_display_name(username: &str) -> String { - format!( - "陶泥儿用户 {}", - username.strip_prefix("router_").unwrap_or(username) - ) + username.to_string() } async fn find_router_token_id( client: &reqwest::Client, origin: &str, login_token: &str, -) -> Result, String> { +) -> Result, String> { let mut search_url = reqwest::Url::parse(format!("{origin}/api/token/search").as_str()) .map_err(|error| format!("Router token 查询地址无效:{error}"))?; search_url .query_pairs_mut() - .append_pair("keyword", LLM_ROUTER_API_KEY_NAME); + .append_pair("keyword", LLM_ROUTER_TOKEN_IDENTIFIER); let response = client .get(search_url) .bearer_auth(login_token) @@ -1259,7 +1819,50 @@ async fn find_router_token_id( provider_payload_error_suffix(&payload) )); } - Ok(extract_router_token_id(&payload, LLM_ROUTER_API_KEY_NAME)) + let token = + extract_router_token_info(&payload, LLM_ROUTER_TOKEN_IDENTIFIER).map_err(|error| { + format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router token 标识不唯一:{error}") + })?; + Ok(token) +} + +async fn ensure_router_token_contract( + client: &reqwest::Client, + origin: &str, + login_token: &str, + token_id: &str, +) -> Result<(), String> { + let numeric_token_id = token_id.parse::().map_err(|_| { + format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router token id 格式无效,无法修正分组") + })?; + let response = client + .put(format!("{origin}/api/token/")) + .bearer_auth(login_token) + .json(&router_token_update_request(numeric_token_id)) + .send() + .await + .map_err(|error| { + format!( + "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router token 分组修正未取得确定响应:{error}" + ) + })?; + let status = response.status(); + let payload = response_json_or_text(response).await?; + if !status.is_success() || provider_payload_failed(&payload) { + let message = format!( + "LLM Router token 分组修正失败:HTTP {}{}", + status.as_u16(), + provider_payload_error_suffix(&payload) + ); + return Err( + if !status.is_success() && router_http_failure_requires_reconciliation(status) { + format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}{message}") + } else { + message + }, + ); + } + Ok(()) } fn router_control_origin(base_url: &str) -> Result { @@ -1311,9 +1914,22 @@ fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> { return Ok(()); } + if base_url == OFFICIAL_LLM_ROUTER_BASE_URL { + if state.config.llm_router_model.trim() != OFFICIAL_LLM_ROUTER_MODEL { + return Err("LLM Router 必须使用官方固定模型".to_string()); + } + if url.scheme() != "https" { + return Err("官方 LLM Router 只允许 HTTPS 地址".to_string()); + } + // Shared Router is intentionally usable by development deployments too: + // each deployment may have an independent database, while the stable + // owner-derived agc_user_ account and agc_auto_generate Token are shared. + return Ok(()); + } + if !is_loopback { return Err(format!( - "当前环境 {} 禁止连接非 loopback 的 LLM Router;请使用本地测试 Router 或显式切换 production", + "当前环境 {} 只允许官方固定 LLM Router 或 loopback Router;请检查路由配置", state.config.environment )); } @@ -1324,16 +1940,36 @@ fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> { } fn router_username_for_owner(owner_user_id: &str) -> String { - let digest = Sha256::digest(owner_user_id.trim().as_bytes()); - format!("router_{}", &hex::encode(digest)[..24]) + // New API 的 User.Username 校验上限是 20 个字符。保留可读前缀后只 + // 能放 11 个字符;使用完整 owner id 做 SHA-256,再编码成 8 字节的 + // URL-safe 短码(11 个 ASCII 字符),得到稳定且看起来随机的用户名。 + let digest = Sha256::digest( + format!("genarrative-agc-router-user:v1\n{}", owner_user_id.trim()).as_bytes(), + ); + let short_code = URL_SAFE_NO_PAD.encode(&digest[..8]); + debug_assert_eq!(short_code.len(), 11); + format!("agc_user_{short_code}") } -fn generate_router_account_password() -> Result { - let mut bytes = [0_u8; 32]; - SystemRandom::new() - .fill(&mut bytes) - .map_err(|_| "LLM Router 账号密码随机数生成失败".to_string())?; - Ok(format!("Router-{}", STANDARD_NO_PAD.encode(bytes))) +fn generate_router_account_password(owner_user_id: &str) -> Result { + let owner_user_id = owner_user_id.trim(); + if owner_user_id.is_empty() { + return Err("LLM Router 账号密码派生参数缺失".to_string()); + } + + let mut signer = HmacSha256::new_from_slice(LLM_ROUTER_PROVISIONING_SECRET_V1) + .map_err(|_| "LLM Router 账号密码派生密钥无效".to_string())?; + signer.update( + format!( + "genarrative-llm-router-password:v{}\n{}", + LLM_ROUTER_PROVISIONING_CREDENTIAL_VERSION, owner_user_id + ) + .as_bytes(), + ); + let digest = signer.finalize().into_bytes(); + // New API 的 User.Password 校验范围是 8..=20。使用完整 owner id + // 派生 10 字节 HMAC 并编码为 20 位 hex;不再附加人为的 Router 前缀。 + Ok(hex::encode(&digest[..10])) } async fn response_json_or_empty(response: reqwest::Response) -> Result { @@ -1400,25 +2036,79 @@ fn provider_value_to_i64(value: &Value) -> Option { .or_else(|| value.as_str().and_then(|value| value.trim().parse().ok())) } -fn extract_router_token_id(payload: &Value, token_name: &str) -> Option { - fn search(value: &Value, token_name: &str) -> Option { +fn extract_router_token_info( + payload: &Value, + token_name: &str, +) -> Result, String> { + fn search(value: &Value, token_name: &str, matches: &mut Vec) { match value { Value::Object(object) => { let object_name = object.get("name").and_then(Value::as_str).map(str::trim); if object_name == Some(token_name) { - return object + if let Some(token_id) = object .get("id") .or_else(|| object.get("tokenId")) .or_else(|| object.get("token_id")) - .and_then(provider_value_to_string); + .and_then(provider_value_to_string) + { + matches.push(RouterTokenMatch { + id: token_id, + group: object + .get("group") + .or_else(|| object.get("groupName")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|group| !group.is_empty()) + .map(str::to_string), + }); + } + return; + } + for value in object.values() { + search(value, token_name, matches); } - object.values().find_map(|value| search(value, token_name)) } - Value::Array(values) => values.iter().find_map(|value| search(value, token_name)), - _ => None, + Value::Array(values) => { + for value in values { + search(value, token_name, matches); + } + } + _ => {} } } - search(payload, token_name) + let mut matches = Vec::new(); + search(payload, token_name, &mut matches); + matches.sort_by(|left, right| left.id.cmp(&right.id)); + matches.dedup(); + let mut ids = matches + .iter() + .map(|token| token.id.as_str()) + .collect::>(); + ids.sort_unstable(); + ids.dedup(); + match ids.as_slice() { + [] => Ok(None), + [token_id] => { + let groups = matches + .iter() + .filter(|token| token.id == *token_id) + .filter_map(|token| token.group.as_deref()) + .collect::>(); + if groups.windows(2).any(|window| window[0] != window[1]) { + return Err(format!( + "固定 Token 标识 {token_name} 对应同一 id 但分组不一致" + )); + } + Ok(matches.iter().find(|token| token.id == *token_id).cloned()) + } + _ => Err(format!( + "固定 Token 标识 {token_name} 对应多个不同 token id" + )), + } +} + +fn extract_router_token_id(payload: &Value, token_name: &str) -> Result, String> { + extract_router_token_info(payload, token_name).map(|token| token.map(|token| token.id)) } fn provider_payload_data(value: &Value) -> &Value { @@ -1434,6 +2124,15 @@ fn router_http_failure_requires_reconciliation(status: reqwest::StatusCode) -> b !status.is_client_error() } +fn is_deterministic_router_credential_error(message: &str) -> bool { + let normalized = message.to_ascii_lowercase(); + normalized.contains("field validation") + && (normalized.contains("username") + || normalized.contains("password") + || normalized.contains("display_name")) + && (normalized.contains("max") || normalized.contains("min")) +} + fn provider_payload_failed(value: &Value) -> bool { value .get("success") @@ -1833,7 +2532,7 @@ mod tests { ExternalApiKeyRecord { key_id: key_id.to_string(), owner_user_id: "user-1".to_string(), - name: LLM_ROUTER_API_KEY_NAME.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(), @@ -1893,22 +2592,121 @@ mod tests { } #[test] - fn new_api_token_request_is_unlimited_and_bound_to_taonier_group() { + fn new_api_token_request_is_unlimited_and_bound_to_default_group() { let payload = router_token_request(); - assert_eq!(payload["name"], LLM_ROUTER_API_KEY_NAME); + assert_eq!(payload["name"], LLM_ROUTER_TOKEN_IDENTIFIER); assert_eq!(payload["expired_time"], -1); assert_eq!(payload["unlimited_quota"], true); - assert_eq!(payload["group"], "taonier"); + assert_eq!(payload["group"], "default"); assert!(payload.get("idempotencyKey").is_none()); } + #[test] + fn router_subscription_renews_when_missing_expired_or_within_one_day() { + let now = 1_700_000_000; + assert!(subscription_requires_renewal(&[], now)); + assert!(subscription_requires_renewal( + &[RouterSubscriptionSummary { + plan_id: LLM_ROUTER_SUBSCRIPTION_PLAN_ID, + status: "expired".to_string(), + end_time: now + 10 * 24 * 60 * 60, + }], + now, + )); + assert!(subscription_requires_renewal( + &[RouterSubscriptionSummary { + plan_id: LLM_ROUTER_SUBSCRIPTION_PLAN_ID, + status: "active".to_string(), + end_time: now + 24 * 60 * 60, + }], + now, + )); + assert!(!subscription_requires_renewal( + &[RouterSubscriptionSummary { + plan_id: LLM_ROUTER_SUBSCRIPTION_PLAN_ID, + status: "active".to_string(), + end_time: now + 24 * 60 * 60 + 1, + }], + now, + )); + assert!(subscription_requires_renewal( + &[RouterSubscriptionSummary { + plan_id: 99, + status: "active".to_string(), + end_time: now + 365 * 24 * 60 * 60, + }], + now, + )); + } + + #[test] + fn router_subscription_payload_reads_new_api_summary_envelope() { + let payload = json!({ + "success": true, + "data": [ + {"subscription": { + "plan_id": 1, + "status": "active", + "start_time": 1700000000, + "end_time": 1800000000 + }} + ] + }); + assert_eq!( + extract_router_subscriptions(&payload), + vec![RouterSubscriptionSummary { + plan_id: 1, + status: "active".to_string(), + end_time: 1800000000, + }] + ); + } + + #[test] + fn existing_router_token_group_is_normalized_to_default() { + 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"); + } + + #[test] + fn duplicate_fixed_router_tokens_require_reconciliation() { + let payload = json!({ + "data": [ + {"id": 77, "name": LLM_ROUTER_TOKEN_IDENTIFIER}, + {"id": 88, "name": LLM_ROUTER_TOKEN_IDENTIFIER} + ] + }); + + let error = extract_router_token_id(&payload, LLM_ROUTER_TOKEN_IDENTIFIER) + .expect_err("multiple fixed-name tokens must be rejected"); + assert!(error.contains("多个不同 token id")); + + let duplicate_same_id = json!({ + "data": [ + {"id": 77, "name": LLM_ROUTER_TOKEN_IDENTIFIER}, + {"id": 77, "name": LLM_ROUTER_TOKEN_IDENTIFIER} + ] + }); + assert_eq!( + extract_router_token_id(&duplicate_same_id, LLM_ROUTER_TOKEN_IDENTIFIER) + .expect("same token repeated in an envelope is not ambiguous"), + Some("77".to_string()) + ); + } + #[test] fn new_api_user_update_uses_numeric_id_and_fixed_group() { - let payload = router_user_update_request(42, "router_abcd"); + let payload = router_user_update_request(42, "router_abcd", "user_full-id"); assert_eq!(payload["id"], 42); assert_eq!(payload["username"], "router_abcd"); + assert_eq!(payload["remark"], "user_full-id"); assert_eq!(payload["group"], "taonier"); assert_eq!(payload["role"], 1); assert!( @@ -1921,6 +2719,8 @@ mod tests { #[tokio::test] async fn new_api_provisioning_uses_admin_user_flow_and_preserves_opaque_key() { let username = router_username_for_owner("owner-1"); + let password = + generate_router_account_password("owner-1").expect("Router password should derive"); let (base_url, captured) = spawn_new_api_provisioning_mock(username.clone()); let state = AppState::new(AppConfig { llm_router_base_url: base_url, @@ -1931,7 +2731,7 @@ mod tests { let pending_login = RouterAccountLogin { username: username.clone(), - password: "pending-password-0123456789".to_string(), + password: password.clone(), }; let provisioned = provision_router_account_via_new_api(&state, "owner-1", None, Some(&pending_login)) @@ -1947,14 +2747,22 @@ mod tests { assert!(provisioned.router_password.is_some()); let requests = captured.lock().expect("captured requests lock").clone(); - assert_eq!(requests.len(), 7); - assert!(requests[0].starts_with("POST /api/user/ HTTP/1.1")); - assert!(requests[1].starts_with("GET /api/user/search?keyword=")); - assert!(requests[2].starts_with("PUT /api/user/ HTTP/1.1")); - assert!(requests[3].starts_with("POST /api/user/login HTTP/1.1")); - assert!(requests[4].starts_with("POST /api/token/ HTTP/1.1")); - assert!(requests[5].starts_with("GET /api/token/search?keyword=")); - assert!(requests[6].starts_with("POST /api/token/77/key HTTP/1.1")); + assert_eq!(requests.len(), 11); + assert!(requests[0].starts_with("GET /api/user/search?keyword=")); + assert!(requests[1].starts_with("POST /api/user/ HTTP/1.1")); + assert!(requests[2].starts_with("GET /api/user/search?keyword=")); + assert!(requests[3].starts_with("PUT /api/user/ HTTP/1.1")); + assert!(requests[4].starts_with("POST /api/user/login HTTP/1.1")); + assert!( + requests[5].starts_with("GET /api/subscription/admin/users/42/subscriptions HTTP/1.1") + ); + assert!( + requests[6].starts_with("POST /api/subscription/admin/users/42/subscriptions HTTP/1.1") + ); + assert!(requests[7].starts_with("GET /api/token/search?keyword=")); + assert!(requests[8].starts_with("POST /api/token/ HTTP/1.1")); + assert!(requests[9].starts_with("PUT /api/token/ HTTP/1.1")); + assert!(requests[10].starts_with("POST /api/token/77/key HTTP/1.1")); assert!( requests[0] @@ -1972,44 +2780,255 @@ mod tests { .any(|line| line.eq_ignore_ascii_case("authorization: Bearer admin-secret")) ); - let create_body = request_body(&requests[0]); + let create_body = request_body(&requests[1]); let create_payload: Value = serde_json::from_str(create_body).expect("create json"); assert_eq!(create_payload["username"], username); - assert_eq!(create_payload["password"], "pending-password-0123456789"); + assert_eq!(create_payload["password"], password); assert_eq!(create_payload["role"], 1); + assert!( + create_payload["remark"] + .as_str() + .is_some_and(|value| value == "owner-1") + ); assert!( create_payload["password"] .as_str() - .is_some_and(|value| value.len() > 20) + .is_some_and(|value| value.len() <= 20) ); let update_payload: Value = - serde_json::from_str(request_body(&requests[2])).expect("update json"); + serde_json::from_str(request_body(&requests[3])).expect("update json"); assert_eq!(update_payload["id"], 42); + assert_eq!(update_payload["remark"], "owner-1"); assert_eq!(update_payload["group"], "taonier"); assert_eq!(update_payload["role"], 1); let token_payload: Value = - serde_json::from_str(request_body(&requests[4])).expect("token json"); + 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"], "taonier"); + assert_eq!(token_payload["group"], "default"); + + 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!( - requests[4] + requests[7] + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer router-login-token")) + ); + assert!( + requests[8] + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer router-login-token")) + ); + assert!( + requests[10] .lines() .any(|line| line.eq_ignore_ascii_case("authorization: Bearer router-login-token")) ); assert!( requests[5] .lines() - .any(|line| line.eq_ignore_ascii_case("authorization: Bearer router-login-token")) + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer admin-secret")) ); assert!( requests[6] .lines() - .any(|line| line.eq_ignore_ascii_case("authorization: Bearer router-login-token")) + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer admin-secret")) ); + let subscription_payload: Value = + serde_json::from_str(request_body(&requests[6])).expect("subscription json"); + assert_eq!( + subscription_payload["plan_id"], + LLM_ROUTER_SUBSCRIPTION_PLAN_ID + ); + } + + #[tokio::test] + async fn missing_local_account_recovers_existing_router_user_and_reuses_token() { + let username = router_username_for_owner("owner-recover"); + let (base_url, captured) = spawn_existing_router_account_mock(username.clone()); + let state = AppState::new(AppConfig { + llm_router_base_url: base_url, + llm_router_admin_token: Some("admin-secret".to_string()), + ..AppConfig::default() + }) + .expect("state should build"); + let pending_login = RouterAccountLogin { + username: username.clone(), + password: generate_router_account_password("owner-recover") + .expect("password should derive"), + }; + + let provisioned = provision_router_account_via_new_api( + &state, + "owner-recover", + None, + Some(&pending_login), + ) + .await + .expect("existing Router account should be recovered"); + + assert_eq!(provisioned.raw_key, "recovered-router-key"); + assert_eq!(provisioned.provider_account_id, username); + let requests = captured.lock().expect("captured requests lock").clone(); + assert_eq!(requests.len(), 7); + assert!(requests[0].starts_with("GET /api/user/search?keyword=")); + assert!(requests[1].starts_with("PUT /api/user/ HTTP/1.1")); + assert!(requests[2].starts_with("POST /api/user/login HTTP/1.1")); + assert!( + requests[3].starts_with("GET /api/subscription/admin/users/42/subscriptions HTTP/1.1") + ); + assert!(requests[4].starts_with("GET /api/token/search?keyword=")); + assert!(requests[5].starts_with("PUT /api/token/ HTTP/1.1")); + assert!(requests[6].starts_with("POST /api/token/77/key HTTP/1.1")); + let update_payload: Value = + serde_json::from_str(request_body(&requests[1])).expect("update json"); + assert_eq!(update_payload["id"], 42); + assert_eq!(update_payload["username"], username); + assert_eq!(update_payload["group"], "taonier"); + assert_eq!(update_payload["role"], 1); + 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!( + !requests + .iter() + .any(|request| request.starts_with("POST /api/user/ HTTP/1.1")) + ); + assert!( + !requests + .iter() + .any(|request| request.starts_with("POST /api/token/ HTTP/1.1")) + ); + } + + #[tokio::test] + async fn active_router_key_repair_keeps_user_taonier_and_token_default() { + let owner_user_id = "owner-active-repair"; + let username = router_username_for_owner(owner_user_id); + let password = + generate_router_account_password(owner_user_id).expect("password should derive"); + let (base_url, captured) = spawn_active_router_contract_mock(username.clone()); + let state = AppState::new(AppConfig { + llm_router_base_url: base_url.clone(), + llm_router_admin_token: Some("admin-secret".to_string()), + llm_router_api_key_encryption_secret: Some("fixture-encryption-secret".to_string()), + ..AppConfig::default() + }) + .expect("state should build"); + let ciphertext = encrypt_router_credential_secret( + &RouterCredentialSecret { + version: 1, + api_key: "existing-router-key".to_string(), + username: Some(username.clone()), + password: Some(password), + access_token: None, + }, + "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) + .await + .expect("active Router contract should repair"); + + let requests = captured.lock().expect("captured requests lock").clone(); + assert_eq!(requests.len(), 7); + assert!(requests[0].starts_with("GET /api/user/search?keyword=")); + assert!(requests[1].starts_with("PUT /api/user/ HTTP/1.1")); + assert!(requests[2].starts_with("POST /api/user/login HTTP/1.1")); + assert!(requests[3].starts_with("GET /api/token/search?keyword=")); + assert!(requests[4].starts_with("PUT /api/token/ HTTP/1.1")); + assert!( + requests[5].starts_with("GET /api/subscription/admin/users/42/subscriptions HTTP/1.1") + ); + assert!( + requests[6].starts_with("POST /api/subscription/admin/users/42/subscriptions HTTP/1.1") + ); + + let user_payload: Value = + serde_json::from_str(request_body(&requests[1])).expect("user update json"); + assert_eq!(user_payload["group"], "taonier"); + assert_eq!(user_payload["remark"], owner_user_id); + + 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["unlimited_quota"], true); + assert_eq!(token_payload["expired_time"], -1); + + let subscription_payload: Value = + serde_json::from_str(request_body(&requests[6])).expect("subscription json"); + assert_eq!( + subscription_payload["plan_id"], + LLM_ROUTER_SUBSCRIPTION_PLAN_ID + ); + } + + #[tokio::test] + async fn router_provisioning_requires_admin_token_for_subscription_guarantee() { + let base_url = "http://127.0.0.1:3100/v1".to_string(); + let state = AppState::new(AppConfig { + llm_router_base_url: base_url, + llm_router_admin_token: None, + ..AppConfig::default() + }) + .expect("state should build"); + let pending_login = RouterAccountLogin { + username: router_username_for_owner("owner-no-admin"), + password: "stable-password".to_string(), + }; + + let result = provision_router_account_via_new_api( + &state, + "owner-no-admin", + None, + Some(&pending_login), + ) + .await; + let error = match result { + Ok(_) => panic!("provisioning must not bypass subscription checks"), + Err(error) => error, + }; + assert!(error.contains("管理员 Token 未配置")); + } + + #[test] + fn router_account_credentials_are_stable_per_owner_across_routes() { + let first = generate_router_account_password("owner-1").expect("password should derive"); + let same = generate_router_account_password(" owner-1 ").expect("password should derive"); + let different_owner = + generate_router_account_password("owner-2").expect("password should derive"); + + assert_eq!(first, same); + assert_ne!(first, different_owner); + // New API 的 User.Username/User.Password 字段校验是 20 字符上限, + // 因此派生值必须保持短且确定:`agc_user_` + 11 字符 = 20, + // 密码为 20 位 hex,且二者都基于完整 owner id。 + let username = router_username_for_owner("owner-1"); + assert!(username.starts_with("agc_user_")); + assert_eq!(username.len(), 20); + assert_ne!(router_username_for_owner("owner-2"), username); + assert_ne!(router_username_for_owner("other-owner"), username); + assert_eq!(first.len(), 20); } #[test] @@ -2338,6 +3357,22 @@ mod tests { )); } + #[test] + fn deterministic_router_credential_validation_errors_can_be_retried() { + assert!(is_deterministic_router_credential_error( + "Invalid input Key: 'User.Username' Error:Field validation for 'Username' failed on the 'max' tag" + )); + assert!(is_deterministic_router_credential_error( + "Field validation for 'Password' failed on the 'min' tag" + )); + assert!(!is_deterministic_router_credential_error( + "connection reset by peer" + )); + assert!(!is_deterministic_router_credential_error( + "Router registration may have committed" + )); + } + #[test] fn non_production_router_target_is_loopback_only() { let state = AppState::new(AppConfig { @@ -2382,6 +3417,7 @@ mod tests { thread::spawn(move || { let responses = vec![ + r#"{"success":true,"data":[]}"#.to_string(), "success".to_string(), format!( r#"{{"success":true,"data":[{{"id":42,"username":"{}"}}]}}"#, @@ -2389,8 +3425,11 @@ mod tests { ), "success".to_string(), r#"{"success":true,"data":{"token":"router-login-token"}}"#.to_string(), - r#"{"success":true,"message":""}"#.to_string(), - r#"{"success":true,"data":[{"id":77,"name":"陶泥儿 LLM Router 官方账号(服务端)"}]}"#.to_string(), + r#"{"success":true,"data":[]}"#.to_string(), + "success".to_string(), + r#"{"success":true,"data":[]}"#.to_string(), + r#"{"success":true,"data":{"id":77}}"#.to_string(), + "success".to_string(), r#"{"success":true,"data":{"key":"opaque-router-key"}}"#.to_string(), ]; for response_body in responses { @@ -2407,6 +3446,82 @@ mod tests { (format!("http://{address}"), captured) } + fn spawn_existing_router_account_mock(username: String) -> (String, Arc>>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("mock listener should bind"); + let address = listener + .local_addr() + .expect("mock address should be available"); + let captured = Arc::new(Mutex::new(Vec::new())); + let captured_for_thread = Arc::clone(&captured); + + thread::spawn(move || { + let responses = vec![ + format!( + r#"{{"success":true,"data":[{{"id":42,"username":"{}"}}]}}"#, + username + ), + "success".to_string(), + r#"{"success":true,"data":{"token":"router-login-token"}}"#.to_string(), + r#"{"success":true,"data":[{"subscription":{"plan_id":1,"status":"active","end_time":4102444800}}]}"#.to_string(), + format!( + r#"{{"success":true,"data":[{{"id":77,"name":"{}","group":"taonier"}}]}}"#, + LLM_ROUTER_TOKEN_IDENTIFIER + ), + "success".to_string(), + r#"{"success":true,"data":{"key":"recovered-router-key"}}"#.to_string(), + ]; + for response_body in responses { + let (mut stream, _) = listener.accept().expect("mock request should connect"); + let request = read_http_request(&mut stream); + captured_for_thread + .lock() + .expect("captured requests lock") + .push(request); + write_http_response(&mut stream, response_body.as_str()); + } + }); + + (format!("http://{address}"), captured) + } + + fn spawn_active_router_contract_mock(username: String) -> (String, Arc>>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("mock listener should bind"); + let address = listener + .local_addr() + .expect("mock address should be available"); + let captured = Arc::new(Mutex::new(Vec::new())); + let captured_for_thread = Arc::clone(&captured); + + thread::spawn(move || { + let responses = vec![ + format!( + r#"{{"success":true,"data":[{{"id":42,"username":"{}"}}]}}"#, + username + ), + "success".to_string(), + r#"{"success":true,"data":{"token":"router-login-token"}}"#.to_string(), + format!( + r#"{{"success":true,"data":[{{"id":77,"name":"{}","group":"taonier"}}]}}"#, + LLM_ROUTER_TOKEN_IDENTIFIER + ), + "success".to_string(), + r#"{"success":true,"data":[]}"#.to_string(), + "success".to_string(), + ]; + for response_body in responses { + let (mut stream, _) = listener.accept().expect("mock request should connect"); + let request = read_http_request(&mut stream); + captured_for_thread + .lock() + .expect("captured requests lock") + .push(request); + write_http_response(&mut stream, response_body.as_str()); + } + }); + + (format!("http://{address}"), captured) + } + fn request_body(request: &str) -> &str { request .split_once("\r\n\r\n") diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 4a066f6c7..03849b45f 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -717,7 +717,14 @@ 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. + // 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. + 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