use axum::{ Json, extract::{Extension, Path, State}, http::StatusCode, }; 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, rand::{SecureRandom, SystemRandom}, }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use shared_kernel::{build_prefixed_uuid_id, new_uuid_simple_string}; use spacetime_client::{ ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord, ExternalApiKeyRevokeRecordInput, LlmRouterAccountRecord, LlmRouterAccountUpsertRecordInput, SpacetimeClientError, }; use std::collections::HashMap; use std::sync::{Arc, OnceLock}; use tokio::sync::Mutex; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, config::{OFFICIAL_LLM_ROUTER_BASE_URL, OFFICIAL_LLM_ROUTER_MODEL}, editor_project::current_utc_micros, http_error::AppError, request_context::RequestContext, state::AppState, }; const EXTERNAL_API_KEY_ID_PREFIX: &str = "external-api-key-"; const EXTERNAL_API_KEY_SECRET_PREFIX: &str = "tnr_sk_"; const EXTERNAL_API_KEY_PREFIX_VISIBLE_CHARS: usize = 18; const EXTERNAL_API_KEY_SCOPES: [&str; 4] = [ "editor:project", "editor:canvas", "editor:image-generate", "editor:asset", ]; /// 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 LLM_ROUTER_RECONCILIATION_ERROR_PREFIX: &str = "llm-router-reconciliation-required:"; const LLM_ROUTER_PROVISIONING_CREDENTIAL_VERSION: u32 = 1; type HmacSha256 = Hmac; static LLM_ROUTER_PROVISION_LOCKS: OnceLock>>>> = OnceLock::new(); struct ProvisionedRouterCredential { provider_account_id: String, provider_account_json: Option, raw_key: String, router_username: Option, router_password: Option, router_access_token: Option, } #[derive(Clone, Debug)] struct RouterAccountLogin { username: String, 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 dedicated `llm_router_account.credential_ciphertext` /// column stores this bundle; plaintext never leaves api-server. #[derive(Debug, Deserialize, Serialize)] struct RouterCredentialSecret { version: u32, api_key: String, username: Option, password: Option, access_token: Option, } fn llm_router_provision_locks() -> &'static Mutex>>> { LLM_ROUTER_PROVISION_LOCKS.get_or_init(|| Mutex::new(HashMap::new())) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalApiKeyCreateRequest { name: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalApiKeyPayload { key_id: String, name: String, key_prefix: String, scopes: Vec, created_at: String, last_used_at: Option, revoked_at: Option, updated_at: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalApiKeyCreateResponse { api_key: String, key: ExternalApiKeyPayload, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalApiKeyListResponse { keys: Vec, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalApiKeyResponse { key: ExternalApiKeyPayload, } pub async fn list_external_api_keys( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, AppError> { let keys = state .spacetime_client() .list_external_api_keys(authenticated.claims().user_id().to_string()) .await .map_err(map_external_api_key_error)? .into_iter() .map(external_api_key_payload_from_record) .collect(); Ok(json_success_body( Some(&request_context), ExternalApiKeyListResponse { keys }, )) } pub async fn create_external_api_key( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { let raw_key = generate_external_api_key_secret(); let key_prefix = external_api_key_prefix(raw_key.as_str()); let key = state .spacetime_client() .create_external_api_key(ExternalApiKeyCreateRecordInput { key_id: build_prefixed_uuid_id(EXTERNAL_API_KEY_ID_PREFIX), owner_user_id: authenticated.claims().user_id().to_string(), name: payload .name .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| "外部 API Key".to_string()), key_prefix, key_hash: hash_external_api_key(raw_key.as_str()), scopes: default_external_api_key_scopes(), now_micros: current_utc_micros(), }) .await .map_err(map_external_api_key_error)?; Ok(json_success_body( Some(&request_context), ExternalApiKeyCreateResponse { api_key: raw_key, key: external_api_key_payload_from_record(key), }, )) } /// Creates the fixed-purpose LLM Router account key. The request intentionally has /// no body so ordinary clients cannot choose a provider, URL, model or scope. pub async fn ensure_llm_router_api_key( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, AppError> { 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_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 dedicated llm_router_account row. pub(crate) async fn ensure_llm_router_account( state: &AppState, owner_user_id: &str, ) -> Result { 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)?; // Registration and the first LLM request can race in the same process. // Serialize provisioning per owner so a transient empty read cannot create // multiple Router accounts/keys for one user. let provision_lock = { let mut locks = llm_router_provision_locks().lock().await; Arc::clone( locks .entry(owner_user_id.to_string()) .or_insert_with(|| Arc::new(Mutex::new(()))), ) }; let _guard = provision_lock.lock().await; let configured_route = state.config.llm_router_base_url.trim_end_matches('/'); let encryption_secret = state .config .effective_llm_router_api_key_encryption_secret() .ok_or_else(|| "LLM Router 账号密钥加密配置缺失".to_string())?; 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 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() && account.revoked_at.is_some() { let repaired = upsert_llm_router_account_state( state, account.account_key.as_str(), owner_user_id, configured_route, account.key_id.as_str(), account.router_account_id.clone(), account.credential_ciphertext.clone(), account.account_json.clone(), "retryable", account.attempt_count, None, Some(current_utc_micros()), Some("Router Key 已撤销,准备重新签发".to_string()), ) .await?; persisted = Some(repaired); } if let Some(account) = persisted.as_ref() { 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("未知错误") )); } } let mut bundle = persisted .as_ref() .and_then(|account| account.credential_ciphertext.as_deref()) .map(|ciphertext| { decrypt_router_credential_secret_allow_pending(ciphertext, encryption_secret.as_str()) }) .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 provisioning_secret = llm_router_provisioning_secret(state)?; let expected_password = generate_router_account_password_with_secret( owner_user_id, provisioning_secret.as_bytes(), )?; 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() .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; if bundle.is_none() { // 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. // The dedicated row already owns the encrypted login bundle; no // historical API-key row is consulted for Router recovery. } if bundle.is_none() { generated_new_pending_credentials = true; let username = expected_username.clone(); let password = expected_password.clone(); let pending = RouterCredentialSecret { version: 1, api_key: String::new(), username: Some(username), password: Some(password), access_token: None, }; let ciphertext = encrypt_router_credential_secret(&pending, encryption_secret.as_str())?; upsert_llm_router_account_state( state, &account_key, owner_user_id, configured_route, &key_id, None, Some(ciphertext), persisted .as_ref() .and_then(|value| value.account_json.clone()), "pending", persisted.as_ref().map_or(0, |value| value.attempt_count), None, None, None, ) .await?; bundle = Some(pending); } if let Some(existing_bundle) = bundle.as_ref() { if !existing_bundle.api_key.trim().is_empty() && persisted .as_ref() .is_some_and(|value| matches!(value.status.as_str(), "key_issued" | "active")) { let active_account = upsert_llm_router_account_state( state, &account_key, owner_user_id, configured_route, &key_id, persisted .as_ref() .and_then(|value| value.router_account_id.clone()), Some(encrypt_router_credential_secret( existing_bundle, encryption_secret.as_str(), )?), persisted .as_ref() .and_then(|value| value.account_json.clone()), "active", persisted.as_ref().map_or(0, |value| value.attempt_count), None, None, None, ) .await?; ensure_existing_llm_router_account_contract(state, &active_account).await?; return Ok(active_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 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 .as_ref() .is_some_and(|value| value.router_account_id.is_some()) { reusable_login = bundle.as_ref().and_then(|value| { Some(RouterAccountLogin { username: value.username.clone()?, password: value.password.clone()?, }) }); } let attempt_count = persisted .as_ref() .map_or(0, |value| value.attempt_count) .saturating_add(1); let pending_ciphertext = bundle .as_ref() .map(|value| encrypt_router_credential_secret(value, encryption_secret.as_str())) .transpose()?; let pending_login = bundle.as_ref().and_then(|value| { Some(RouterAccountLogin { username: value.username.clone()?, password: value.password.clone()?, }) }); upsert_llm_router_account_state( state, &account_key, owner_user_id, configured_route, &key_id, persisted .as_ref() .and_then(|value| value.router_account_id.clone()), pending_ciphertext.clone(), persisted .as_ref() .and_then(|value| value.account_json.clone()), "registering", attempt_count, None, None, None, ) .await?; let provisioned = match provision_router_account( state, owner_user_id, reusable_login.as_ref(), pending_login.as_ref(), ) .await { Ok(value) => value, Err(error) => { let status = if error.starts_with(LLM_ROUTER_RECONCILIATION_ERROR_PREFIX) { "unknown" } else { "retryable" }; let _ = upsert_llm_router_account_state( state, &account_key, owner_user_id, configured_route, &key_id, persisted .as_ref() .and_then(|value| value.router_account_id.clone()), pending_ciphertext, persisted .as_ref() .and_then(|value| value.account_json.clone()), status, attempt_count, None, Some(current_utc_micros().saturating_add(60_000_000)), Some(error.clone()), ) .await; return Err(error); } }; let credential_secret = RouterCredentialSecret { version: 1, api_key: provisioned.raw_key.clone(), username: provisioned .router_username .clone() .or_else(|| reusable_login.as_ref().map(|v| v.username.clone())), password: provisioned .router_password .clone() .or_else(|| reusable_login.as_ref().map(|v| v.password.clone())), access_token: provisioned.router_access_token.clone(), }; let secret_ciphertext = encrypt_router_credential_secret(&credential_secret, encryption_secret.as_str()) .map_err(|error| format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}{error}"))?; upsert_llm_router_account_state( state, &account_key, owner_user_id, configured_route, &key_id, Some(provisioned.provider_account_id.clone()), Some(secret_ciphertext.clone()), provisioned.provider_account_json.clone(), "key_issued", attempt_count, None, None, None, ) .await?; 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( state: &AppState, account_key: &str, owner_user_id: &str, route_origin: &str, key_id: &str, router_account_id: Option, credential_ciphertext: Option, account_json: Option, status: &str, attempt_count: u32, lease_until_micros: Option, 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()), ) }) }); let account = 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: key_id.to_string(), router_account_id, credential_ciphertext, account_json, status: status.to_string(), attempt_count, lease_until_micros, next_retry_at_micros, 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}"))?; Ok(account) } /// 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: &LlmRouterAccountRecord, ) -> 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 .credential_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_account(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(()) } pub(crate) fn router_user_id_from_account(record: &LlmRouterAccountRecord) -> Option { record .router_account_id .as_deref() .and_then(|value| value.trim().parse::().ok()) .or_else(|| { record .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) }) }) } /// Starts best-effort Router-account preparation after successful /// authentication. Router control-plane availability must not gate the main /// site session; the LLM hot path validates the locally persisted credential /// when a request is actually made. pub(crate) async fn ensure_llm_router_account_after_auth_success( state: &AppState, _request_context: &RequestContext, owner_user_id: &str, ) -> Result<(), String> { #[cfg(test)] if std::env::var_os("GENARRATIVE_LLM_ROUTER_AUTH_PROVISION_TEST").is_none() { // Authentication tests should not contact the production Router. The // provisioning path remains covered by its dedicated tests and can // be enabled explicitly with this test-only fixture switch. return Ok(()); } #[cfg(test)] { if let Err(error) = ensure_llm_router_account(state, owner_user_id).await { tracing::warn!( user_id = %owner_user_id, error = %error, "测试环境登录后 LLM Router 账号准备失败;已允许主站登录" ); } return Ok(()); } #[cfg(not(test))] { let state = state.clone(); let owner_user_id = owner_user_id.to_string(); tokio::spawn(async move { if let Err(error) = ensure_llm_router_account(&state, owner_user_id.as_str()).await { tracing::warn!( user_id = %owner_user_id, error = %error, "登录后 LLM Router 账号准备失败;已允许主站登录,后续 LLM 请求将按需失败关闭" ); } }); Ok(()) } } /// 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 Some(account) = state .spacetime_client() .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}"))? .filter(|account| account.status == "active" && account.revoked_at.is_none()) else { return Ok(None); }; let ciphertext = account.credential_ciphertext.as_deref().ok_or_else(|| { format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}active Router Key 密文缺失") })?; // Router account state is read from SpacetimeDB immediately before every // upstream request and the current ciphertext is decrypted on demand. This // deliberately avoids a TTL cache: another api-server instance can rotate // or revoke the durable row at any time, and no process-local entry can // outlive that authoritative state. 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, account.key_id, ))) } pub(crate) async fn revoke_llm_router_account( state: &AppState, owner_user_id: &str, key_id: &str, ) -> Result<(), String> { let route_origin = state.config.llm_router_base_url.trim_end_matches('/'); 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.key_id == key_id) { upsert_llm_router_account_state( state, account.account_key.as_str(), owner_user_id, route_origin, key_id, account.router_account_id.clone(), account.credential_ciphertext.clone(), account.account_json.clone(), "retryable", account.attempt_count, None, Some(current_utc_micros()), Some("Router 返回 401/403,等待重新签发账号 Key".to_string()), ) .await?; } Ok(()) } async fn provision_router_account( state: &AppState, owner_user_id: &str, reusable_login: Option<&RouterAccountLogin>, pending_login: Option<&RouterAccountLogin>, ) -> Result { // 走 Router New API 的正式账号链路:先按稳定用户名/密码恢复已有用户,确认不存在 // 后才注册,再登录、复用或创建无限额度 token、签发 API Key。网络/5xx/格式不确定 // 等情况保持 reconciliation,避免重复注册。 provision_router_account_via_new_api(state, owner_user_id, reusable_login, pending_login).await } async fn provision_router_account_via_new_api( state: &AppState, owner_user_id: &str, reusable_login: Option<&RouterAccountLogin>, pending_login: Option<&RouterAccountLogin>, ) -> Result { let origin = router_control_origin(state.config.llm_router_base_url.as_str())?; let client = 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}"))?; 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_with_secret( owner_user_id, llm_router_provisioning_secret(state)?.as_bytes(), )?, )); 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 (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 用户无法使用保存的凭据登录:{error}" ) })?; 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 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) )); } 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 .post(format!("{origin}/api/token/{token_id}/key")) .bearer_auth(login_token.as_str()) .send() .await .map_err(|error| { format!( "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router API Key 签发未取得确定响应:{error}" ) })?; let key_status = key_response.status(); let key_payload = response_json_or_text(key_response).await?; if !key_status.is_success() { if router_http_failure_requires_reconciliation(key_status) { return Err(format!( "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}LLM Router API Key 签发返回 HTTP {},结果可能未知{}", key_status.as_u16(), provider_payload_error_suffix(&key_payload) )); } return Err(format!( "LLM Router API Key 签发失败:HTTP {}{}", key_status.as_u16(), provider_payload_error_suffix(&key_payload) )); } if provider_payload_failed(&key_payload) { return Err(format!( "LLM Router API Key 签发失败:HTTP {}{}", key_status.as_u16(), provider_payload_error_suffix(&key_payload) )); } let raw_key = extract_provider_string(&key_payload, &["apiKey", "api_key", "key", "token"]) .ok_or_else(|| { format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router API Key 响应缺少 key") })?; let account_id = extract_provider_string( &token_payload, &["accountId", "account_id", "userId", "user_id"], ) .unwrap_or_else(|| username.clone()); Ok(ProvisionedRouterCredential { provider_account_id: account_id.clone(), provider_account_json: Some( 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, "credentialSource": "router-new-api", }) .to_string(), ), raw_key: normalize_new_api_relay_api_key(raw_key.as_str())?, router_username: Some(username), router_password: Some(password), router_access_token: None, }) } 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_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), "remark": owner_user_id, "group": LLM_ROUTER_USER_GROUP, "role": 1, }) } fn router_user_display_name(username: &str) -> String { username.to_string() } async fn find_router_token_id( client: &reqwest::Client, origin: &str, login_token: &str, ) -> 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_TOKEN_IDENTIFIER); let response = client .get(search_url) .bearer_auth(login_token) .send() .await .map_err(|error| { format!( "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router token 查询未取得确定响应:{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 token 查询返回 HTTP {}{}", status.as_u16(), provider_payload_error_suffix(&payload) )); } if provider_payload_failed(&payload) { return Err(format!( "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router token 查询失败{}", provider_payload_error_suffix(&payload) )); } 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 { let mut url = reqwest::Url::parse(base_url.trim_end_matches('/')) .map_err(|error| format!("LLM Router 地址无效:{error}"))?; let is_loopback = url.host_str().is_some_and(|host| { host.eq_ignore_ascii_case("localhost") || host .parse::() .is_ok_and(|address| address.is_loopback()) }); let allowed_scheme = url.scheme() == "https" || (url.scheme() == "http" && is_loopback); if !allowed_scheme || url.host_str().is_none() { return Err("LLM Router 账号链路只允许 HTTPS 或 loopback HTTP 地址".to_string()); } url.set_path(""); url.set_query(None); url.set_fragment(None); Ok(url.to_string().trim_end_matches('/').to_string()) } fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> { let base_url = state.config.llm_router_base_url.trim_end_matches('/'); let url = reqwest::Url::parse(base_url).map_err(|error| format!("LLM Router 地址无效:{error}"))?; let host = url .host_str() .ok_or_else(|| "LLM Router 地址缺少主机名".to_string())?; let is_loopback = host.eq_ignore_ascii_case("localhost") || host .parse::() .is_ok_and(|address| address.is_loopback()); #[cfg(test)] if !is_loopback { return Err("单元测试禁止连接非 loopback 的 LLM Router".to_string()); } if state.config.is_production() { if base_url != OFFICIAL_LLM_ROUTER_BASE_URL { return Err("生产环境 LLM Router 必须使用官方固定路由".to_string()); } 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()); } 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!( "当前环境 {} 只允许官方固定 LLM Router 或 loopback Router;请检查路由配置", state.config.environment )); } if !matches!(url.scheme(), "http" | "https") { return Err("非生产环境 LLM Router 只允许 HTTP/HTTPS loopback 地址".to_string()); } Ok(()) } fn router_username_for_owner(owner_user_id: &str) -> String { // 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 llm_router_provisioning_secret(state: &AppState) -> Result { if let Some(secret) = state .config .llm_router_provisioning_secret .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { return Ok(secret.to_string()); } #[cfg(test)] { return Ok("test-only-router-provisioning-secret".to_string()); } #[cfg(not(test))] { Err("LLM Router 账号密码派生密钥未配置".to_string()) } } #[cfg(test)] fn generate_router_account_password(owner_user_id: &str) -> Result { generate_router_account_password_with_secret( owner_user_id, b"test-only-router-provisioning-secret", ) } fn generate_router_account_password_with_secret( owner_user_id: &str, provisioning_secret: &[u8], ) -> Result { let owner_user_id = owner_user_id.trim(); if owner_user_id.is_empty() { return Err("LLM Router 账号密码派生参数缺失".to_string()); } if provisioning_secret.is_empty() { return Err("LLM Router 账号密码派生密钥未配置".to_string()); } let mut signer = HmacSha256::new_from_slice(provisioning_secret) .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 { let bytes = response.bytes().await.map_err(|error| { format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 响应读取失败:{error}") })?; if bytes.is_empty() { return Ok(json!({})); } serde_json::from_slice::(&bytes).map_err(|error| { format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 响应格式无效:{error}") }) } async fn response_json_or_text(response: reqwest::Response) -> Result { let bytes = response.bytes().await.map_err(|error| { format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 响应读取失败:{error}") })?; if bytes.is_empty() { return Ok(json!({})); } match serde_json::from_slice::(&bytes) { Ok(value) => Ok(value), Err(_) => String::from_utf8(bytes.to_vec()) .map(|value| Value::String(value.trim().to_string())) .ok() .filter(|value| value.as_str().is_some_and(|text| !text.is_empty())) .ok_or_else(|| { format!( "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}Router 响应既不是合法 JSON 也不是非空文本" ) }), } } fn extract_router_user_id(payload: &Value, username: &str) -> Option { fn search(value: &Value, username: &str) -> Option { match value { Value::Object(object) => { let object_username = object .get("username") .or_else(|| object.get("userName")) .and_then(Value::as_str); if object_username.is_some_and(|value| value == username) { return object .get("id") .or_else(|| object.get("userId")) .or_else(|| object.get("user_id")) .and_then(provider_value_to_i64); } object.values().find_map(|value| search(value, username)) } Value::Array(values) => values.iter().find_map(|value| search(value, username)), _ => None, } } search(payload, username) } fn provider_value_to_i64(value: &Value) -> Option { value .as_i64() .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok())) .or_else(|| value.as_str().and_then(|value| value.trim().parse().ok())) } 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) { 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) { 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); } } Value::Array(values) => { for value in values { search(value, token_name, matches); } } _ => {} } } 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 { value.get("data").unwrap_or(value) } /// A 4xx response is a deterministic rejection and may be retried with the /// same durable account intent. A 3xx/5xx response is not safe to interpret as /// a failed provisioning operation because the remote side may have committed /// the account before the response was lost or redirected; keep it in /// reconciliation instead of registering another account. fn router_http_failure_requires_reconciliation(status: reqwest::StatusCode) -> bool { !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") .and_then(Value::as_bool) .is_some_and(|success| !success) || value .get("ok") .and_then(Value::as_bool) .is_some_and(|ok| !ok) || value .get("code") .and_then(provider_value_to_i64) .is_some_and(|code| code != 0 && code != 200) } fn provider_payload_error_suffix(value: &Value) -> String { extract_provider_string(value, &["message", "error", "msg"]) .map(|message| format!(":{message}")) .unwrap_or_default() } fn extract_provider_string(value: &Value, keys: &[&str]) -> Option { let data = provider_payload_data(value); if let Some(result) = keys .iter() .find_map(|key| data.get(*key).and_then(provider_value_to_string)) { return Some(result); } // New API uses both object envelopes (`data: { key: ... }`) and direct // string envelopes (`data: "opaque-key"`) depending on the endpoint and // version. A direct string is only accepted for callers that explicitly // requested a scalar credential/id field, never for arbitrary messages. if data.is_string() && keys.iter().any(|key| { matches!( *key, "token" | "accessToken" | "access_token" | "userToken" | "apiKey" | "api_key" | "key" | "id" | "tokenId" | "token_id" ) }) { return provider_value_to_string(data); } None } fn provider_value_to_string(value: &Value) -> Option { match value { Value::String(value) => Some(value.trim().to_string()), Value::Number(value) => Some(value.to_string()), _ => None, } .filter(|value| !value.is_empty()) } fn derive_llm_router_key_id(owner_user_id: &str, route_origin: &str) -> String { let fingerprint = Sha256::digest( format!( "{}\n{}", owner_user_id.trim(), route_origin.trim_end_matches('/') ) .as_bytes(), ); format!( "{EXTERNAL_API_KEY_ID_PREFIX}llm-router-{}", &hex::encode(fingerprint)[..32] ) } fn derive_llm_router_account_key(owner_user_id: &str, route_origin: &str) -> String { let fingerprint = Sha256::digest( format!( "llm-router-account\n{}\n{}", owner_user_id.trim(), route_origin.trim_end_matches('/') ) .as_bytes(), ); format!("llm-router-account-{}", &hex::encode(fingerprint)[..40]) } fn hash_llm_router_key(owner_user_id: &str, key_id: &str, raw_key: &str) -> String { // LLM Router rows are server-owned credentials and are never authenticated through // the public external-key endpoint. Bind the uniqueness hash to owner and // row id so each deterministic provisioning generation has one local row. hash_external_api_key(&format!("llm-router:{owner_user_id}:{key_id}:{raw_key}")) } fn normalize_router_api_key(value: &str) -> Result { let value = value.trim(); if value.is_empty() || value.len() > 1024 || value .chars() .any(|character| character.is_whitespace() || character.is_control()) { return Err("LLM Router 返回的 API Key 格式无效".to_string()); } Ok(value.to_string()) } /// Preserve the opaque key returned by Router. Key prefixes are owned by the /// Router deployment; api-server must not invent or rewrite one. fn normalize_new_api_relay_api_key(value: &str) -> Result { normalize_router_api_key(value) } fn encrypt_router_credential_secret( secret_payload: &RouterCredentialSecret, secret: &str, ) -> Result { let payload = serde_json::to_vec(secret_payload) .map_err(|error| format!("LLM Router 账号凭据序列化失败:{error}"))?; encrypt_router_secret_payload(payload.as_slice(), secret) } fn encrypt_router_secret_payload(payload: &[u8], secret: &str) -> Result { let key_material = Sha256::digest(secret.trim().as_bytes()); let unbound = aead::UnboundKey::new(&aead::AES_256_GCM, key_material.as_ref()) .map_err(|_| "LLM Router API Key 加密密钥无效".to_string())?; let key = aead::LessSafeKey::new(unbound); let mut nonce_bytes = [0_u8; aead::NONCE_LEN]; SystemRandom::new() .fill(&mut nonce_bytes) .map_err(|_| "LLM Router API Key 随机数生成失败".to_string())?; let nonce = aead::Nonce::assume_unique_for_key(nonce_bytes); let mut encrypted_payload = payload.to_vec(); key.seal_in_place_append_tag(nonce, aead::Aad::empty(), &mut encrypted_payload) .map_err(|_| "LLM Router API Key 加密失败".to_string())?; let mut encoded = nonce_bytes.to_vec(); encoded.extend_from_slice(&encrypted_payload); Ok(STANDARD_NO_PAD.encode(encoded)) } pub(crate) fn decrypt_router_api_key(ciphertext: &str, secret: &str) -> Result { let plaintext = decrypt_router_secret_payload(ciphertext, secret)?; if let Ok(bundle) = serde_json::from_slice::(&plaintext) { return normalize_router_api_key(bundle.api_key.as_str()); } normalize_router_api_key( std::str::from_utf8(&plaintext).map_err(|_| "LLM Router API Key 编码无效".to_string())?, ) } fn decrypt_router_credential_secret( ciphertext: &str, secret: &str, ) -> Result { let plaintext = decrypt_router_secret_payload(ciphertext, secret)?; let bundle = serde_json::from_slice::(&plaintext) .map_err(|_| "LLM Router 账号凭据格式无效".to_string())?; normalize_router_api_key(bundle.api_key.as_str())?; Ok(bundle) } fn decrypt_router_credential_secret_allow_pending( ciphertext: &str, secret: &str, ) -> Result { let plaintext = decrypt_router_secret_payload(ciphertext, secret)?; let bundle = serde_json::from_slice::(&plaintext) .map_err(|_| "LLM Router 账号凭据格式无效".to_string())?; if bundle.api_key.trim().is_empty() && (bundle.username.as_deref().unwrap_or("").trim().is_empty() || bundle.password.as_deref().unwrap_or("").trim().is_empty()) { return Err("LLM Router 待完成账号凭据缺少用户名或密码".to_string()); } if !bundle.api_key.trim().is_empty() { normalize_router_api_key(bundle.api_key.as_str())?; } Ok(bundle) } fn decrypt_router_secret_payload(ciphertext: &str, secret: &str) -> Result, String> { let mut encoded = STANDARD_NO_PAD .decode(ciphertext.trim()) .map_err(|_| "LLM Router API Key 密文格式无效".to_string())?; if encoded.len() < aead::NONCE_LEN + 16 { return Err("LLM Router API Key 密文长度无效".to_string()); } let nonce_bytes: [u8; aead::NONCE_LEN] = encoded[..aead::NONCE_LEN] .try_into() .map_err(|_| "LLM Router API Key nonce 无效".to_string())?; let nonce = aead::Nonce::assume_unique_for_key(nonce_bytes); let payload = &mut encoded[aead::NONCE_LEN..]; let key_material = Sha256::digest(secret.trim().as_bytes()); let unbound = aead::UnboundKey::new(&aead::AES_256_GCM, key_material.as_ref()) .map_err(|_| "LLM Router API Key 解密密钥无效".to_string())?; let key = aead::LessSafeKey::new(unbound); let plaintext = key .open_in_place(nonce, aead::Aad::empty(), payload) .map_err(|_| "LLM Router API Key 解密失败".to_string())?; Ok(plaintext.to_vec()) } pub async fn revoke_external_api_key( State(state): State, Path(key_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, AppError> { let key = state .spacetime_client() .revoke_external_api_key(ExternalApiKeyRevokeRecordInput { key_id, owner_user_id: authenticated.claims().user_id().to_string(), revoked_at_micros: current_utc_micros(), }) .await .map_err(map_external_api_key_error)?; Ok(json_success_body( Some(&request_context), ExternalApiKeyResponse { key: external_api_key_payload_from_record(key), }, )) } pub(crate) fn hash_external_api_key(raw_key: &str) -> String { hash_refresh_session_token(raw_key) } pub(crate) fn default_external_api_key_scopes() -> Vec { EXTERNAL_API_KEY_SCOPES .iter() .map(|scope| (*scope).to_string()) .collect() } fn generate_external_api_key_secret() -> String { format!( "{EXTERNAL_API_KEY_SECRET_PREFIX}{}.{}", new_uuid_simple_string(), new_uuid_simple_string() ) } fn external_api_key_prefix(raw_key: &str) -> String { raw_key .chars() .take(EXTERNAL_API_KEY_PREFIX_VISIBLE_CHARS) .collect() } fn external_api_key_payload_from_record(record: ExternalApiKeyRecord) -> ExternalApiKeyPayload { ExternalApiKeyPayload { key_id: record.key_id, name: record.name, key_prefix: record.key_prefix, scopes: record.scopes, created_at: record.created_at, last_used_at: record.last_used_at, revoked_at: record.revoked_at, updated_at: record.updated_at, } } 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(), 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) if message.contains("不存在") || message.contains("已失效") => { AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({ "provider": "external-api-key", "message": message, })) } SpacetimeClientError::Procedure(message) if message.contains("无权") => { AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({ "provider": "external-api-key", "message": message, })) } SpacetimeClientError::Runtime(message) | SpacetimeClientError::Procedure(message) => { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "external-api-key", "message": message, })) } other => AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": other.to_string(), })), } } #[cfg(test)] mod tests { use super::*; use std::{ io::{Read, Write}, net::{TcpListener, TcpStream}, sync::{Arc, Mutex}, thread, time::Duration, }; use crate::{config::AppConfig, state::AppState}; #[test] fn generated_external_api_key_uses_public_prefix_only_for_display() { let raw_key = generate_external_api_key_secret(); let key_prefix = external_api_key_prefix(raw_key.as_str()); assert!(raw_key.starts_with(EXTERNAL_API_KEY_SECRET_PREFIX)); assert_eq!( key_prefix.chars().count(), EXTERNAL_API_KEY_PREFIX_VISIBLE_CHARS ); assert!(raw_key.starts_with(key_prefix.as_str())); assert_ne!(hash_external_api_key(raw_key.as_str()), raw_key); } #[test] fn router_api_key_accepts_provider_owned_opaque_formats() { assert_eq!( normalize_router_api_key(" sk-eNuq-test-key ").expect("sk key should be accepted"), "sk-eNuq-test-key" ); assert_eq!( normalize_router_api_key("tnr_sk_fixture_123").expect("tnr key should remain accepted"), "tnr_sk_fixture_123" ); assert!(normalize_router_api_key(" ").is_err()); assert!(normalize_router_api_key("router-key\nwith-newline").is_err()); } #[test] fn new_api_relay_key_preserves_router_owned_opaque_format() { assert_eq!( normalize_new_api_relay_api_key("raw-router-token").unwrap(), "raw-router-token" ); assert_eq!( normalize_new_api_relay_api_key(" sk-already-prefixed ").unwrap(), "sk-already-prefixed" ); } #[test] fn new_api_token_request_is_unlimited_and_bound_to_default_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!(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", "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!( payload["display_name"] .as_str() .is_some_and(|value| value.contains("abcd")) ); } #[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, llm_router_admin_token: Some("admin-secret".to_string()), ..AppConfig::default() }) .expect("state should build"); let pending_login = RouterAccountLogin { username: username.clone(), password: password.clone(), }; let provisioned = provision_router_account_via_new_api(&state, "owner-1", None, Some(&pending_login)) .await .expect("New API provisioning should succeed"); assert_eq!(provisioned.raw_key, "opaque-router-key"); assert_eq!(provisioned.provider_account_id, username); assert_eq!( provisioned.router_username.as_deref(), Some(username.as_str()) ); assert!(provisioned.router_password.is_some()); let requests = captured.lock().expect("captured requests lock").clone(); 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] .lines() .any(|line| line.eq_ignore_ascii_case("authorization: Bearer admin-secret")) ); assert!( requests[1] .lines() .any(|line| line.eq_ignore_ascii_case("authorization: Bearer admin-secret")) ); assert!( requests[2] .lines() .any(|line| line.eq_ignore_ascii_case("authorization: Bearer admin-secret")) ); 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"], 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) ); let update_payload: Value = 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[8])).expect("token json"); assert_eq!(token_payload["unlimited_quota"], true); assert_eq!(token_payload["expired_time"], -1); 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[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 admin-secret")) ); assert!( requests[6] .lines() .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 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"); 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] fn default_external_api_key_scopes_cover_editor_openapi_v1() { let scopes = default_external_api_key_scopes(); assert_eq!( scopes, vec![ "editor:project".to_string(), "editor:canvas".to_string(), "editor:image-generate".to_string(), "editor:asset".to_string(), ] ); } #[test] fn router_credential_bundle_round_trip_keeps_password_server_side() { let bundle = RouterCredentialSecret { version: 1, api_key: "sk-router-test".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"); assert_eq!( decrypt_router_api_key(&ciphertext, "test-secret").unwrap(), "sk-router-test" ); let plaintext = decrypt_router_secret_payload(&ciphertext, "test-secret") .expect("credential bundle should decrypt"); let restored: RouterCredentialSecret = serde_json::from_slice(&plaintext).expect("credential bundle should be json"); assert_eq!(restored.password.as_deref(), Some("Router-random-password")); assert!(!ciphertext.contains("Router-random-password")); } #[test] fn router_control_origin_removes_responses_path() { assert_eq!( router_control_origin("https://router.example/v1").unwrap(), "https://router.example" ); assert!(router_control_origin("ftp://router.example/v1").is_err()); } #[test] fn router_payload_success_and_key_extraction_accept_new_api_shapes() { let payload = json!({"success": true, "data": {"id": 42, "key": "sk-router"}}); assert!(!provider_payload_failed(&payload)); assert_eq!( extract_provider_string(&payload, &["id"]), Some("42".to_string()) ); assert_eq!( extract_provider_string(&payload, &["key"]), Some("sk-router".to_string()) ); assert!(provider_payload_failed( &json!({"success": false, "message": "denied"}) )); } #[test] fn new_api_scalar_data_envelopes_are_supported_for_login_and_key_issuance() { assert_eq!( extract_provider_string( &json!({"success": true, "data": "router-login-token"}), &["token", "accessToken"] ), Some("router-login-token".to_string()) ); assert_eq!( extract_provider_string( &json!({"success": true, "data": "sk-router-key"}), &["apiKey", "key"] ), Some("sk-router-key".to_string()) ); assert_eq!( extract_provider_string(&json!("sk-router-plain-text"), &["apiKey", "key"]), Some("sk-router-plain-text".to_string()) ); } #[test] fn router_http_failures_mark_non_client_responses_as_uncertain() { assert!(!router_http_failure_requires_reconciliation( reqwest::StatusCode::BAD_REQUEST )); assert!(!router_http_failure_requires_reconciliation( reqwest::StatusCode::FORBIDDEN )); assert!(router_http_failure_requires_reconciliation( reqwest::StatusCode::MOVED_PERMANENTLY )); assert!(router_http_failure_requires_reconciliation( reqwest::StatusCode::BAD_GATEWAY )); assert!(router_http_failure_requires_reconciliation( reqwest::StatusCode::SERVICE_UNAVAILABLE )); } #[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 { environment: "test".to_string(), llm_router_base_url: "https://router.genarrative.world/v1".to_string(), ..AppConfig::default() }) .expect("test state should build"); let error = ensure_llm_router_target_allowed(&state) .expect_err("test environment must reject the online Router target"); assert!(error.contains("loopback")); let state = AppState::new(AppConfig { environment: "test".to_string(), llm_router_base_url: "http://127.0.0.1:3100/v1".to_string(), ..AppConfig::default() }) .expect("test state should build"); ensure_llm_router_target_allowed(&state) .expect("test environment should allow a loopback Router fixture"); } #[test] fn production_router_target_requires_https_but_not_admin_token_for_reuse() { let state = AppState::new(AppConfig { environment: "production".to_string(), llm_router_base_url: "http://router.example/v1".to_string(), ..AppConfig::default() }) .expect("test state should build"); ensure_llm_router_target_allowed(&state) .expect_err("production Router control plane must use HTTPS"); } fn spawn_new_api_provisioning_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![ r#"{"success":true,"data":[]}"#.to_string(), "success".to_string(), 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":[]}"#.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 { 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_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") .map(|(_, body)| body) .unwrap_or_default() } fn read_http_request(stream: &mut TcpStream) -> String { stream .set_read_timeout(Some(Duration::from_secs(2))) .expect("mock read timeout should set"); let mut buffer = Vec::new(); let mut chunk = [0_u8; 4096]; let mut expected_total = None; loop { match stream.read(&mut chunk) { Ok(0) => break, Ok(read) => { buffer.extend_from_slice(&chunk[..read]); if expected_total.is_none() && let Some(header_end) = buffer.windows(4).position(|value| value == b"\r\n\r\n") { let header_end = header_end + 4; let headers = String::from_utf8_lossy(&buffer[..header_end]); let content_length = headers .lines() .find_map(|line| { line.strip_prefix("Content-Length:") .or_else(|| line.strip_prefix("content-length:")) }) .and_then(|value| value.trim().parse::().ok()) .unwrap_or(0); expected_total = Some(header_end + content_length); } if expected_total.is_some_and(|total| buffer.len() >= total) { break; } } Err(error) if matches!( error.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut ) => { break; } Err(error) => panic!("mock request read failed: {error}"), } } String::from_utf8_lossy(&buffer).into_owned() } fn write_http_response(stream: &mut TcpStream, body: &str) { let response = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body ); stream .write_all(response.as_bytes()) .expect("mock response should write"); } }