From 8d9aaf0bbd7cbb50092ff10e218a87c888637bae Mon Sep 17 00:00:00 2001 From: Zyy <39424122+zyycn@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:04:41 +0800 Subject: [PATCH] feat(accounts): support upstream API key accounts (#107) Add Responses-compatible upstream accounts with isolated credentials, transport, model catalogs, atomic settings updates, and account management controls. Co-authored-by: Zyy Co-authored-by: Codex --- .../src/model/provider_credentials.rs | 3 + .../gateway-admin/src/ports/provider.rs | 8 + .../gateway-admin/src/use_case/accounts.rs | 74 +++- .../crates/gateway-admin/src/use_case/mod.rs | 17 +- .../gateway-admin/src/use_case/openai.rs | 15 + .../gateway-admin/tests/use_case/accounts.rs | 46 +++ .../src/admin/accounts/credentials.rs | 81 ++++- .../src/admin/accounts/handlers.rs | 9 +- .../src/admin/accounts/presenter.rs | 6 +- .../gateway-api/src/admin/accounts/wire.rs | 8 + .../gateway-api/tests/admin/accounts/mod.rs | 38 +++ .../crates/gateway-core/src/account/model.rs | 7 +- .../gateway-core/src/engine/execution.rs | 10 +- .../gateway-core/src/routing/catalog.rs | 26 +- .../crates/gateway-core/src/routing/mod.rs | 3 +- .../crates/gateway-core/tests/account/mod.rs | 17 +- .../gateway-core/tests/engine/execution.rs | 16 +- .../provider_accounts/admin_adapter.rs | 38 ++- .../provider_accounts/core_adapter.rs | 8 +- .../postgres/provider_accounts/repository.rs | 59 +++- .../src/postgres/provider_accounts/rows.rs | 2 + .../tests/postgres/provider_accounts/mod.rs | 143 +++++++- backend/crates/providers/openai/src/admin.rs | 88 ++++- backend/crates/providers/openai/src/config.rs | 21 +- .../providers/openai/src/credential/admin.rs | 291 +++++++++++++++- .../openai/src/credential/api_key.rs | 87 +++++ .../openai/src/credential/catalog.rs | 195 ++++++++--- .../providers/openai/src/credential/mod.rs | 6 + .../openai/src/credential/quota/mod.rs | 12 +- .../openai/src/credential/repository.rs | 23 +- .../openai/src/credential/security.rs | 31 ++ .../openai/src/credential/selector.rs | 55 ++- .../providers/openai/src/credential/types.rs | 14 +- .../openai/src/provider/execution.rs | 4 + .../providers/openai/src/provider/mod.rs | 45 ++- .../providers/openai/src/transport/catalog.rs | 72 +++- .../providers/openai/src/transport/client.rs | 37 ++ .../openai/src/transport/client_sse.rs | 54 +-- .../openai/src/transport/endpoints.rs | 18 + .../providers/openai/src/transport/headers.rs | 67 +++- .../providers/openai/src/transport/mod.rs | 1 + .../src/transport/websocket/handshake.rs | 23 +- .../crates/providers/openai/tests/admin.rs | 75 ++++ .../openai/tests/credential/admin.rs | 113 ++++++ .../openai/tests/credential/catalog.rs | 321 +++++++++++++++++- .../openai/tests/provider/contract.rs | 186 +++++++++- .../crates/providers/openai/tests/support.rs | 43 +++ docs/api.md | 53 ++- docs/architecture.md | 7 + frontend/src/api/modules/accounts.ts | 22 ++ frontend/src/components/base/BaseCheckbox.vue | 2 +- .../src/components/base/BaseSegmented.vue | 9 +- .../components/AccountApiKeyFields.vue | 33 ++ .../AccountProviderChooser.vue | 31 +- .../components/AccountCreateModal/index.vue | 6 +- .../components/AccountCreateModal/model.ts | 8 +- .../AccountCreateModal/presenter.ts | 8 +- .../accounts/components/AccountEditModal.vue | 22 +- .../components/AccountIdentityCell.vue | 14 +- .../components/AccountModelAccessField.vue | 39 ++- .../accounts/components/AccountPlanBadge.vue | 6 +- .../components/AccountQuotaPanel/index.vue | 17 +- .../AccountQuotaSummaryCell/index.vue | 15 +- .../components/AccountTableActions.vue | 3 +- .../accounts/components/AccountUsagePanel.vue | 2 + .../accounts/composables/useAccountEditor.ts | 70 +++- .../composables/useAccountOnboarding.ts | 19 +- frontend/src/views/accounts/index.vue | 8 +- .../views/accounts/utils/upstreamApiKey.ts | 31 ++ .../components/DashboardAccountIdentity.vue | 2 +- 70 files changed, 2632 insertions(+), 311 deletions(-) create mode 100644 backend/crates/providers/openai/src/credential/api_key.rs create mode 100644 frontend/src/views/accounts/components/AccountApiKeyFields.vue create mode 100644 frontend/src/views/accounts/utils/upstreamApiKey.ts diff --git a/backend/crates/gateway-admin/src/model/provider_credentials.rs b/backend/crates/gateway-admin/src/model/provider_credentials.rs index 28d5611b4..0f1342e46 100644 --- a/backend/crates/gateway-admin/src/model/provider_credentials.rs +++ b/backend/crates/gateway-admin/src/model/provider_credentials.rs @@ -585,6 +585,7 @@ pub struct CredentialDeletionResult { pub struct RotateCredential { pub mutation: CredentialMutation, pub provider_material: ProviderDocument, + pub settings: Option, } /// Provider 校验手工轮换材料时所需的非事务输入。 @@ -664,6 +665,7 @@ impl fmt::Debug for PreparedCredentialRotation { #[derive(Debug, Clone, PartialEq)] pub struct CredentialRotationCommit { pub prepared: PreparedCredentialRotationFacts, + pub settings: Option, } impl fmt::Debug for RotateCredential { @@ -672,6 +674,7 @@ impl fmt::Debug for RotateCredential { .debug_struct("RotateCredential") .field("mutation", &self.mutation) .field("provider_material", &self.provider_material) + .field("settings", &self.settings) .finish() } } diff --git a/backend/crates/gateway-admin/src/ports/provider.rs b/backend/crates/gateway-admin/src/ports/provider.rs index ac61c8b9b..5420f2a99 100644 --- a/backend/crates/gateway-admin/src/ports/provider.rs +++ b/backend/crates/gateway-admin/src/ports/provider.rs @@ -170,6 +170,14 @@ pub trait ProviderAdmin: Send + Sync { command: PrepareCredentialRefresh, ) -> Result; + /// 可公开的账号连接设置;实现只能显式投影非敏感字段,不能返回凭据原文。 + async fn account_configuration( + &self, + _account_id: &ProviderAccountId, + ) -> Result, ProviderAdminError> { + Ok(None) + } + async fn quota( &self, request: ProviderQuotaRequest, diff --git a/backend/crates/gateway-admin/src/use_case/accounts.rs b/backend/crates/gateway-admin/src/use_case/accounts.rs index bf840c5c7..785fc3ebd 100644 --- a/backend/crates/gateway-admin/src/use_case/accounts.rs +++ b/backend/crates/gateway-admin/src/use_case/accounts.rs @@ -17,8 +17,8 @@ use crate::{ AdminError, MutationContext, accounts::{ AccountConnectionTestEvent, AccountConnectionTestEventStream, AccountListQuery, - AccountPageItem, AccountUpdateResult, AccountUsageWindowQuery, AccountsUpdateResult, - BatchUpdateAccounts, UpdateAccount, + AccountPageItem, AccountUpdateResult, AccountUsage, AccountUsageWindowQuery, + AccountsUpdateResult, BatchUpdateAccounts, UpdateAccount, }, observability::TimeRange, provider_credentials::{ @@ -79,6 +79,13 @@ pub trait AccountsService: Send + Sync { command: BatchUpdateAccounts, ) -> Result; + async fn account_configuration( + &self, + _account_id: &ProviderAccountId, + ) -> Result, AdminError> { + Ok(None) + } + async fn quota( &self, account_id: &ProviderAccountId, @@ -250,6 +257,37 @@ impl DefaultAccountsService { Ok(()) } + async fn load_api_key_usage( + &self, + accounts: &[AccountPageItem], + ) -> Result, AdminError> { + let now = Utc::now(); + // API Key 没有套餐周期;本地累计直接查询账号创建后仍保留的请求记录。 + let windows = accounts + .iter() + .filter(|item| item.account.authentication_kind == "api_key") + .map(|item| AccountUsageWindowQuery { + account_id: item.account.id.clone(), + key: "account-lifetime".to_owned(), + range: TimeRange { + start: item.account.created_at, + end: now, + }, + }) + .collect::>(); + if windows.is_empty() { + return Ok(BTreeMap::new()); + } + Ok(self + .accounts + .load_account_usage_by_windows(&windows) + .await + .map_err(|error| map_store_error(error, "API Key account usage"))? + .into_iter() + .map(|result| (result.account_id, result.usage)) + .collect()) + } + async fn load_directory_item( &self, account_id: &ProviderAccountId, @@ -287,9 +325,15 @@ impl DefaultAccountsService { std::slice::from_mut(&mut quota), ) .await?; - let usage = quota - .usage_window() - .and_then(|(window, _)| window.local_usage.clone()); + let usage = self + .load_api_key_usage(std::slice::from_ref(&stored)) + .await? + .remove(&stored.account.id) + .or_else(|| { + quota + .usage_window() + .and_then(|(window, _)| window.local_usage.clone()) + }); Ok(AccountDirectoryItem { plan_type_display: self.providers.resolve_account_plan( stored.account.provider_kind.as_str(), @@ -377,14 +421,17 @@ impl AccountsService for DefaultAccountsService { .collect::, AdminError>>()?; self.attach_quota_local_usage(&page.items, &mut quotas) .await?; + let mut api_key_usage = self.load_api_key_usage(&page.items).await?; let items = page .items .into_iter() .zip(quotas) .map(|(mut item, quota)| { - let usage = quota - .usage_window() - .and_then(|(window, _)| window.local_usage.clone()); + let usage = api_key_usage.remove(&item.account.id).or_else(|| { + quota + .usage_window() + .and_then(|(window, _)| window.local_usage.clone()) + }); AccountDirectoryItem { plan_type_display: self.providers.resolve_account_plan( item.account.provider_kind.as_str(), @@ -581,6 +628,17 @@ impl AccountsService for DefaultAccountsService { Ok(result) } + async fn account_configuration( + &self, + account_id: &ProviderAccountId, + ) -> Result, AdminError> { + let (_, provider) = self.provider_for_account(account_id).await?; + provider + .account_configuration(account_id) + .await + .map_err(|error| map_provider_error(error, "provider account configuration")) + } + async fn quota( &self, account_id: &ProviderAccountId, diff --git a/backend/crates/gateway-admin/src/use_case/mod.rs b/backend/crates/gateway-admin/src/use_case/mod.rs index bad27c666..ad83960e4 100644 --- a/backend/crates/gateway-admin/src/use_case/mod.rs +++ b/backend/crates/gateway-admin/src/use_case/mod.rs @@ -345,12 +345,19 @@ async fn commit_authorization( async fn commit_credential_rotation( accounts: &dyn AccountStore, prepared: PreparedCredentialRotation, + settings: Option, context: &MutationContext, resource: &'static str, ) -> Result { let (facts, guard) = prepared.into_parts(); match accounts - .commit_credential_rotation(CredentialRotationCommit { prepared: facts }, context) + .commit_credential_rotation( + CredentialRotationCommit { + prepared: facts, + settings, + }, + context, + ) .await { Ok(result) => { @@ -372,7 +379,13 @@ async fn commit_credential_refresh( ) -> Result { let (facts, guard) = prepared.into_parts(); match accounts - .commit_credential_refresh(CredentialRotationCommit { prepared: facts }, context) + .commit_credential_refresh( + CredentialRotationCommit { + prepared: facts, + settings: None, + }, + context, + ) .await { Ok(result) => { diff --git a/backend/crates/gateway-admin/src/use_case/openai.rs b/backend/crates/gateway-admin/src/use_case/openai.rs index 3722e0bbb..bf2fd36c8 100644 --- a/backend/crates/gateway-admin/src/use_case/openai.rs +++ b/backend/crates/gateway-admin/src/use_case/openai.rs @@ -189,6 +189,17 @@ impl OpenAiService for DefaultOpenAiService { ) -> Result { let context = command.mutation.context; let account_id = command.mutation.account_id; + if command + .settings + .as_ref() + .is_some_and(|settings| settings.account_id != account_id.as_str()) + { + return Err(AdminError::invalid("凭据和账号设置的目标不一致")); + } + let disable_account = command + .settings + .as_ref() + .is_some_and(|settings| !settings.enabled); let details = required_credential( self.accounts.as_ref(), self.provider.provider_kind(), @@ -209,10 +220,14 @@ impl OpenAiService for DefaultOpenAiService { let result = commit_credential_rotation( self.accounts.as_ref(), prepared, + command.settings, &context, "OpenAI credential rotation", ) .await?; + if disable_account { + self.provider.account_unavailable(&account_id).await; + } self.provider .account_facts_changed(std::slice::from_ref(&result.account_id)) .await; diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index 23d2f82c5..4b15a3adc 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -1956,6 +1956,52 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_ ); } +#[tokio::test] +async fn api_key_list_and_detail_should_accumulate_local_usage_without_subscription_windows() { + let provider = FakeProviderAdmin::new("openai", events()); + let mut account = account_record("openai"); + account.authentication_kind = "api_key".to_owned(); + account.created_at = Utc::now() - TimeDelta::days(60); + let added_at = account.created_at; + let store = FakeAccountStore::with_account(account, events()); + store.set_quota_window_usage(vec![AccountUsageWindowResult { + account_id: "acct_test".to_owned(), + key: "account-lifetime".to_owned(), + usage: quota_local_usage("acct_test", 4_330_000), + }]); + let services = accounts_service(provider, store.clone()).await; + let page = services + .accounts() + .list(AccountListQuery { + page: 1, + page_size: gateway_admin::model::PageSize::new(20).unwrap(), + provider_kind: None, + group_filter: None, + search: None, + status: None, + sort: None, + }) + .await + .unwrap(); + assert!(page.items[0].quota.windows.is_empty()); + assert_eq!( + page.items[0].usage.as_ref().unwrap().total_tokens, + Some(4_330_000) + ); + let queries = store.quota_window_queries(); + assert_eq!(queries.len(), 1); + assert_eq!(queries[0].range.start, added_at); + assert!(queries[0].range.end > added_at + TimeDelta::days(59)); + let detail = services + .accounts() + .quota(&ProviderAccountId::new("acct_test").unwrap(), false) + .await + .unwrap(); + assert_eq!(detail.usage, page.items[0].usage); + assert!(detail.quota.windows.is_empty()); + assert_eq!(store.quota_window_queries()[0].range.start, added_at); +} + #[tokio::test] async fn accounts_list_should_attach_local_usage_to_quota_windows() { let provider = FakeProviderAdmin::new("openai", events()); diff --git a/backend/crates/gateway-api/src/admin/accounts/credentials.rs b/backend/crates/gateway-api/src/admin/accounts/credentials.rs index 11a8ecd83..382f9adec 100644 --- a/backend/crates/gateway-api/src/admin/accounts/credentials.rs +++ b/backend/crates/gateway-api/src/admin/accounts/credentials.rs @@ -319,9 +319,13 @@ impl AccountDeletionRequest { pub struct RotateAccountRequest { pub provider: String, pub account_id: String, - pub access_token: String, + pub access_token: Option, pub refresh_token: Option, pub id_token: Option, + pub base_url: Option, + pub api_key: Option, + pub transport: Option, + pub settings: Option, } impl RotateAccountRequest { @@ -330,11 +334,40 @@ impl RotateAccountRequest { return Err(WireValidationError::new("provider")); } require_account_id(&self.account_id, "accountId")?; - validate_oauth_material( - &self.access_token, - self.refresh_token.as_deref(), - self.id_token.as_deref(), - ) + if let Some(settings) = &self.settings { + settings.validate()?; + if settings.account_id != self.account_id { + return Err(WireValidationError::new("settings.accountId")); + } + } + if let Some(base_url) = &self.base_url { + if self.access_token.is_some() + || self.refresh_token.is_some() + || self.id_token.is_some() + || base_url.is_empty() + || base_url.len() > 2048 + || !matches!(self.transport.as_deref(), Some("http" | "prefer_websocket")) + || self.api_key.as_ref().is_some_and(|key| { + key.is_empty() + || key.len() > 16 * 1024 + || !key.bytes().all(|byte| byte.is_ascii_graphic()) + }) + { + return Err(WireValidationError::new("credential")); + } + Ok(()) + } else { + if self.api_key.is_some() || self.transport.is_some() { + return Err(WireValidationError::new("credential")); + } + validate_oauth_material( + self.access_token + .as_deref() + .ok_or_else(|| WireValidationError::new("accessToken"))?, + self.refresh_token.as_deref(), + self.id_token.as_deref(), + ) + } } pub(super) fn into_command( @@ -343,16 +376,34 @@ impl RotateAccountRequest { ) -> Result { self.validate()?; let mut material = Map::new(); - material.insert("access_token".to_owned(), Value::String(self.access_token)); - material.insert( - "refresh_token".to_owned(), - self.refresh_token.map_or(Value::Null, Value::String), - ); - material.insert( - "id_token".to_owned(), - self.id_token.map_or(Value::Null, Value::String), - ); + if let Some(base_url) = self.base_url { + material.insert("base_url".to_owned(), Value::String(base_url)); + material.insert( + "transport".to_owned(), + self.transport.map_or(Value::Null, Value::String), + ); + if let Some(key) = self.api_key { + material.insert("api_key".to_owned(), Value::String(key)); + } + } else { + material.insert( + "access_token".to_owned(), + self.access_token.map_or(Value::Null, Value::String), + ); + material.insert( + "refresh_token".to_owned(), + self.refresh_token.map_or(Value::Null, Value::String), + ); + material.insert( + "id_token".to_owned(), + self.id_token.map_or(Value::Null, Value::String), + ); + } Ok(RotateCredential { + settings: self + .settings + .map(UpdateAccountRequest::into_command) + .transpose()?, mutation: CredentialMutation { context, account_id: ProviderAccountId::new(self.account_id) diff --git a/backend/crates/gateway-api/src/admin/accounts/handlers.rs b/backend/crates/gateway-api/src/admin/accounts/handlers.rs index 38f9a70c6..aa50d8751 100644 --- a/backend/crates/gateway-api/src/admin/accounts/handlers.rs +++ b/backend/crates/gateway-api/src/admin/accounts/handlers.rs @@ -120,8 +120,15 @@ where .quota(&account_id, false) .await .map_err(map_service_error)?; - let data = AccountQuotaData { + let configuration = state + .admin_services() + .accounts() + .account_configuration(&account_id) + .await + .map_err(map_service_error)?; + let data = AccountDetailData { account: account_view(result, Utc::now()), + credential_configuration: configuration.map(provider_document_value), }; Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data))) } diff --git a/backend/crates/gateway-api/src/admin/accounts/presenter.rs b/backend/crates/gateway-api/src/admin/accounts/presenter.rs index 6bc5f5fd3..9bc6bb252 100644 --- a/backend/crates/gateway-api/src/admin/accounts/presenter.rs +++ b/backend/crates/gateway-api/src/admin/accounts/presenter.rs @@ -71,6 +71,10 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime) -> Ac let added_at = china_rfc3339(&account.created_at); let updated_at = china_rfc3339(&account.updated_at); let usage_period = quota.usage_window().map(|(_, period)| period); + let mut usage = account_usage_view(usage, usage_period, now); + if account.authentication_kind == "api_key" { + usage.window_label_display = "通用额度".to_owned(); + } let (quota, refresh_token_expires_at) = account_quota_view(quota, rate_limited_until, now); AccountView { id: account.id.clone(), @@ -121,7 +125,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime) -> Ac updated_at, updated_at_display: china_datetime(&account.updated_at), quota, - usage: account_usage_view(usage, usage_period, now), + usage, } } diff --git a/backend/crates/gateway-api/src/admin/accounts/wire.rs b/backend/crates/gateway-api/src/admin/accounts/wire.rs index ce8f84a71..e90ca705b 100644 --- a/backend/crates/gateway-api/src/admin/accounts/wire.rs +++ b/backend/crates/gateway-api/src/admin/accounts/wire.rs @@ -684,6 +684,14 @@ pub struct AccountQuotaData { pub account: AccountView, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountDetailData { + pub account: AccountView, + #[serde(skip_serializing_if = "Option::is_none")] + pub credential_configuration: Option, +} + /// 个人资料、累计统计与订阅的统一响应。 #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] diff --git a/backend/crates/gateway-api/tests/admin/accounts/mod.rs b/backend/crates/gateway-api/tests/admin/accounts/mod.rs index 20892cb08..d9bbb19b7 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/mod.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/mod.rs @@ -599,6 +599,44 @@ mod actions { ); } + #[test] + fn api_key_rotation_settings_must_target_the_same_account_and_remain_valid() { + let mut request = json!({ + "provider": "openai", + "accountId": "acct_api", + "baseUrl": "https://api.example.invalid/v1", + "transport": "http", + "settings": { + "accountId": "acct_api", + "enabled": true, + "concurrencyLimit": null, + "weight": 1, + "groupIds": [] + } + }); + serde_json::from_value::(request.clone()) + .expect("decode combined save") + .validate() + .expect("blank replacement key preserves the existing key"); + request["settings"]["accountId"] = json!("acct_other"); + assert_eq!( + serde_json::from_value::(request.clone()) + .unwrap() + .validate() + .unwrap_err() + .field(), + "settings.accountId" + ); + request["settings"]["accountId"] = json!("acct_api"); + request["settings"]["concurrencyLimit"] = json!(0); + assert!( + serde_json::from_value::(request) + .unwrap() + .validate() + .is_err() + ); + } + #[test] fn credential_recovery_requests_should_not_accept_client_revision_fences() { let authorization: StartAccountAuthorizationRequest = serde_json::from_value(json!({ diff --git a/backend/crates/gateway-core/src/account/model.rs b/backend/crates/gateway-core/src/account/model.rs index d05c16da9..45e1d8ea9 100644 --- a/backend/crates/gateway-core/src/account/model.rs +++ b/backend/crates/gateway-core/src/account/model.rs @@ -762,11 +762,8 @@ impl ProviderAccount { last_error_message: Option, ) -> Self { self.enabled = enabled; - self.credential_state = if self.upstream_user_id.is_some() { - credential_state - } else { - CredentialState::Unknown - }; + // 凭据是否可用由 Provider 判断;API Key 等认证不要求上游用户身份。 + self.credential_state = credential_state; self.quota = quota; self.last_error_reason = last_error_reason; self.last_error_message = last_error_message; diff --git a/backend/crates/gateway-core/src/engine/execution.rs b/backend/crates/gateway-core/src/engine/execution.rs index c75152edc..2b1c90b91 100644 --- a/backend/crates/gateway-core/src/engine/execution.rs +++ b/backend/crates/gateway-core/src/engine/execution.rs @@ -1051,10 +1051,7 @@ impl ExecutionService for DefaultExecutionService { continue; } if seen.insert(model.clone()) { - result.push(PublicModelDescriptor::Native { - model, - payload: source.payload.clone(), - }); + result.push(source.content.for_public_model(model)); } } for model in client.snapshot.public_models_for_provider(kind) { @@ -1073,10 +1070,7 @@ impl ExecutionService for DefaultExecutionService { } if let Some(source) = by_id.get(target.as_str()) { seen.insert(model.clone()); - result.push(PublicModelDescriptor::Native { - model, - payload: source.payload.clone(), - }); + result.push(source.content.for_public_model(model)); } } } diff --git a/backend/crates/gateway-core/src/routing/catalog.rs b/backend/crates/gateway-core/src/routing/catalog.rs index bfbeb220c..d419c7a6a 100644 --- a/backend/crates/gateway-core/src/routing/catalog.rs +++ b/backend/crates/gateway-core/src/routing/catalog.rs @@ -11,14 +11,20 @@ use super::{ ModelCapabilities, ModelPresentation, PublicModelId, PublicModelProfile, UpstreamModelId, }; -/// Provider 原生客户端目录条目;Core 只解释模型标识,不解释协议正文。 +/// Provider 客户端目录条目;缺少原生协议正文时使用已编译的通用画像。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProviderModelDescriptor { pub model: UpstreamModelId, - pub payload: RawJsonPayload, + pub content: ProviderModelContent, } -/// 对外目录保留原生正文;只有没有原生目录的 Provider 才使用通用画像适配。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProviderModelContent { + Native(RawJsonPayload), + Adapted(ModelPresentation), +} + +/// 对外目录按条目保留原生正文或使用通用画像适配。 #[derive(Debug, Clone)] pub enum PublicModelDescriptor { Native { @@ -28,6 +34,20 @@ pub enum PublicModelDescriptor { Adapted(PublicModelProfile), } +impl ProviderModelContent { + pub(crate) fn for_public_model(&self, model: PublicModelId) -> PublicModelDescriptor { + match self { + Self::Native(payload) => PublicModelDescriptor::Native { + model, + payload: payload.clone(), + }, + Self::Adapted(presentation) => { + PublicModelDescriptor::Adapted(PublicModelProfile::new(model, presentation.clone())) + } + } + } +} + /// Provider 实时目录编译后的单模型能力。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProviderModelCapabilities { diff --git a/backend/crates/gateway-core/src/routing/mod.rs b/backend/crates/gateway-core/src/routing/mod.rs index e2c32969c..1ed4a9b78 100644 --- a/backend/crates/gateway-core/src/routing/mod.rs +++ b/backend/crates/gateway-core/src/routing/mod.rs @@ -10,7 +10,8 @@ pub use crate::account::scope::{ pub use crate::identity::ProviderKind; pub use catalog::{ ProviderCatalogGeneration, ProviderCatalogPort, ProviderCatalogUnavailable, - ProviderModelCapabilities, ProviderModelDescriptor, PublicModelDescriptor, + ProviderModelCapabilities, ProviderModelContent, ProviderModelDescriptor, + PublicModelDescriptor, }; pub use snapshot::RuntimeSnapshot; diff --git a/backend/crates/gateway-core/tests/account/mod.rs b/backend/crates/gateway-core/tests/account/mod.rs index b2fc1957b..c92bece3c 100644 --- a/backend/crates/gateway-core/tests/account/mod.rs +++ b/backend/crates/gateway-core/tests/account/mod.rs @@ -323,13 +323,13 @@ fn rate_limited_projection_carries_only_the_active_cooldown_deadline() { } #[test] -fn account_without_upstream_identity_should_stay_unknown_and_not_schedulable() { +fn account_without_upstream_identity_preserves_provider_credential_state() { let account = ProviderAccount::new( ProviderAccountId::new("acct_pending_identity").expect("valid account"), ProviderKind::new("openai").expect("valid provider"), "pending identity".to_owned(), None, - "oauth".to_owned(), + "api_key".to_owned(), CredentialRevision::new(1).expect("valid revision"), Some(SystemTime::now() + Duration::from_secs(3600)), ) @@ -346,7 +346,18 @@ fn account_without_upstream_identity_should_stay_unknown_and_not_schedulable() { account.credential_state(), account.status_projection(SystemTime::now(), None).status ), - (CredentialState::Unknown, AccountStatus::Error) + (CredentialState::Ready, AccountStatus::Normal) + ); + let pending = account.with_account_facts( + true, + CredentialState::Unknown, + QuotaState::unknown(), + None, + None, + ); + assert_eq!( + pending.status_projection(SystemTime::now(), None).status, + AccountStatus::Error ); } diff --git a/backend/crates/gateway-core/tests/engine/execution.rs b/backend/crates/gateway-core/tests/engine/execution.rs index 149d679a2..ab4fd35e4 100644 --- a/backend/crates/gateway-core/tests/engine/execution.rs +++ b/backend/crates/gateway-core/tests/engine/execution.rs @@ -1045,13 +1045,15 @@ impl Provider for NativeCatalogProvider { .into_iter() .map(|id| gateway_core::routing::ProviderModelDescriptor { model: UpstreamModelId::new(id).expect("id"), - payload: RawJsonPayload::new( - "codex", - serde_json::to_vec(&json!({"slug":id,"future":null})) - .expect("JSON") - .into(), - ) - .expect("payload"), + content: gateway_core::routing::ProviderModelContent::Native( + RawJsonPayload::new( + "codex", + serde_json::to_vec(&json!({"slug":id,"future":null})) + .expect("JSON") + .into(), + ) + .expect("payload"), + ), }) .collect(), )) diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs index d8a7602de..7f35687c4 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs @@ -281,6 +281,7 @@ impl PgAdminAccountStore { async fn commit_prepared_rotation( &self, prepared: PreparedCredentialRotationFacts, + settings: Option, context: &MutationContext, action: &str, ) -> AdminStoreResult { @@ -288,9 +289,24 @@ impl PgAdminAccountStore { let scope = ProviderAccountAdminScope { provider_kind: prepared.provider_kind.as_str().to_owned(), }; + let mut changed_fields = vec!["credentials".to_owned()]; + if let Some(settings) = &settings { + changed_fields + .extend(["enabled", "concurrency_limit", "weight", "groups"].map(str::to_owned)); + if settings.model_access.is_some() { + changed_fields.push("model_access".to_owned()); + } + if settings.outbound_proxy.is_some() { + changed_fields.push("outbound_proxy".to_owned()); + } + if settings.notes.is_some() { + changed_fields.push("notes".to_owned()); + } + } let rotation = self .accounts .rotate_provider_account(RotateProviderAccount { + settings, scope, profile: UpdateProviderAccount { id: account_id.as_str().to_owned(), @@ -313,7 +329,7 @@ impl PgAdminAccountStore { action, "provider_account", account_id.as_str(), - vec!["credentials".to_owned()], + changed_fields, ), }) .await @@ -637,7 +653,7 @@ impl AccountStore for PgAdminAccountStore { "reauthorization cannot change account settings", )); } - self.commit_prepared_rotation(prepared, context, "reauthorize") + self.commit_prepared_rotation(prepared, None, context, "reauthorize") .await } } @@ -648,8 +664,13 @@ impl AccountStore for PgAdminAccountStore { command: CredentialRotationCommit, context: &MutationContext, ) -> AdminStoreResult { - self.commit_prepared_rotation(command.prepared, context, "rotate_credential") - .await + self.commit_prepared_rotation( + command.prepared, + command.settings, + context, + "rotate_credential", + ) + .await } async fn commit_credential_refresh( @@ -657,7 +678,14 @@ impl AccountStore for PgAdminAccountStore { command: CredentialRotationCommit, context: &MutationContext, ) -> AdminStoreResult { - self.commit_prepared_rotation(command.prepared, context, "refresh_credential") + if command.settings.is_some() { + return Err(AdminStoreError::new( + AdminStoreErrorKind::Invalid, + ENTITY, + "credential refresh cannot change account settings", + )); + } + self.commit_prepared_rotation(command.prepared, None, context, "refresh_credential") .await } diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/core_adapter.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/core_adapter.rs index f944a57e5..2a07e2644 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/core_adapter.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/core_adapter.rs @@ -184,16 +184,16 @@ impl ProviderAccountStore for PgProviderAccountRepository { has_refresh_token = $7, access_token_expires_at = $8, next_refresh_at = $9, credential_state = case - when $10::text is not null and enabled and upstream_user_id is not null + when $10::text is not null and enabled then $10 else credential_state end, credential_observed_at = case - when $10::text is not null and enabled and upstream_user_id is not null + when $10::text is not null and enabled then $11 else credential_observed_at end, last_error_reason = case - when $10::text is not null and enabled and upstream_user_id is not null + when $10::text is not null and enabled then $12 else last_error_reason end, last_error_message = case - when $10::text is not null and enabled and upstream_user_id is not null + when $10::text is not null and enabled then $13 else last_error_message end, updated_at = greatest(now(), coalesce($11, now())) where id = $1 and credential_revision = $2 diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs index 00072c911..5663967fb 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs @@ -128,11 +128,7 @@ impl ProviderAccountRepository for PgProviderAccountRepository { async fn insert_provider_account(&self, account: NewProviderAccount) -> StoreResult<()> { account.validate()?; - let credential_state = if account.upstream_user_id.is_some() { - account.credential_state - } else { - CredentialState::Unknown - }; + let credential_state = account.credential_state; let mut transaction = self .pool .begin() @@ -255,19 +251,19 @@ impl ProviderAccountRepository for PgProviderAccountRepository { let result = sqlx::query( "update provider_accounts set credential_state = case - when enabled and upstream_user_id is not null then $3 + when enabled then $3 else credential_state end, credential_observed_at = case - when enabled and upstream_user_id is not null then $4 + when enabled then $4 else credential_observed_at end, last_error_reason = case - when enabled and upstream_user_id is not null then $5 + when enabled then $5 else last_error_reason end, last_error_message = case - when enabled and upstream_user_id is not null then $6 + when enabled then $6 else last_error_message end, updated_at = case when enabled then greatest(now(), $4) else updated_at end @@ -543,6 +539,14 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository { return Err(invalid("rotated profile and credential account IDs differ")); } validate_credential_update(&command.credential)?; + if let Some(settings) = &command.settings { + if settings.account_id != command.profile.id { + return Err(invalid( + "rotated credential and account settings IDs differ", + )); + } + validate_batch_update_group_ids(&settings.group_ids)?; + } let mut transaction = self .pool .begin() @@ -558,6 +562,30 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository { &command.credential, ) .await?; + // 凭据 CAS、普通设置和审计共享事务,任何设置失败都回滚凭据更新。 + if let Some(settings) = &command.settings { + let ids = std::slice::from_ref(&settings.account_id); + update_provider_accounts_scheduling_in_transaction( + &mut transaction, + ids, + Some(settings.enabled), + Some(settings.concurrency_limit), + Some(settings.weight), + settings.model_access.as_ref(), + settings.outbound_proxy.as_ref(), + ) + .await?; + replace_account_group_assignments_in_transaction( + &mut transaction, + ids, + &settings.group_ids, + ) + .await?; + if let Some(notes) = settings.notes.as_deref() { + update_provider_account_notes_in_transaction(&mut transaction, ids, notes) + .await?; + } + } append_admin_audit_event_in_transaction( &mut transaction, command.audit, @@ -637,7 +665,7 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository { let recovered = sqlx::query_scalar::<_, String>( "update provider_accounts set enabled = true, - credential_state = 'ready', + credential_state = case when credential_state = 'unknown' then 'unknown' else 'ready' end, credential_observed_at = now(), access_token_expires_at = case when access_token_expires_at <= now() then null @@ -767,11 +795,7 @@ pub(crate) async fn upsert_provider_account_in_transaction( account: &NewProviderAccount, ) -> StoreResult { account.validate()?; - let credential_state = if account.upstream_user_id.is_some() { - account.credential_state - } else { - CredentialState::Unknown - }; + let credential_state = account.credential_state; let proxy_id = match account.outbound_proxy.as_ref() { Some(proxy) => Some( super::super::proxies::ensure_proxy_for_url(transaction, proxy, None) @@ -877,6 +901,7 @@ pub(crate) async fn rotate_provider_account_in_transaction( let upstream_user_id = replacement_identity.map(ProviderAccountIdentity::upstream_user_id); let upstream_account_id = replacement_identity.and_then(ProviderAccountIdentity::upstream_account_id); + // 事务时间可能早于应用写入的额度观测,保留既有时间下界,避免轮换破坏时间约束。 let next = sqlx::query_scalar::<_, i64>( "update provider_accounts set name = $4, @@ -891,7 +916,7 @@ pub(crate) async fn rotate_provider_account_in_transaction( upstream_account_id = case when $11::boolean then $13::text else upstream_account_id end, credential_state = case when not enabled then credential_state - when coalesce($12::text, upstream_user_id) is not null then 'ready' + when $11::boolean or credential_state <> 'unknown' then 'ready' else 'unknown' end, credential_observed_at = case @@ -900,7 +925,7 @@ pub(crate) async fn rotate_provider_account_in_transaction( end, last_error_reason = case when enabled then null else last_error_reason end, last_error_message = case when enabled then null else last_error_message end, - updated_at = now() + updated_at = greatest(now(), updated_at) where id = $1 and provider_kind = $2 and credential_revision = $3 returning credential_revision", diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs index 832c3fc07..aad7af4c5 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs @@ -295,6 +295,7 @@ impl ImportProviderAccounts { #[derive(Clone)] pub struct RotateProviderAccount { + pub settings: Option, pub scope: ProviderAccountAdminScope, pub profile: UpdateProviderAccount, pub replacement_identity: Option, @@ -306,6 +307,7 @@ impl fmt::Debug for RotateProviderAccount { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("RotateProviderAccount") + .field("settings", &self.settings) .field("scope", &self.scope) .field("profile", &self.profile) .field("replacement_identity", &self.replacement_identity) diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs index 38b37ac0d..15a5b449e 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs @@ -164,6 +164,7 @@ async fn provider_account_should_allow_missing_upstream_user_id() { let mut pending = account("acct_pending_identity", "unused"); pending.upstream_user_id = None; pending.email = None; + pending.authentication_kind = "api_key".to_owned(); pending.credential_state = CredentialState::Ready; repository .insert_provider_account(pending) @@ -180,7 +181,7 @@ async fn provider_account_should_allow_missing_upstream_user_id() { stored.summary.upstream_user_id, stored.summary.credential_state, ), - (None, CredentialState::Unknown) + (None, CredentialState::Ready) ); database.close().await; @@ -2198,6 +2199,7 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() { }; let wrong_scope_error = repository .rotate_provider_account(RotateProviderAccount { + settings: None, scope: wrong_scope, profile: profile("acct_admin_a", "wrong scope"), replacement_identity: None, @@ -2227,6 +2229,7 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() { let rotation = repository .rotate_provider_account(RotateProviderAccount { + settings: None, scope: scope.clone(), profile: profile("acct_admin_a", "rotated account"), replacement_identity: Some(ProviderAccountIdentity::new( @@ -2259,6 +2262,7 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() { let identity_conflict = repository .rotate_provider_account(RotateProviderAccount { + settings: None, scope: scope.clone(), profile: profile("acct_admin_a", "must roll back"), replacement_identity: Some(ProviderAccountIdentity::new( @@ -2343,6 +2347,137 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() { database.close().await; } +#[tokio::test] +async fn credential_rotation_and_settings_share_one_transaction() { + use gateway_admin::model::proxies::AccountProxySelection; + use gateway_core::account::{AccountConcurrencyLimit, AccountWeight}; + + const ACCOUNT_ID: &str = "acct_combined_save"; + const GROUP_ID: &str = "grp_00000000000000000000000000000091"; + let Some(database) = TestDatabase::create("combined_account_save").await else { + return; + }; + let repository = PgProviderAccountRepository::new(database.pool.clone()); + repository + .insert_provider_account(account(ACCOUNT_ID, "combined-save-user")) + .await + .expect("seed account"); + sqlx::query( + "insert into account_groups (id, name, color, created_at, updated_at) + values ($1, 'Combined save', '#2563EBFF', now(), now())", + ) + .bind(GROUP_ID) + .execute(&database.pool) + .await + .expect("seed group"); + let settings = UpdateAccount { + account_id: ACCOUNT_ID.to_owned(), + notes: Some("统一保存".to_owned()), + enabled: false, + concurrency_limit: Some(AccountConcurrencyLimit::new(3).unwrap()), + weight: AccountWeight::new(7).unwrap(), + group_ids: vec![AccountGroupId::new(GROUP_ID).unwrap()], + model_access: None, + outbound_proxy: Some(AccountProxySelection::Direct), + }; + let scope = ProviderAccountAdminScope { + provider_kind: "openai".to_owned(), + }; + let result = repository + .rotate_provider_account(RotateProviderAccount { + scope: scope.clone(), + profile: profile(ACCOUNT_ID, "combined save"), + replacement_identity: None, + credential: credential_update(ACCOUNT_ID, 1, "combined-secret"), + settings: Some(settings.clone()), + audit: audit("audit_combined_save", "rotate", ACCOUNT_ID), + }) + .await + .expect("save credentials and settings"); + assert_eq!(result.config_revision.get(), 2); + assert_eq!(result.credential_revision.get(), 2); + let before: serde_json::Value = + sqlx::query_scalar("select to_jsonb(account) from provider_accounts account where id = $1") + .bind(ACCOUNT_ID) + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!( + before["provider_credentials_json"]["access_token"], + "combined-secret" + ); + assert_eq!(before["enabled"], false); + assert_eq!(before["concurrency_limit"], 3); + assert_eq!(before["weight"], 7); + assert_eq!(before["notes"], "统一保存"); + assert_eq!( + account_group_ids(&database.pool, ACCOUNT_ID).await, + [GROUP_ID] + ); + + for (case, expected_revision, invalid_settings) in [ + ( + "missing_group", + 2, + UpdateAccount { + enabled: true, + group_ids: vec![ + AccountGroupId::new("grp_00000000000000000000000000000092").unwrap(), + ], + ..settings.clone() + }, + ), + ( + "missing_proxy", + 2, + UpdateAccount { + outbound_proxy: Some(AccountProxySelection::Saved("missing_proxy".to_owned())), + ..settings.clone() + }, + ), + ( + "stale_credential", + 1, + UpdateAccount { + enabled: true, + notes: Some("must not persist".to_owned()), + ..settings.clone() + }, + ), + ] { + repository + .rotate_provider_account(RotateProviderAccount { + scope: scope.clone(), + profile: profile(ACCOUNT_ID, "must not persist"), + replacement_identity: None, + credential: credential_update(ACCOUNT_ID, expected_revision, "must-not-persist"), + settings: Some(invalid_settings), + audit: audit(&format!("audit_{case}"), "rotate", ACCOUNT_ID), + }) + .await + .expect_err("failed combined save must roll back all mutations"); + let after: serde_json::Value = sqlx::query_scalar( + "select to_jsonb(account) from provider_accounts account where id = $1", + ) + .bind(ACCOUNT_ID) + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(after, before, "{case}"); + assert_eq!( + account_group_ids(&database.pool, ACCOUNT_ID).await, + [GROUP_ID] + ); + assert_eq!(current_revision(&database.pool).await, 2); + let audit_count: i64 = sqlx::query_scalar("select count(*) from admin_audit_events") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(audit_count, 1, "{case}"); + } + database.close().await; +} + #[tokio::test] async fn provider_account_import_and_reauthorization_preserve_existing_memberships() { const GROUP_ID: &str = "grp_00000000000000000000000000000091"; @@ -2422,6 +2557,7 @@ async fn provider_account_import_and_reauthorization_preserve_existing_membershi let revision_before_reauthorization = current_revision(&database.pool).await; let reauthorized = repository .rotate_provider_account(RotateProviderAccount { + settings: None, scope, profile: profile("acct_grouped_import", "reauthorized"), replacement_identity: None, @@ -2480,7 +2616,8 @@ async fn verified_credential_rotation_preserves_quota_exhaustion() { }) .await .expect("import exhausted account"); - let observed_at = SystemTime::now(); + // 模拟应用与数据库的微小时钟偏差,轮换不能把更新时间倒退到已有额度观测之前。 + let observed_at = SystemTime::now() + Duration::from_secs(3); repository .apply_quota_access(QuotaAccessChange { account_id: ProviderAccountId::new("acct_rotation_quota").expect("account ID"), @@ -2492,6 +2629,7 @@ async fn verified_credential_rotation_preserves_quota_exhaustion() { repository .rotate_provider_account(RotateProviderAccount { + settings: None, scope, profile: profile("acct_rotation_quota", "reauthorized account"), replacement_identity: None, @@ -2637,6 +2775,7 @@ async fn disabled_account_preserves_user_state_during_refresh_writes() { .expect("disabled state write is a no-op"); repository .rotate_provider_account(RotateProviderAccount { + settings: None, scope, profile: profile(account_id.as_str(), "refreshed disabled account"), replacement_identity: None, diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index d18db1d58..ebfb0b970 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -328,6 +328,10 @@ impl ProviderAdmin for OpenAiAdminProvider { if pending.provider_kind() != &self.provider_kind { return Err(provider_admin_error(ProviderAdminErrorKind::Invalid)); } + if let gateway_admin::model::provider_credentials::AuthorizationMutationTarget::Reauthorize { account_id } = pending.target() + && self.account(account_id).await?.authentication_kind() != crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH { + return Err(provider_admin_error(ProviderAdminErrorKind::Unsupported)); + } let started = self .oauth .start_authorization(StartCodexOAuthAuthorization { mutation: pending }) @@ -412,6 +416,17 @@ impl ProviderAdmin for OpenAiAdminProvider { return Err(provider_admin_error(ProviderAdminErrorKind::Conflict) .with_public_message("账号凭据已被更新,请刷新账号列表后重试")); } + if current.account.authentication_kind() + == crate::credential::CODEX_AUTHENTICATION_KIND_API_KEY + { + let prepared = CodexCredentialAdmin + .prepare_api_key_rotation( + current, + Value::Object(command.provider_material.into_provider_data().into_inner()), + ) + .map_err(map_credential_admin_error)?; + return prepared_rotation(prepared, command.account.provider_kind); + } let mut secret = rotation_secret(command.provider_material)?; if secret.id_token.is_none() { let runtime = CodexCredentialCodec::decode(¤t.credential) @@ -433,6 +448,10 @@ impl ProviderAdmin for OpenAiAdminProvider { &self, command: PrepareCredentialRefresh, ) -> Result { + if command.account.authentication_kind != crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH + { + return Err(provider_admin_error(ProviderAdminErrorKind::Unsupported)); + } validate_account_record(&command.account, &self.provider_kind)?; let account_id = ProviderAccountId::new(command.account.id.clone()) .map_err(|_| provider_admin_error(ProviderAdminErrorKind::Invalid))?; @@ -453,6 +472,36 @@ impl ProviderAdmin for OpenAiAdminProvider { prepared_rotation(prepared, command.account.provider_kind) } + async fn account_configuration( + &self, + account_id: &ProviderAccountId, + ) -> Result, ProviderAdminError> { + let account = self.account(account_id).await?; + if account.authentication_kind() != crate::credential::CODEX_AUTHENTICATION_KIND_API_KEY { + return Ok(None); + } + let current = self + .accounts + .load_current_credential(account_id) + .await + .map_err(map_store_error)?; + let crate::credential::CodexCredentialData::ApiKey(data) = + CodexCredentialCodec::decode_complete(¤t.credential) + .map_err(|_| provider_admin_error(ProviderAdminErrorKind::Invalid))? + else { + return Err(provider_admin_error(ProviderAdminErrorKind::Invalid)); + }; + let value = serde_json::to_value(data.configuration()) + .map_err(|_| provider_admin_error(ProviderAdminErrorKind::Internal))?; + let object = value + .as_object() + .cloned() + .ok_or_else(|| provider_admin_error(ProviderAdminErrorKind::Internal))?; + Ok(Some(ProviderDocument::new( + gateway_core::account::OpaqueProviderData::new(object), + ))) + } + async fn quota( &self, request: ProviderQuotaRequest, @@ -463,6 +512,12 @@ impl ProviderAdmin for OpenAiAdminProvider { rolling_usage: _, } = request; let mut account = self.account(&account_id).await?; + if account.authentication_kind() == crate::credential::CODEX_AUTHENTICATION_KIND_API_KEY { + if refresh { + return Err(provider_admin_error(ProviderAdminErrorKind::Unsupported)); + } + return Ok(project_quota(None, &account)); + } let snapshot = if refresh { let snapshot = Some( self.quota @@ -517,7 +572,11 @@ impl ProviderAdmin for OpenAiAdminProvider { &self, account_id: &ProviderAccountId, ) -> Result, ProviderAdminError> { - self.account(account_id).await?; + if self.account(account_id).await?.authentication_kind() + != crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH + { + return Err(provider_admin_error(ProviderAdminErrorKind::Unsupported)); + } self.profile_statistics .subscription(account_id) .await @@ -538,7 +597,11 @@ impl ProviderAdmin for OpenAiAdminProvider { &self, account_id: &ProviderAccountId, ) -> Result { - self.account(account_id).await?; + if self.account(account_id).await?.authentication_kind() + != crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH + { + return Err(provider_admin_error(ProviderAdminErrorKind::Unsupported)); + } let statistics = self .profile_statistics .profile_statistics(account_id) @@ -551,7 +614,11 @@ impl ProviderAdmin for OpenAiAdminProvider { &self, account_id: &ProviderAccountId, ) -> Result { - self.account(account_id).await?; + if self.account(account_id).await?.authentication_kind() + != crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH + { + return Err(provider_admin_error(ProviderAdminErrorKind::Unsupported)); + } self.profile_statistics .profile_avatar(account_id) .await @@ -563,7 +630,11 @@ impl ProviderAdmin for OpenAiAdminProvider { &self, account_id: &ProviderAccountId, ) -> Result { - self.account(account_id).await?; + if self.account(account_id).await?.authentication_kind() + != crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH + { + return Err(provider_admin_error(ProviderAdminErrorKind::Unsupported)); + } self.quota .list_reset_credits(account_id) .await @@ -575,7 +646,14 @@ impl ProviderAdmin for OpenAiAdminProvider { &self, command: ConsumeProviderResetCredit, ) -> Result { - self.account(&command.account_id).await?; + if self + .account(&command.account_id) + .await? + .authentication_kind() + != crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH + { + return Err(provider_admin_error(ProviderAdminErrorKind::Unsupported)); + } self.quota .consume_reset_credit( &command.account_id, diff --git a/backend/crates/providers/openai/src/config.rs b/backend/crates/providers/openai/src/config.rs index f84f48a9c..9a62a4a2a 100644 --- a/backend/crates/providers/openai/src/config.rs +++ b/backend/crates/providers/openai/src/config.rs @@ -5,7 +5,7 @@ use std::time::Duration; use chrono::{DateTime, Utc}; use serde::Deserialize; -use url::{Host, Url}; +use url::Url; use crate::credential::CodexQuotaRefreshPolicy; use crate::transport::profile::{ @@ -146,24 +146,7 @@ impl Default for CodexApiConfig { impl CodexApiConfig { fn validate(&self) -> Result<(), OpenAiConfigError> { - let url = Url::parse(&self.base_url) - .map_err(|_| OpenAiConfigError::InvalidField("openai.api.base_url"))?; - // 上游地址只接受 https;明文 http 仅放行本机回环(本地联调), - // 避免把上游指向内网明文服务。凭据/查询串会污染端点拼接,一并拒绝。 - let is_loopback_host = match url.host() { - Some(Host::Domain("localhost")) => true, - Some(Host::Ipv4(address)) => address.is_loopback(), - Some(Host::Ipv6(address)) => address.is_loopback(), - Some(Host::Domain(_)) | None => false, - }; - let is_loopback_http = url.scheme() == "http" && is_loopback_host; - let is_secure_public = url.scheme() == "https" && url.host_str().is_some(); - if !(is_loopback_http || is_secure_public) - || !url.username().is_empty() - || url.password().is_some() - || url.query().is_some() - || url.fragment().is_some() - { + if !crate::transport::valid_upstream_base_url(&self.base_url) { return Err(OpenAiConfigError::InvalidField("openai.api.base_url")); } Ok(()) diff --git a/backend/crates/providers/openai/src/credential/admin.rs b/backend/crates/providers/openai/src/credential/admin.rs index a1bb9a2e9..4f1651cd8 100644 --- a/backend/crates/providers/openai/src/credential/admin.rs +++ b/backend/crates/providers/openai/src/credential/admin.rs @@ -24,6 +24,7 @@ use serde::Serialize; use serde_json::Value; use thiserror::Error; +use super::api_key::{ApiKeyCredentialData, ApiKeyTransport, CODEX_AUTHENTICATION_KIND_API_KEY}; use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery}; use super::security::CodexCredentialCodec; use super::token_client::{ @@ -128,13 +129,19 @@ struct ParsedCodexImportAccount { outbound_proxy: Option, } -struct ParsedCodexAuthentication { +#[derive(Debug)] +enum ParsedCodexAuthentication { + OAuth(ParsedOAuthAuthentication), + ApiKey(ApiKeyCredentialData), +} + +struct ParsedOAuthAuthentication { access_token: Option, refresh_token: Option, id_token: Option, } -impl fmt::Debug for ParsedCodexAuthentication { +impl fmt::Debug for ParsedOAuthAuthentication { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("OAuth") @@ -188,7 +195,7 @@ impl fmt::Debug for ExportManagedCodexCredential { #[serde(rename_all = "camelCase")] pub struct CodexCprExportDocument { source_format: &'static str, - accounts: Vec, + accounts: Vec, } impl CodexCprExportDocument { @@ -235,6 +242,24 @@ struct CodexCprExportCommon { outbound_proxy_url: Option, } +#[derive(Serialize)] +#[serde(untagged)] +enum CodexCprExportAccount { + OAuth(CodexCprOAuthExportAccount), + ApiKey(CodexCprApiKeyExportAccount), +} + +#[derive(Serialize)] +struct CodexCprApiKeyExportAccount { + #[serde(flatten)] + common: CodexCprExportCommon, + provider: &'static str, + authentication_kind: &'static str, + base_url: String, + api_key: String, + transport: ApiKeyTransport, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct CodexCprOAuthExportAccount { @@ -383,6 +408,94 @@ impl CodexCredentialAdminError { pub struct CodexCredentialAdmin; impl CodexCredentialAdmin { + fn prepare_api_key( + &self, + account_id: String, + name: String, + data: ApiKeyCredentialData, + ) -> Result { + if name.trim().is_empty() { + return Err(CodexCredentialAdminError::InvalidInput); + } + let credential = CodexCredentialCodec::encode_complete(CodexCredentialData::ApiKey(data)) + .map_err(|_| CodexCredentialAdminError::InvalidCredential)?; + let account = ProviderAccount::new( + ProviderAccountId::new(account_id) + .map_err(|_| CodexCredentialAdminError::InvalidInput)?, + ProviderKind::new(PROVIDER_NAME) + .map_err(|_| CodexCredentialAdminError::InvalidInput)?, + name, + None, + CODEX_AUTHENTICATION_KIND_API_KEY.to_owned(), + CredentialRevision::new(1).map_err(|_| CodexCredentialAdminError::InvalidCredential)?, + None, + ) + .with_account_facts( + true, + CredentialState::Ready, + QuotaState::unknown(), + None, + None, + ); + Ok(NewProviderAccount { + account, + credential, + model_access: None, + }) + } + + pub(crate) fn prepare_api_key_rotation( + &self, + current: LoadedCredential, + material: Value, + ) -> Result { + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct Rotation { + base_url: String, + transport: ApiKeyTransport, + api_key: Option, + } + let rotation: Rotation = serde_json::from_value(material) + .map_err(|_| CodexCredentialAdminError::InvalidInput)?; + let CodexCredentialData::ApiKey(mut data) = + CodexCredentialCodec::decode_complete(¤t.credential) + .map_err(|_| CodexCredentialAdminError::InvalidCredential)? + else { + return Err(CodexCredentialAdminError::InvalidCredential); + }; + data.base_url = rotation.base_url; + data.transport = rotation.transport; + if let Some(api_key) = rotation.api_key { + data.api_key = api_key; + } + let credential = CodexCredentialCodec::encode_complete(CodexCredentialData::ApiKey(data)) + .map_err(|_| CodexCredentialAdminError::InvalidCredential)?; + let profile = ProviderAccountUpdate { + account_id: current.account.id().clone(), + name: current.account.name().to_owned(), + email: None, + plan_type: None, + }; + let credential = CredentialCasUpdate::new( + current.account.id().clone(), + current.account.revision(), + profile.clone(), + credential, + false, + None, + None, + ) + .map_err(|_| CodexCredentialAdminError::InvalidCredential)? + .with_account_state(CredentialState::Ready, SystemTime::now(), None, None); + Ok(PreparedCodexCredentialRotation { + profile, + credential, + replacement_identity: None, + refresh_guards: None, + }) + } + pub fn prepare_import( &self, input: ImportCodexOAuthCredential, @@ -530,13 +643,27 @@ impl CodexCredentialAdmin { .map(|proxy| proxy.expose_url().to_owned()), }; let exported = match data { + CodexCredentialData::ApiKey(data) => { + if account.authentication_kind() != CODEX_AUTHENTICATION_KIND_API_KEY { + return Err(CodexCredentialAdminError::InvalidCredential); + } + CodexCprExportAccount::ApiKey(CodexCprApiKeyExportAccount { + common, + provider: PROVIDER_NAME, + authentication_kind: CODEX_AUTHENTICATION_KIND_API_KEY, + base_url: data.base_url, + api_key: data.api_key, + transport: data.transport, + }) + } + CodexCredentialData::OAuth(data) => { if account.authentication_kind() != CODEX_AUTHENTICATION_KIND_OAUTH || account.has_refresh_token() != data.refresh_token.is_some() { return Err(CodexCredentialAdminError::InvalidCredential); } - CodexCprOAuthExportAccount { + CodexCprExportAccount::OAuth(CodexCprOAuthExportAccount { common, access_token: data.access_token, refresh_token: data.refresh_token, @@ -545,7 +672,7 @@ impl CodexCredentialAdmin { .access_token_expires_at() .map(DateTime::::from) .map(|value| value.to_rfc3339()), - } + }) } }; accounts.push(exported); @@ -862,14 +989,32 @@ impl CodexCredentialAdminService { let mut accounts = Vec::with_capacity(candidates.len()); for candidate in candidates { let account_id = format!("acct_{}", uuid::Uuid::now_v7().simple()); + let authentication = match candidate.authentication { + ParsedCodexAuthentication::ApiKey(data) => { + let mut prepared = CodexCredentialAdmin.prepare_api_key( + account_id, + candidate + .name + .unwrap_or_else(|| "OpenAI API Key".to_owned()), + data, + )?; + prepared.model_access = candidate.model_access; + prepared.account = prepared + .account + .with_outbound_proxy(candidate.outbound_proxy); + accounts.push(prepared); + continue; + } + ParsedCodexAuthentication::OAuth(authentication) => authentication, + }; let typed_account_id = ProviderAccountId::new(account_id.clone()) .map_err(|_| CodexCredentialAdminError::InvalidInput)?; let (mut secret, mut access_token_expires_at) = self .resolve_import_tokens( &typed_account_id, - candidate.authentication.access_token.clone(), - candidate.authentication.refresh_token.clone(), - candidate.authentication.id_token.clone(), + authentication.access_token.clone(), + authentication.refresh_token.clone(), + authentication.id_token.clone(), candidate.outbound_proxy.as_ref(), ) .await?; @@ -1086,10 +1231,10 @@ fn parse_import_document( .unwrap_or(payload); let mut accounts = Vec::new(); for value in import_account_values(payload)? { - if !is_openai_oauth_candidate(value) { + if !is_codex_import_candidate(value) { continue; } - let mut account = parse_oauth_import_account(value)?; + let mut account = parse_codex_import_account(value)?; account.outbound_proxy = if ["outboundProxyUrl", "outbound_proxy_url", "proxy_key"] .iter() .any(|field| value.get(field).is_some()) @@ -1103,7 +1248,7 @@ fn parse_import_document( Ok(accounts) } -fn parse_oauth_import_account( +fn parse_codex_import_account( value: &Value, ) -> Result { let credentials = value.get("credentials").unwrap_or(value); @@ -1115,7 +1260,11 @@ fn parse_oauth_import_account( .map_err(|_| CodexCredentialAdminError::InvalidInput)?, name: first_string(value, &["name", "label"]), email: first_string(value, &["email"]).or_else(|| first_string(credentials, &["email"])), - authentication: parse_oauth_import_tokens(value)?, + authentication: if is_api_key_import(value) { + ParsedCodexAuthentication::ApiKey(parse_api_key_import(value)?) + } else { + ParsedCodexAuthentication::OAuth(parse_oauth_import_tokens(value)?) + }, outbound_proxy: None, }) } @@ -1214,7 +1363,7 @@ fn import_account_values(payload: &Value) -> Result, CodexCredential fn parse_oauth_import_tokens( value: &Value, -) -> Result { +) -> Result { let mut access_token = None; let mut refresh_token = None; let mut id_token = None; @@ -1247,14 +1396,14 @@ fn parse_oauth_import_tokens( if access_token.is_none() && refresh_token.is_none() { return Err(CodexCredentialAdminError::InvalidCredential); } - Ok(ParsedCodexAuthentication { + Ok(ParsedOAuthAuthentication { access_token, refresh_token, id_token, }) } -fn is_openai_oauth_candidate(value: &Value) -> bool { +fn is_codex_import_candidate(value: &Value) -> bool { let Some(account) = value.as_object() else { return false; }; @@ -1272,6 +1421,8 @@ fn is_openai_oauth_candidate(value: &Value) -> bool { kind.eq_ignore_ascii_case("openai") || kind.eq_ignore_ascii_case("codex") || kind.eq_ignore_ascii_case("oauth") + || kind.eq_ignore_ascii_case("api_key") + || kind.eq_ignore_ascii_case("apikey") }) } @@ -1282,3 +1433,113 @@ fn first_string(value: &Value, keys: &[&str]) -> Option { .filter(|value| !value.is_empty()) .map(str::to_owned) } + +fn is_api_key_import(value: &Value) -> bool { + value + .get("authentication_kind") + .or_else(|| value.get("type")) + .and_then(Value::as_str) + .is_some_and(|kind| matches!(kind, "api_key" | "apikey")) +} + +fn parse_api_key_import(value: &Value) -> Result { + let credentials = value.get("credentials").unwrap_or(value); + let external = value.get("type").and_then(Value::as_str) == Some("apikey"); + let mut base_url = first_string(credentials, &["base_url"]) + .or_else(|| external.then(|| "https://api.openai.com/v1".to_owned())) + .ok_or(CodexCredentialAdminError::InvalidInput)?; + if external { + if !crate::transport::valid_upstream_base_url(&base_url) { + return Err(CodexCredentialAdminError::InvalidInput); + } + // sub2api 接受服务根、版本前缀或完整 Responses 端点;仅在导入边界归一化。 + base_url = base_url.trim_end_matches('/').to_owned(); + if let Some(prefix) = base_url.strip_suffix("/responses") { + base_url = prefix.to_owned(); + } else if !sub2api_version_suffix(&base_url) { + base_url.push_str("/v1"); + } + // 不静默丢失会改变协议、身份请求头或模型映射的外部设置。 + for field in [ + "model_mapping", + "compact_model_mapping", + "custom_headers", + "header_overrides", + "force_model", + "responses_websockets", + "api_base_urls", + ] { + if credentials.get(field).is_some_and(external_option_enabled) { + return Err(CodexCredentialAdminError::InvalidInput); + } + } + if credentials + .get("api_protocol") + .and_then(Value::as_str) + .is_some_and(|protocol| !protocol.is_empty() && protocol != "responses") + { + return Err(CodexCredentialAdminError::InvalidInput); + } + if value + .get("extra") + .and_then(Value::as_object) + .is_some_and(|extra| { + extra.iter().any(|(key, value)| { + (key.starts_with("openai_") || key == "header_overrides") + && external_option_enabled(value) + }) + }) + { + return Err(CodexCredentialAdminError::InvalidInput); + } + } + let data = ApiKeyCredentialData { + schema_version: 1, + installation_id: uuid::Uuid::new_v4().to_string(), + base_url, + api_key: first_string(credentials, &["api_key"]) + .ok_or(CodexCredentialAdminError::InvalidCredential)?, + transport: credentials + .get("transport") + .map(|v| serde_json::from_value(v.clone())) + .transpose() + .map_err(|_| CodexCredentialAdminError::InvalidInput)? + .unwrap_or_default(), + }; + if !data.validate() { + return Err(CodexCredentialAdminError::InvalidCredential); + } + Ok(data) +} + +fn external_option_enabled(value: &Value) -> bool { + match value { + Value::Null | Value::Bool(false) => false, + Value::Object(value) => !value.is_empty(), + Value::Array(value) => !value.is_empty(), + Value::String(value) => !matches!(value.as_str(), "" | "off"), + _ => true, + } +} + +fn sub2api_version_suffix(base_url: &str) -> bool { + let Some(segment) = base_url.rsplit('/').next() else { + return false; + }; + let segment = segment.to_ascii_lowercase(); + let Some(version) = segment.strip_prefix('v') else { + return false; + }; + let digits = version.bytes().take_while(u8::is_ascii_digit).count(); + if digits == 0 { + return false; + } + let suffix = &version[digits..]; + suffix.is_empty() + || ["alpha", "beta", "preview"] + .iter() + .any(|prefix| suffix.starts_with(prefix)) + || suffix.strip_prefix('.').is_some_and(|minor| { + !minor.is_empty() && minor.bytes().all(|byte| byte.is_ascii_digit()) + }) +} diff --git a/backend/crates/providers/openai/src/credential/api_key.rs b/backend/crates/providers/openai/src/credential/api_key.rs new file mode 100644 index 000000000..091d3d5e2 --- /dev/null +++ b/backend/crates/providers/openai/src/credential/api_key.rs @@ -0,0 +1,87 @@ +//! API Key 账号的凭据合同;地址与传输策略随凭据 revision 一起更新。 + +use std::fmt; + +use secrecy::SecretString; +use serde::{Deserialize, Serialize}; + +pub const CODEX_AUTHENTICATION_KIND_API_KEY: &str = "api_key"; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApiKeyTransport { + #[default] + Http, + PreferWebsocket, +} + +/// 可在管理端展示的上游设置,不包含密钥。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApiKeyConfiguration { + pub base_url: String, + #[serde(default)] + pub transport: ApiKeyTransport, +} + +impl ApiKeyConfiguration { + pub(crate) fn validate(&self) -> bool { + self.base_url.len() <= 2048 + && !self.base_url.chars().any(char::is_control) + && crate::transport::valid_upstream_base_url(&self.base_url) + } +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApiKeyCredentialData { + pub schema_version: u32, + pub installation_id: String, + pub api_key: String, + pub base_url: String, + #[serde(default)] + pub transport: ApiKeyTransport, +} + +impl ApiKeyCredentialData { + pub fn configuration(&self) -> ApiKeyConfiguration { + ApiKeyConfiguration { + base_url: self.base_url.clone(), + transport: self.transport, + } + } + + pub(crate) fn validate(&self) -> bool { + self.schema_version == 1 + && self.configuration().validate() + && !self.api_key.is_empty() + && self.api_key.len() <= 16 * 1024 + && self.api_key.bytes().all(|byte| byte.is_ascii_graphic()) + } +} + +impl fmt::Debug for ApiKeyCredentialData { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ApiKeyCredentialData") + .field("schema_version", &self.schema_version) + .field("configuration", &self.configuration()) + .field("api_key", &"") + .finish() + } +} + +pub struct ApiKeyAuthentication { + pub configuration: ApiKeyConfiguration, + pub secret: SecretString, +} + +impl fmt::Debug for ApiKeyAuthentication { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ApiKeyAuthentication") + .field("configuration", &self.configuration) + .field("secret", &"") + .finish() + } +} diff --git a/backend/crates/providers/openai/src/credential/catalog.rs b/backend/crates/providers/openai/src/credential/catalog.rs index 335fb980c..e6e3ec819 100644 --- a/backend/crates/providers/openai/src/credential/catalog.rs +++ b/backend/crates/providers/openai/src/credential/catalog.rs @@ -6,6 +6,7 @@ use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, SystemTime}; use chrono::{DateTime, Utc}; +use futures::{StreamExt, stream}; use gateway_core::account::{ CredentialRevision, CredentialState, OpaqueProviderData, ProviderAccount, ProviderAccountId, }; @@ -31,7 +32,8 @@ const PLAN_CATALOG_CACHE_TTL: Duration = Duration::from_secs(5 * 60); const MAX_CATALOG_FETCH_ATTEMPTS: usize = 3; const MAX_PLAN_CATALOG_MODELS: usize = 2_048; const MAX_CLIENT_CATALOGS: usize = 32; -const CLIENT_CATALOG_TIMEOUT: Duration = Duration::from_secs(15); +const MODEL_CATALOG_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_CONCURRENT_API_CATALOGS: usize = 4; const CLIENT_CATALOG_ERROR_TTL: Duration = Duration::from_secs(5); /// OpenAI 以套餐划分的模型目录作用域。 @@ -41,6 +43,15 @@ pub struct CodexCatalogScope(ProviderCatalogScope); impl CodexCatalogScope { /// 从账号已验证的套餐事实构造目录作用域。 pub fn for_account(account: &ProviderAccount) -> Result { + if account.authentication_kind() == super::CODEX_AUTHENTICATION_KIND_API_KEY { + return ProviderCatalogScope::new(format!( + "account:{}:{}", + account.id(), + account.revision().get() + )) + .map(Self) + .map_err(|_| CodexCredentialCatalogError::InvalidCredentialData); + } let plan = account .plan_type() .map(str::trim) @@ -265,6 +276,47 @@ impl CodexCredentialCatalogService { accounts .retain(|account| scope.allows(account.id()) && eligible_catalog_account(account, now)); accounts.sort_by(|left, right| left.id().cmp(right.id())); + let mut adapted = BTreeMap::new(); + let (api_accounts, mut accounts): (Vec<_>, Vec<_>) = + accounts.into_iter().partition(|account| { + account.authentication_kind() == super::CODEX_AUTHENTICATION_KIND_API_KEY + }); + let mut catalogs = stream::iter(api_accounts) + .map(|account| async move { + let result = self.cached_or_refresh_account_catalog(&account).await; + (account, result) + }) + .buffer_unordered(MAX_CONCURRENT_API_CATALOGS); + // 所有 API 账号共享等待预算;慢上游不能逐个耗尽超时或阻塞 OAuth 目录。 + let deadline = Instant::now() + MODEL_CATALOG_TIMEOUT; + while let Ok(Some((account, result))) = + tokio::time::timeout_at(deadline, catalogs.next()).await + { + if let Ok(catalog) = result { + for id in catalog.models() { + if account.model_access().allows(id) { + let model = gateway_core::routing::UpstreamModelId::new(id.clone()) + .map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?; + adapted + .entry(id.clone()) + .or_insert(ProviderModelDescriptor { + model, + content: gateway_core::routing::ProviderModelContent::Adapted( + gateway_core::routing::ModelPresentation::new( + Some(id.clone()), + None, + ) + .with_agent_tools(true, false), + ), + }); + } + } + } + } + drop(catalogs); + accounts.retain(|account| { + account.authentication_kind() == super::CODEX_AUTHENTICATION_KIND_OAUTH + }); let mut last_error = CodexCredentialCatalogError::NoEligibleCredential; for account in accounts.iter().take(MAX_CATALOG_FETCH_ATTEMPTS) { let profile = self.profile.snapshot(); @@ -310,25 +362,21 @@ impl CodexCredentialCatalogService { self.base_url.clone(), CodexWireProfileState::new(profile), ); - let result = tokio::time::timeout( - CLIENT_CATALOG_TIMEOUT, - self.fetch_account_models(&client, account, Some(client_version)), - ) - .await - .map_err(|_| CodexCredentialCatalogError::Upstream { - detail: "client model catalog timed out".to_owned(), - }) - .and_then(|result| result) - .map(|fetched| { - fetched - .models - .into_iter() - .map(|model| ProviderModelDescriptor { - model: model.request_model().clone(), - payload: model.document().clone(), - }) - .collect() - }); + let result = self + .fetch_account_models(&client, account, Some(client_version)) + .await + .map(|fetched| { + fetched + .models + .into_iter() + .map(|model| ProviderModelDescriptor { + model: model.request_model().clone(), + content: gateway_core::routing::ProviderModelContent::Native( + model.document().clone(), + ), + }) + .collect() + }); CachedClientCatalog { completed_at: Instant::now(), result, @@ -336,11 +384,24 @@ impl CodexCredentialCatalogService { }) .await; match &result.result { - Ok(models) => return Ok(models.clone()), + Ok(models) => { + for model in models { + adapted.remove(model.model.as_str()); + } + return Ok(models + .iter() + .cloned() + .chain(adapted.into_values()) + .collect()); + } Err(error) => last_error = error.clone(), } } - Err(last_error) + if adapted.is_empty() { + Err(last_error) + } else { + Ok(adapted.into_values().collect()) + } } #[must_use] @@ -473,8 +534,15 @@ impl CodexCredentialCatalogService { let mut union_order = Vec::new(); let mut scope_models = BTreeMap::new(); let mut etags = Vec::new(); + let mut last_error = None; for (scope, candidates) in groups { - let fetched = self.fetch_scope_models(&client, candidates).await?; + let fetched = match self.fetch_scope_models(&client, candidates).await { + Ok(fetched) => fetched, + Err(error) => { + last_error = Some(error); + continue; + } + }; let entitlement = model_ids(&fetched.models); self.replace_plan_catalog(&CodexPlanCatalog::new( scope.clone(), @@ -489,12 +557,24 @@ impl CodexCredentialCatalogService { union_order.push(entry.key().clone()); entry.insert(model.clone()); } - Entry::Occupied(_) => {} + Entry::Occupied(mut entry) => { + // 原生目录含完整能力证据,同名 API ID 不能覆盖它,也不能跨来源拼字段。 + if model.document().protocol() == "codex" + && entry.get().document().protocol() != "codex" + { + entry.insert(model.clone()); + } + } } } scope_models.insert(scope, entitlement); etags.extend(fetched.etag); } + if union.is_empty() + && let Some(error) = last_error + { + return Err(error); + } let observed_at = SystemTime::now(); let snapshot = CodexCredentialCatalogSnapshot { observed_at, @@ -562,38 +642,45 @@ impl CodexCredentialCatalogService { account: &ProviderAccount, client_version: Option<&str>, ) -> Result { - let credential = self - .repository - .load_runtime_credential(account) - .await - .map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?; - let request_id = format!("catalog_{}", Uuid::now_v7().simple()); - let authorization = credential - .authentication - .authorization_header() - .map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?; - let result = client - .for_account(account) - .map_err(|error| CodexCredentialCatalogError::Upstream { + tokio::time::timeout(MODEL_CATALOG_TIMEOUT, async { + let credential = self + .repository + .load_runtime_credential(account) + .await + .map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?; + let request_id = format!("catalog_{}", Uuid::now_v7().simple()); + let authorization = credential + .authentication + .authorization_header() + .map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?; + let result = client + .for_account(account) + .map_err(|error| CodexCredentialCatalogError::Upstream { + detail: error.to_string(), + })? + .with_authentication(&credential.authentication) + .fetch_models_with_context( + CodexRequestContext::auxiliary( + authorization.expose_secret(), + account.upstream_account_id(), + &request_id, + None, + ), + client_version, + ) + .await; + let snapshot = result.map_err(|error| CodexCredentialCatalogError::Upstream { detail: error.to_string(), - })? - .fetch_models_with_context( - CodexRequestContext::auxiliary( - authorization.expose_secret(), - account.upstream_account_id(), - &request_id, - None, - ), - client_version, - ) - .await; - let snapshot = result.map_err(|error| CodexCredentialCatalogError::Upstream { - detail: error.to_string(), - })?; - Ok(FetchedAccountModels { - models: snapshot.models().to_vec(), - etag: snapshot.etag().map(str::to_owned), + })?; + Ok(FetchedAccountModels { + models: snapshot.models().to_vec(), + etag: snapshot.etag().map(str::to_owned), + }) }) + .await + .map_err(|_| CodexCredentialCatalogError::Upstream { + detail: "model catalog timed out".to_owned(), + })? } pub fn invalidate(&self) -> Result<(), CodexCredentialCatalogError> { diff --git a/backend/crates/providers/openai/src/credential/mod.rs b/backend/crates/providers/openai/src/credential/mod.rs index 26c8371a7..7e9ce07e4 100644 --- a/backend/crates/providers/openai/src/credential/mod.rs +++ b/backend/crates/providers/openai/src/credential/mod.rs @@ -2,6 +2,7 @@ mod admin; mod affinity; +mod api_key; mod catalog; mod cookie; mod oauth; @@ -23,6 +24,11 @@ pub(crate) use affinity::{ pub(crate) use oauth::oauth_owner_ref; pub(crate) use types::parse_access_token_expiration; +pub use api_key::{ + ApiKeyAuthentication, ApiKeyConfiguration, ApiKeyCredentialData, ApiKeyTransport, + CODEX_AUTHENTICATION_KIND_API_KEY, +}; + pub use admin::{ CodexCprExportDocument, CodexCredentialAdmin, CodexCredentialAdminError, CodexCredentialAdminService, ExportManagedCodexCredential, ImportCodexOAuthCredential, diff --git a/backend/crates/providers/openai/src/credential/quota/mod.rs b/backend/crates/providers/openai/src/credential/quota/mod.rs index da291690a..54ad359e5 100644 --- a/backend/crates/providers/openai/src/credential/quota/mod.rs +++ b/backend/crates/providers/openai/src/credential/quota/mod.rs @@ -685,7 +685,10 @@ impl CodexCredentialQuotaService { } pub async fn synchronize(&self) -> Result { - let accounts = self.repository.list_for_provider().await?; + let mut accounts = self.repository.list_for_provider().await?; + accounts.retain(|account| { + account.authentication_kind() == crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH + }); let mut summary = CodexQuotaSyncSummary::default(); let now = SystemTime::now(); let initial = self.initial_quota_sync_accounts(&accounts, now).await?; @@ -863,7 +866,9 @@ impl CodexCredentialQuotaService { account: &ProviderAccount, rate_limits: &[ParsedRateLimits], ) -> Result { - if rate_limits.is_empty() { + if account.authentication_kind() != crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH + || rate_limits.is_empty() + { return Ok(false); } let has_quota_facts = rate_limits.iter().any(|observation| { @@ -1056,6 +1061,9 @@ impl CodexCredentialQuotaService { .await? .filter(|account| account.provider().as_str() == "openai") .ok_or(CodexCredentialQuotaError::NotFound)?; + if account.authentication_kind() != crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH { + return Err(CodexCredentialQuotaError::NotFound); + } let observed_at = SystemTime::now(); if !access_token_is_current(&account, observed_at) { return Err(CodexCredentialQuotaError::CredentialRefreshRequired); diff --git a/backend/crates/providers/openai/src/credential/repository.rs b/backend/crates/providers/openai/src/credential/repository.rs index 358a3fa87..e5cf94a47 100644 --- a/backend/crates/providers/openai/src/credential/repository.rs +++ b/backend/crates/providers/openai/src/credential/repository.rs @@ -75,7 +75,12 @@ impl CodexCredentialRepository { next_refresh_at, ) .map_err(|_| CredentialRepositoryError::InvalidCredentialData)? - .with_account_state(CredentialState::Ready, SystemTime::now(), None, None); + .with_account_state( + oauth_account_state(account, CredentialState::Ready), + SystemTime::now(), + None, + None, + ); cas_revision(self.store.compare_and_swap_credential(update).await?) } @@ -146,6 +151,10 @@ impl CodexCredentialRepository { if loaded.account.provider().as_str() != PROVIDER_NAME { return Err(CredentialRepositoryError::InvalidCredentialData); } + let data = CodexCredentialCodec::decode_complete(&loaded.credential)?; + if data.authentication_kind() != loaded.account.authentication_kind() { + return Err(CredentialRepositoryError::InvalidCredentialData); + } CodexCredentialCodec::decode(&loaded.credential).map_err(Into::into) } @@ -230,6 +239,7 @@ impl CodexCredentialRepository { if !account.enabled() { return Ok(()); } + let credential_state = oauth_account_state(account, credential_state); let message = message.filter(|value| !value.trim().is_empty()); let error_reason = if credential_state == CredentialState::Ready { message.as_ref().and(error_reason) @@ -295,3 +305,14 @@ impl From for CredentialRepositoryError { Self::InvalidCredentialData } } + +/// OAuth 未取得用户身份时保留未验证状态;通用账号层不解释认证类型。 +fn oauth_account_state(account: &ProviderAccount, observed: CredentialState) -> CredentialState { + if account.authentication_kind() == super::CODEX_AUTHENTICATION_KIND_OAUTH + && account.upstream_user_id().is_none() + { + CredentialState::Unknown + } else { + observed + } +} diff --git a/backend/crates/providers/openai/src/credential/security.rs b/backend/crates/providers/openai/src/credential/security.rs index eaef78ac8..db44410a1 100644 --- a/backend/crates/providers/openai/src/credential/security.rs +++ b/backend/crates/providers/openai/src/credential/security.rs @@ -1,5 +1,6 @@ //! Codex 明文 credential JSON 的 schema 校验与日志脱敏边界。 +use super::api_key::ApiKeyAuthentication; use gateway_core::account::PlaintextCredential; use secrecy::{ExposeSecret, SecretString}; use serde_json::Value; @@ -40,11 +41,16 @@ impl std::fmt::Debug for CodexRuntimeCredential { pub enum CodexRuntimeAuthentication { OAuth(CodexOAuthSecret), + ApiKey(ApiKeyAuthentication), } impl CodexRuntimeAuthentication { pub fn authorization_header(&self) -> Result { match self { + Self::ApiKey(auth) => Ok(SecretString::from(format!( + "Bearer {}", + auth.secret.expose_secret() + ))), Self::OAuth(secret) => Ok(SecretString::from(format!( "Bearer {}", secret.access_token.expose_secret() @@ -56,6 +62,7 @@ impl CodexRuntimeAuthentication { pub const fn oauth(&self) -> Option<&CodexOAuthSecret> { match self { Self::OAuth(secret) => Some(secret), + Self::ApiKey(_) => None, } } } @@ -64,6 +71,7 @@ impl std::fmt::Debug for CodexRuntimeAuthentication { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::OAuth(secret) => secret.fmt(formatter), + Self::ApiKey(auth) => auth.fmt(formatter), } } } @@ -166,6 +174,17 @@ impl CodexCredentialCodec { validate(&data)?; let (authentication, principal, installation_id, cookies, oauth_client_id, oauth_scope) = match data { + CodexCredentialData::ApiKey(data) => ( + CodexRuntimeAuthentication::ApiKey(ApiKeyAuthentication { + configuration: data.configuration(), + secret: SecretString::from(data.api_key), + }), + None, + data.installation_id, + Vec::new(), + None, + None, + ), CodexCredentialData::OAuth(data) => ( CodexRuntimeAuthentication::OAuth(CodexOAuthSecret { access_token: SecretString::from(data.access_token), @@ -220,13 +239,25 @@ impl CodexCredentialCodec { (CodexCredentialData::OAuth(incoming), CodexCredentialData::OAuth(existing)) => { incoming.installation_id = existing.installation_id; } + (CodexCredentialData::ApiKey(incoming), CodexCredentialData::ApiKey(existing)) => { + incoming.installation_id = existing.installation_id; + } + _ => return Err(CodexCredentialDataError::Invalid), } Self::encode_complete(incoming) } } fn validate(data: &CodexCredentialData) -> Result<(), CodexCredentialDataError> { + if let CodexCredentialData::ApiKey(data) = data { + return if data.validate() && valid_installation_id(&data.installation_id) { + Ok(()) + } else { + Err(CodexCredentialDataError::Invalid) + }; + } let (installation_id, cookies) = match data { + CodexCredentialData::ApiKey(_) => return Err(CodexCredentialDataError::Invalid), CodexCredentialData::OAuth(data) => { if data.schema_version != CODEX_CREDENTIAL_SCHEMA_VERSION { return Err(CodexCredentialDataError::Invalid); diff --git a/backend/crates/providers/openai/src/credential/selector.rs b/backend/crates/providers/openai/src/credential/selector.rs index f0f8ce8ef..3d1ef7ab6 100644 --- a/backend/crates/providers/openai/src/credential/selector.rs +++ b/backend/crates/providers/openai/src/credential/selector.rs @@ -101,6 +101,8 @@ pub(crate) struct SelectCodexProviderEndpointCredential<'a> { } struct CredentialSelectionInput<'a> { + requires_websocket: bool, + oauth_only: bool, request_url: &'a Url, attempt: &'a AttemptContext, session_affinity_key: Option<&'a ProviderSessionAffinityKey>, @@ -297,6 +299,8 @@ impl CodexCredentialSelector { request: &SelectCodexCredential<'_>, ) -> Result { let input = CredentialSelectionInput { + requires_websocket: false, + oauth_only: false, request_url: request.request_url, attempt: request.attempt, session_affinity_key: request.session_affinity_key, @@ -315,8 +319,12 @@ impl CodexCredentialSelector { request: &SelectCodexCredential<'_>, cyber_policy_session_key: Option<&ProviderSessionAffinityKey>, session_affinity_observation: Option<&CodexSessionAffinity>, + requires_websocket: bool, + oauth_only: bool, ) -> Result { let input = CredentialSelectionInput { + requires_websocket, + oauth_only, request_url: request.request_url, attempt: request.attempt, session_affinity_key: request.session_affinity_key, @@ -339,6 +347,8 @@ impl CodexCredentialSelector { request: &SelectCodexProviderEndpointCredential<'_>, ) -> Result { let input = CredentialSelectionInput { + requires_websocket: false, + oauth_only: true, request_url: request.request_url, attempt: request.attempt, session_affinity_key: request.session_affinity.map(CodexSessionAffinity::key), @@ -369,6 +379,8 @@ impl CodexCredentialSelector { .into_iter() .filter(|account| { account.provider() == &self.provider_kind + && (!request.oauth_only + || account.authentication_kind() == CODEX_AUTHENTICATION_KIND_OAUTH) && (diagnostic || request .attempt @@ -387,11 +399,32 @@ impl CodexCredentialSelector { let observed_support = self .catalog .observed_model_support(account, upstream_model); - matches!(observed_support, Ok(None | Some(true))) + if account.authentication_kind() + == super::CODEX_AUTHENTICATION_KIND_API_KEY + { + matches!(observed_support, Ok(Some(true))) + } else { + matches!(observed_support, Ok(None | Some(true))) + } } }) }) .collect::>(); + let mut eligible = Vec::with_capacity(accounts.len()); + for account in accounts { + if request.requires_websocket + && account.authentication_kind() == super::CODEX_AUTHENTICATION_KIND_API_KEY + { + let runtime = self.repository.load_runtime_credential(&account).await?; + if !matches!(runtime.authentication, CodexRuntimeAuthentication::ApiKey(ref auth) + if auth.configuration.transport == super::ApiKeyTransport::PreferWebsocket) + { + continue; + } + } + eligible.push(account); + } + let accounts = eligible; if model_access_rejected > 0 && request.attempt.trace().is_enabled() { request.attempt.trace().record( "account.model_access", @@ -1151,6 +1184,12 @@ impl CodexCredentialSelector { response_origin: &Url, headers: &[String], ) -> Result { + if account.authentication_kind() != CODEX_AUTHENTICATION_KIND_OAUTH { + return Ok(CodexCookieCaptureOutcome { + credential_revision: None, + rejected: headers.len(), + }); + } let parsed = self.cookie_policy.parse_response_headers( account.id().as_str(), account.revision().get(), @@ -1165,7 +1204,12 @@ impl CodexCredentialSelector { }); } let mut data = self.repository.load_complete_data(account).await?; - let cookies = data.cookies_mut(); + let Some(cookies) = data.cookies_mut() else { + return Ok(CodexCookieCaptureOutcome { + credential_revision: None, + rejected: headers.len(), + }); + }; for input in parsed.inputs { let scope = self.cookie_policy.validate_capture( &input.response_origin, @@ -1238,10 +1282,13 @@ impl CodexCredentialSelector { if data.cookies().is_empty() { return Ok(()); } + let Some(cookies) = data.cookies_mut() else { + return Ok(()); + }; match recovery { CookieRecovery::ExpireAt(expires_at) => { let expires_at = chrono::DateTime::::from(expires_at); - for cookie in data.cookies_mut() { + for cookie in cookies { cookie.expires_at = Some( cookie .expires_at @@ -1249,7 +1296,7 @@ impl CodexCredentialSelector { ); } } - CookieRecovery::Clear => data.cookies_mut().clear(), + CookieRecovery::Clear => cookies.clear(), } self.repository.compare_and_swap_data(account, data).await?; Ok(()) diff --git a/backend/crates/providers/openai/src/credential/types.rs b/backend/crates/providers/openai/src/credential/types.rs index 0e002c293..38703b580 100644 --- a/backend/crates/providers/openai/src/credential/types.rs +++ b/backend/crates/providers/openai/src/credential/types.rs @@ -322,11 +322,12 @@ impl fmt::Debug for CodexOAuthCredentialData { } } -/// OpenAI Provider 的规范化 OAuth 凭据形态。 +/// OpenAI Provider 的规范化凭据形态。 #[derive(Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum CodexCredentialData { OAuth(CodexOAuthCredentialData), + ApiKey(super::api_key::ApiKeyCredentialData), } impl CodexCredentialData { @@ -334,6 +335,7 @@ impl CodexCredentialData { pub const fn authentication_kind(&self) -> &'static str { match self { Self::OAuth(_) => CODEX_AUTHENTICATION_KIND_OAUTH, + Self::ApiKey(_) => super::api_key::CODEX_AUTHENTICATION_KIND_API_KEY, } } @@ -341,6 +343,7 @@ impl CodexCredentialData { pub fn installation_id(&self) -> &str { match self { Self::OAuth(data) => &data.installation_id, + Self::ApiKey(data) => &data.installation_id, } } @@ -348,12 +351,14 @@ impl CodexCredentialData { pub fn cookies(&self) -> &[CodexCookie] { match self { Self::OAuth(data) => &data.cookies, + Self::ApiKey(_) => &[], } } - pub fn cookies_mut(&mut self) -> &mut Vec { + pub fn cookies_mut(&mut self) -> Option<&mut Vec> { match self { - Self::OAuth(data) => &mut data.cookies, + Self::OAuth(data) => Some(&mut data.cookies), + Self::ApiKey(_) => None, } } @@ -361,12 +366,14 @@ impl CodexCredentialData { pub fn oauth(&self) -> Option<&CodexOAuthCredentialData> { match self { Self::OAuth(data) => Some(data), + Self::ApiKey(_) => None, } } pub fn oauth_mut(&mut self) -> Option<&mut CodexOAuthCredentialData> { match self { Self::OAuth(data) => Some(data), + Self::ApiKey(_) => None, } } @@ -381,6 +388,7 @@ impl fmt::Debug for CodexCredentialData { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::OAuth(data) => data.fmt(formatter), + Self::ApiKey(data) => data.fmt(formatter), } } } diff --git a/backend/crates/providers/openai/src/provider/execution.rs b/backend/crates/providers/openai/src/provider/execution.rs index 69f6bc2f4..162fec634 100644 --- a/backend/crates/providers/openai/src/provider/execution.rs +++ b/backend/crates/providers/openai/src/provider/execution.rs @@ -198,6 +198,8 @@ pub(super) struct ColdJsonResponse { #[derive(Clone, Serialize, Deserialize)] pub(super) struct OpenAiSessionState { pub(super) account_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) credential_revision: Option, pub(super) conversation_id: Option, #[serde(default)] pub(super) turn_state: Option, @@ -216,6 +218,7 @@ pub(super) enum OpenAiContinuationScope { pub(super) struct OpenAiSessionCapture { pub(super) account_id: String, + pub(super) credential_revision: Option, pub(super) conversation_id: Option, pub(super) turn_state: Option, pub(super) client_turn_id: Option, @@ -261,6 +264,7 @@ fn encode_openai_session_capture( }; encode_openai_session_state(OpenAiSessionState { account_id: capture.account_id.clone(), + credential_revision: capture.credential_revision, conversation_id: capture.conversation_id.clone(), turn_state: capture.turn_state.clone(), client_turn_id: capture.client_turn_id.clone(), diff --git a/backend/crates/providers/openai/src/provider/mod.rs b/backend/crates/providers/openai/src/provider/mod.rs index 1479a3d55..c23924ef0 100644 --- a/backend/crates/providers/openai/src/provider/mod.rs +++ b/backend/crates/providers/openai/src/provider/mod.rs @@ -388,6 +388,12 @@ impl Provider for CodexProvider { let cyber_policy_session_key = derive_codex_cyber_policy_session_key(&upstream_request, context.client_api_key_ref()); + let requires_websocket = transport_requirement(&upstream_request).requires_websocket() + || (context.continuation_attempt() == ContinuationAttempt::Native + && (previous_session.as_ref().is_some_and(|state| { + state.continuation_scope == OpenAiContinuationScope::ConnectionLocal + }) || matches!(context.continuation(), Some(ContinuationBinding::Pinned(binding)) + if binding.scope() == NativeContinuationScope::ConnectionLocal))); let selection_started_at = Instant::now(); let lease = self .selector @@ -400,6 +406,9 @@ impl Provider for CodexProvider { }, cyber_policy_session_key.as_ref(), session_affinity.as_ref(), + requires_websocket, + upstream_request.responses_lite.is_some() + || upstream_request.memgen_request.is_some(), ) .await .map_err(map_selection_error)?; @@ -407,6 +416,13 @@ impl Provider for CodexProvider { u64::try_from(selection_started_at.elapsed().as_millis()).unwrap_or(u64::MAX); let lease = Arc::new(lease); // 首字计时的起点:账号选择完成之后、上游建立之前。 + if previous_session.as_ref().is_some_and(|state| { + state + .credential_revision + .is_some_and(|revision| revision != lease.account().revision().get()) + }) { + return Err(continuation_replay_required_error("scope_unavailable")); + } let output_started_at = Instant::now(); let provider_kind = ProviderKind::new(PROVIDER_NAME) .map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent))?; @@ -498,7 +514,19 @@ impl Provider for CodexProvider { account_scope, ); let requirement = transport_requirement(&upstream_request); - let requested_transport = selected_transport(&upstream_request); + let api_http = matches!(lease.authentication(), crate::credential::CodexRuntimeAuthentication::ApiKey(auth) + if auth.configuration.transport == crate::credential::ApiKeyTransport::Http); + if api_http && requirement.requires_websocket() { + return Err(provider_error( + ProviderErrorKind::Unsupported, + UpstreamSendState::NotSent, + )); + } + let requested_transport = if api_http { + CodexProviderTransport::HttpOnly + } else { + selected_transport(&upstream_request) + }; let session_http_fallback = requirement.allows_pre_send_http_fallback() && session_affinity .as_ref() @@ -527,6 +555,11 @@ impl Provider for CodexProvider { let session_capture = (!continuation_requested || previous_session.is_some()).then(|| OpenAiSessionCapture { account_id: lease.account_id().as_str().to_owned(), + credential_revision: matches!( + lease.authentication(), + crate::credential::CodexRuntimeAuthentication::ApiKey(_) + ) + .then_some(lease.account().revision().get()), conversation_id: upstream_request.local_conversation_id.clone(), turn_state: upstream_request.turn_state.clone(), client_turn_id: upstream_request.client_turn_id.clone(), @@ -543,9 +576,13 @@ impl Provider for CodexProvider { AttemptTransport::Default | AttemptTransport::Fallback => 0, }; let events = cold_response_stream(ColdResponse { - client: self.client.for_account(lease.account()).map_err(|_| { - provider_error(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent) - })?, + client: self + .client + .for_account(lease.account()) + .map_err(|_| { + provider_error(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent) + })? + .with_authentication(lease.authentication()), response_origin: self.responses_url.clone(), request: upstream_request, upstream_model: upstream_model.clone(), diff --git a/backend/crates/providers/openai/src/transport/catalog.rs b/backend/crates/providers/openai/src/transport/catalog.rs index 7b958f1d0..843e34c3d 100644 --- a/backend/crates/providers/openai/src/transport/catalog.rs +++ b/backend/crates/providers/openai/src/transport/catalog.rs @@ -19,13 +19,14 @@ const MAX_SERVICE_TIER_ID_BYTES: usize = 64; const MAX_ETAG_BYTES: usize = 256; /// 上游目录对一项能力给出的明确证据。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum CodexCatalogCapabilityEvidence { /// 上游明确声明原生支持。 DeclaredNative, /// 上游明确声明不支持。 DeclaredUnsupported, /// 上游没有提供可依赖的声明。 + #[default] Unknown, } @@ -40,7 +41,7 @@ impl CodexCatalogCapabilityEvidence { } /// Codex 目录中允许进入控制面的能力证据。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct CodexCatalogCapabilities { responses_api: CodexCatalogCapabilityEvidence, reasoning: CodexCatalogCapabilityEvidence, @@ -110,7 +111,7 @@ impl CodexCatalogCapabilities { } /// Codex 目录中明确声明的模型限制。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct CodexCatalogLimits { context_window_tokens: Option, max_context_window_tokens: Option, @@ -131,7 +132,7 @@ impl CodexCatalogLimits { } /// Codex 目录中允许持久化的原始元数据白名单。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct CodexCatalogMetadata { description: Option, priority: Option, @@ -585,3 +586,66 @@ fn validate_etag(value: &str) -> Result { } Ok(value.to_owned()) } + +/// 标准 API 模型 ID 遵循核心 ID 合同,不套用 Codex slug 语法或推断原生画像。 +pub(crate) fn parse_api_model_catalog( + body: &[u8], + etag: Option<&str>, +) -> Result { + #[derive(Deserialize)] + struct ApiModels { + data: Vec, + } + #[derive(Deserialize)] + struct ApiModel { + id: String, + display_name: Option, + } + if body.len() > MAX_CODEX_MODEL_CATALOG_BYTES { + return Err(CodexModelCatalogError::ResponseTooLarge); + } + let wire: ApiModels = + serde_json::from_slice(body).map_err(|_| CodexModelCatalogError::InvalidWire)?; + if wire.data.is_empty() { + return Err(CodexModelCatalogError::EmptySnapshot); + } + if wire.data.len() > MAX_CATALOG_MODELS { + return Err(CodexModelCatalogError::TooManyModels); + } + let mut seen = BTreeSet::new(); + let mut models = Vec::with_capacity(wire.data.len()); + for item in wire.data { + if item.id.trim() != item.id { + return Err(CodexModelCatalogError::InvalidModelSlug); + } + let request_model = + UpstreamModelId::new(item.id).map_err(|_| CodexModelCatalogError::InvalidModelSlug)?; + let display_name = item + .display_name + .unwrap_or_else(|| request_model.as_str().to_owned()); + validate_public_text(&display_name, MAX_DISPLAY_NAME_BYTES, false)?; + let document = RawJsonPayload::new( + "openai", + Bytes::from( + serde_json::to_vec(&serde_json::json!({"id": request_model.as_str()})) + .map_err(|_| CodexModelCatalogError::InvalidWire)?, + ), + ) + .map_err(|_| CodexModelCatalogError::InvalidWire)?; + if !seen.insert(request_model.clone()) { + return Err(CodexModelCatalogError::DuplicateModelSlug); + } + models.push(CodexCatalogModel { + document, + request_model, + display_name, + capabilities: CodexCatalogCapabilities::default(), + limits: CodexCatalogLimits::default(), + metadata: CodexCatalogMetadata::default(), + }); + } + Ok(CodexModelCatalogSnapshot { + models, + etag: etag.map(validate_etag).transpose()?, + }) +} diff --git a/backend/crates/providers/openai/src/transport/client.rs b/backend/crates/providers/openai/src/transport/client.rs index d1af38b2e..158d47519 100644 --- a/backend/crates/providers/openai/src/transport/client.rs +++ b/backend/crates/providers/openai/src/transport/client.rs @@ -642,12 +642,28 @@ pub struct CodexBackendJsonResponse { // CodexBackendClient // --------------------------------------------------------------------------- +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum OpenAiUpstreamProtocol { + Codex, + ResponsesApi, +} + +impl OpenAiUpstreamProtocol { + pub(super) const fn responses_path(self) -> &'static str { + match self { + Self::Codex => super::endpoints::CODEX_RESPONSES_PATH, + Self::ResponsesApi => "/responses", + } + } +} + /// Codex HTTP/SSE 上游客户端。 #[derive(Clone)] pub struct CodexBackendClient { pub(super) client: Client, pub(super) direct_client: Client, pub(super) base_url: String, + pub(super) protocol: OpenAiUpstreamProtocol, pub(super) profile: CodexWireProfileState, pub(super) websocket_pool: Option>, pub(super) websocket_origin_breaker: WebSocketOriginBreaker, @@ -657,6 +673,22 @@ pub struct CodexBackendClient { } impl CodexBackendClient { + pub(crate) fn with_authentication( + mut self, + authentication: &crate::credential::CodexRuntimeAuthentication, + ) -> Self { + if let crate::credential::CodexRuntimeAuthentication::ApiKey(auth) = authentication { + self.base_url = auth.configuration.base_url.trim_end_matches('/').to_owned(); + self.protocol = OpenAiUpstreamProtocol::ResponsesApi; + self.websocket_origin_key = format!( + "{}:{}", + websocket_origin_key(&self.base_url), + self.egress_key + ); + } + self + } + pub fn for_account( &self, account: &gateway_core::account::ProviderAccount, @@ -664,6 +696,11 @@ impl CodexBackendClient { let mut client = self.clone(); client.outbound_proxy = account.outbound_proxy().cloned(); client.egress_key = egress_key(account.id().as_str(), account.outbound_proxy()); + if account.authentication_kind() == crate::credential::CODEX_AUTHENTICATION_KIND_API_KEY { + client + .egress_key + .push_str(&format!(":revision:{}", account.revision().get())); + } client.websocket_origin_key = format!( "{}:{}", websocket_origin_key(&self.base_url), diff --git a/backend/crates/providers/openai/src/transport/client_sse.rs b/backend/crates/providers/openai/src/transport/client_sse.rs index 2d4858f81..8471ebf2b 100644 --- a/backend/crates/providers/openai/src/transport/client_sse.rs +++ b/backend/crates/providers/openai/src/transport/client_sse.rs @@ -60,6 +60,7 @@ impl CodexBackendClient { outbound_proxy: None, egress_key: String::new(), base_url, + protocol: OpenAiUpstreamProtocol::Codex, profile, websocket_pool: None, websocket_origin_breaker: WebSocketOriginBreaker::default(), @@ -87,7 +88,7 @@ impl CodexBackendClient { ) -> CodexClientResult { let headers = self.request_headers_for_http_response(upstream_request, context)?; let headers_started_at = Instant::now(); - // 与官方 Codex app-server 一致:/v1/responses 请求体默认 zstd 压缩。 + // OAuth 请求遵循 Codex 压缩合同;API Key 上游使用普通 JSON。 // Codex 上游只交付 SSE;即使下游请求 `stream: false`,也要上游流式执行, // 再由 API 层收集 canonical events 并返回完整 JSON。不能把下游的传输偏好 // 直接透传给 Codex,否则上游会以 400 拒绝非流式请求。 @@ -95,7 +96,7 @@ impl CodexBackendClient { upstream_body.insert("stream".to_owned(), serde_json::Value::Bool(true)); let body = serde_json::to_vec(&upstream_body).map_err(CodexClientError::RequestBodyEncode)?; - let endpoint = endpoint_url(&self.base_url, CODEX_RESPONSES_PATH); + let endpoint = endpoint_url(&self.base_url, self.protocol.responses_path()); let trace = context .trace .cloned() @@ -111,16 +112,15 @@ impl CodexBackendClient { .map(|(name, value)| (name.as_str(), value.as_bytes())), ); trace.capture("upstream.request.body", &body); - let body = zstd::stream::encode_all(std::io::Cursor::new(body), 3) - .map_err(CodexClientError::RequestCompression)?; - let response = self - .client - .post(endpoint) - .headers(headers) - .header(CONTENT_ENCODING, HeaderValue::from_static("zstd")) - .body(body) - .send() - .await?; + let mut outbound = self.client.post(endpoint).headers(headers); + let body = if self.protocol == OpenAiUpstreamProtocol::Codex { + outbound = outbound.header(CONTENT_ENCODING, HeaderValue::from_static("zstd")); + zstd::stream::encode_all(std::io::Cursor::new(body), 3) + .map_err(CodexClientError::RequestCompression)? + } else { + body + }; + let response = outbound.body(body).send().await?; let upstream_headers_ms = elapsed_duration_millis(headers_started_at.elapsed()); let http_version = http_version_name(response.version()).to_string(); let status = response.status(); @@ -251,8 +251,9 @@ impl CodexBackendClient { let websocket_request = websocket_upstream_request(request); let headers = self.request_headers_for_websocket_response(&websocket_request, context)?; - let mut websocket_create = CodexWebSocketConnection::responses_create_request( + let mut websocket_create = CodexWebSocketConnection::responses_create_request_for_path( &self.base_url, + self.protocol.responses_path(), &generate_key(), websocket_header_pairs(&headers), &websocket_request, @@ -501,19 +502,23 @@ impl CodexBackendClient { context: CodexRequestContext<'_>, client_version: Option<&str>, ) -> CodexClientResult { - let endpoint = endpoint_url(&self.base_url, "codex/models"); + let path = match self.protocol { + OpenAiUpstreamProtocol::Codex => "codex/models", + OpenAiUpstreamProtocol::ResponsesApi => "models", + }; let profile = self.profile.snapshot(); let headers = self.model_request_headers(&profile, context)?; - let response = self + let mut request = self .client - .get(endpoint) - .query(&[( + .get(endpoint_url(&self.base_url, path)) + .headers(headers); + if self.protocol == OpenAiUpstreamProtocol::Codex { + request = request.query(&[( "client_version", client_version.unwrap_or(profile.codex_version.as_str()), - )]) - .headers(headers) - .send() - .await?; + )]); + } + let response = request.send().await?; let status = response.status(); let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers()); let set_cookie_headers = response_meta::set_cookie_headers(response.headers()); @@ -540,7 +545,12 @@ impl CodexBackendClient { send_phase: CodexUpstreamSendPhase::AfterPayload, }); } - Ok(parse_codex_model_catalog(&body, etag.as_deref())?) + Ok(match self.protocol { + OpenAiUpstreamProtocol::Codex => parse_codex_model_catalog(&body, etag.as_deref())?, + OpenAiUpstreamProtocol::ResponsesApi => { + super::catalog::parse_api_model_catalog(&body, etag.as_deref())? + } + }) } } diff --git a/backend/crates/providers/openai/src/transport/endpoints.rs b/backend/crates/providers/openai/src/transport/endpoints.rs index b721ab001..ab5d0253e 100644 --- a/backend/crates/providers/openai/src/transport/endpoints.rs +++ b/backend/crates/providers/openai/src/transport/endpoints.rs @@ -27,6 +27,24 @@ pub fn endpoint_url(base_url: &str, endpoint_path: &str) -> String { ) } +/// 上游启动配置与账号地址共用同一校验,HTTP 仅允许本机联调。 +pub(crate) fn valid_upstream_base_url(value: &str) -> bool { + let Ok(url) = url::Url::parse(value) else { + return false; + }; + let loopback = match url.host() { + Some(url::Host::Domain("localhost")) => true, + Some(url::Host::Ipv4(address)) => address.is_loopback(), + Some(url::Host::Ipv6(address)) => address.is_loopback(), + _ => false, + }; + ((url.scheme() == "https" && url.host_str().is_some()) || (url.scheme() == "http" && loopback)) + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() +} + /// 返回与 base path 对应的唯一 usage endpoint。 pub fn usage_endpoint_url(base_url: &str) -> String { account_endpoint_url(base_url, "usage") diff --git a/backend/crates/providers/openai/src/transport/headers.rs b/backend/crates/providers/openai/src/transport/headers.rs index e9d338342..964d0efe1 100644 --- a/backend/crates/providers/openai/src/transport/headers.rs +++ b/backend/crates/providers/openai/src/transport/headers.rs @@ -8,7 +8,8 @@ use reqwest::header::{ }; use super::client::{ - CodexBackendClient, CodexClientResult, CodexRequestContext, openai_subagent_from_metadata, + CodexBackendClient, CodexClientResult, CodexRequestContext, OpenAiUpstreamProtocol, + openai_subagent_from_metadata, }; use super::profile::{CodexResidency, CodexWireProfile}; use super::protocol::responses::CodexResponsesRequest; @@ -87,6 +88,11 @@ impl CodexBackendClient { profile: &CodexWireProfile, context: CodexRequestContext<'_>, ) -> CodexClientResult { + if self.protocol == OpenAiUpstreamProtocol::ResponsesApi { + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, HeaderValue::from_str(context.authorization)?); + return Ok(headers); + } let mut headers = build_codex_model_headers(profile, context.authorization, context.account_id)?; insert_optional_header(&mut headers, "cookie", context.cookie_header)?; @@ -114,11 +120,13 @@ impl CodexBackendClient { let mut headers = self.response_headers(request, context)?; headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream")); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - insert_optional_protocol_header( - &mut headers, - X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER, - request.responses_lite.as_deref(), - ); + if self.protocol == OpenAiUpstreamProtocol::Codex { + insert_optional_protocol_header( + &mut headers, + X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER, + request.responses_lite.as_deref(), + ); + } Ok(headers) } @@ -142,6 +150,15 @@ impl CodexBackendClient { context: CodexRequestContext<'_>, ) -> CodexClientResult { let mut headers = self.model_request_headers(&self.profile.snapshot(), context)?; + if self.protocol == OpenAiUpstreamProtocol::ResponsesApi { + // 上游身份只来自该账号;客户端的 OAuth 画像与 Cookie 不跨域转发。 + headers.insert( + HeaderName::from_static("x-client-request-id"), + HeaderValue::from_str(context.request_id)?, + ); + append_passthrough_headers(&mut headers, request, self.protocol); + return Ok(headers); + } headers.insert( HeaderName::from_static("x-client-request-id"), HeaderValue::from_str(context.request_id)?, @@ -181,9 +198,26 @@ impl CodexBackendClient { None => format!("model={}", request.model()), }; insert_optional_protocol_header(&mut headers, "x-codex-routing-hint", Some(&routing_hint)); - for name in request.passthrough_headers.keys() { - // 身份与传输字段只由画像/正文生成;其余协议头保留原始多值字节。 - if matches!( + append_passthrough_headers(&mut headers, request, self.protocol); + Ok(headers) + } +} + +fn append_passthrough_headers( + headers: &mut HeaderMap, + request: &CodexResponsesRequest, + protocol: OpenAiUpstreamProtocol, +) { + for name in request.passthrough_headers.keys() { + // 身份与传输字段只由画像/正文生成;其余协议头保留原始多值字节。 + if (protocol == OpenAiUpstreamProtocol::ResponsesApi + && (name.as_str().starts_with("x-codex-") + || name.as_str().starts_with("x-openai-internal-") + || matches!( + name.as_str(), + "session-id" | "thread-id" | "x-openai-actor-authorization" + ))) + || matches!( name.as_str(), "originator" | "user-agent" @@ -203,15 +237,14 @@ impl CodexBackendClient { | "x-oai-is" | "x-oai-is-update" | X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER - ) { - continue; - } - headers.remove(name); - for value in request.passthrough_headers.get_all(name) { - headers.append(name.clone(), value.clone()); - } + ) + { + continue; + } + headers.remove(name); + for value in request.passthrough_headers.get_all(name) { + headers.append(name.clone(), value.clone()); } - Ok(headers) } } diff --git a/backend/crates/providers/openai/src/transport/mod.rs b/backend/crates/providers/openai/src/transport/mod.rs index 1cfed0bbb..767d04d01 100644 --- a/backend/crates/providers/openai/src/transport/mod.rs +++ b/backend/crates/providers/openai/src/transport/mod.rs @@ -18,6 +18,7 @@ mod response_meta; pub(crate) mod session; pub mod subscription; mod time; +pub(crate) use endpoints::valid_upstream_base_url; pub mod tls; pub mod usage; pub mod websocket; diff --git a/backend/crates/providers/openai/src/transport/websocket/handshake.rs b/backend/crates/providers/openai/src/transport/websocket/handshake.rs index 4f6d6db31..1cde1c8ba 100644 --- a/backend/crates/providers/openai/src/transport/websocket/handshake.rs +++ b/backend/crates/providers/openai/src/transport/websocket/handshake.rs @@ -76,8 +76,29 @@ impl CodexWebSocketConnection { business_headers: Vec<(String, String)>, request: &CodexResponsesRequest, ) -> Result { + Self::responses_create_request_for_path( + base_url, + CODEX_RESPONSES_PATH, + websocket_key, + business_headers, + request, + ) + } + + pub(crate) fn responses_create_request_for_path( + base_url: &str, + path: &str, + websocket_key: &str, + business_headers: Vec<(String, String)>, + request: &CodexResponsesRequest, + ) -> Result { + let mut connection = Self::responses(base_url, websocket_key, business_headers); + let endpoint = crate::transport::endpoint_url(base_url, path); + connection.endpoint = endpoint + .replacen("https://", "wss://", 1) + .replacen("http://", "ws://", 1); Ok(CodexWebSocketRequest { - connection: Self::responses(base_url, websocket_key, business_headers), + connection, payload_text: websocket_response_create_payload_text(request)?, continuation: WebSocketContinuationRequirement::from_request(request), }) diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index d5021f8f6..f572f7839 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -1991,3 +1991,78 @@ mod errors { } } } + +#[tokio::test] +async fn api_key_admin_exposes_only_configuration_and_preserves_key_when_rotating_address() { + let store = Arc::new(MemoryAccountStore::default()); + store + .seed_api_key( + "acct_api_admin", + "https://first.example/v1".to_owned(), + provider_openai::credential::ApiKeyTransport::Http, + ) + .await; + let account = store.account("acct_api_admin").unwrap(); + let config = valid_config(); + let bundle = provider_openai::initialize( + config.config.clone(), + provider_ports_with(Arc::clone(&store), Arc::new(TestOAuthPending::default())), + ) + .await + .unwrap(); + let admin = bundle.admin_provider(); + let configuration = admin + .account_configuration(account.id()) + .await + .unwrap() + .unwrap(); + let configuration = configuration.expose_to_provider().expose_to_provider(); + assert_eq!(configuration.len(), 2); + assert_eq!( + configuration.get("base_url"), + Some(&json!("https://first.example/v1")) + ); + assert!(!configuration.contains_key("api_key")); + let prepared = admin + .prepare_rotation(PrepareCredentialRotation { + account: account_record(&account), + provider_material: ProviderDocument::new(OpaqueProviderData::new( + json!({"base_url":"https://second.example/root", "transport":"prefer_websocket"}) + .as_object() + .unwrap() + .clone(), + )), + }) + .await + .unwrap(); + let material = prepared + .facts() + .provider_material + .expose_to_provider() + .expose_to_provider(); + assert_eq!(material.get("api_key"), Some(&json!("sk-api-test-only"))); + assert_eq!( + material.get("base_url"), + Some(&json!("https://second.example/root")) + ); + assert!(!prepared.facts().has_refresh_token); + assert_eq!(prepared.facts().account_id, *account.id()); + assert_eq!( + admin + .prepare_refresh(PrepareCredentialRefresh { + account: account_record(&account) + }) + .await + .unwrap_err() + .kind(), + ProviderAdminErrorKind::Unsupported + ); + assert_eq!( + admin.subscription(account.id()).await.unwrap_err().kind(), + ProviderAdminErrorKind::Unsupported + ); + assert_eq!( + admin.reset_credits(account.id()).await.unwrap_err().kind(), + ProviderAdminErrorKind::Unsupported + ); +} diff --git a/backend/crates/providers/openai/tests/credential/admin.rs b/backend/crates/providers/openai/tests/credential/admin.rs index 5963459b5..57e5ab6bd 100644 --- a/backend/crates/providers/openai/tests/credential/admin.rs +++ b/backend/crates/providers/openai/tests/credential/admin.rs @@ -837,3 +837,116 @@ async fn pat_import_uses_account_proxy_without_direct_fallback() { ); } } + +#[tokio::test] +async fn api_key_import_export_preserves_target_without_oauth_exchange() { + use gateway_core::account::LoadedCredential; + use provider_openai::credential::{ + CodexCredentialAdmin, CodexCredentialData, ExportManagedCodexCredential, + }; + let service = CodexCredentialAdminService::new( + Arc::new(UnusedRefresher), + Arc::new(TestLeaseCoordinator::default()), + runtime_policy(), + ); + let imported = service.prepare_import_document(serde_json::json!({ + "provider": "openai", "authentication_kind": "api_key", "name": "relay", "base_url": "https://relay.example/custom/v2", "api_key": "sk-test-only" + })).await.expect("import API account").into_accounts().pop().expect("one account"); + assert_eq!(imported.account.authentication_kind(), "api_key"); + assert!(!imported.account.has_refresh_token()); + assert_eq!(imported.account.upstream_account_id(), None); + assert!( + !format!( + "{:?}", + CodexCredentialCodec::decode(&imported.credential).unwrap() + ) + .contains("sk-test-only") + ); + let now = Utc::now(); + let document = CodexCredentialAdmin + .format_cpr_export(vec![ExportManagedCodexCredential { + current: LoadedCredential { + account: imported.account, + credential: imported.credential, + }, + added_at: now, + updated_at: now, + }]) + .expect("export") + .into_json() + .expect("JSON"); + let restored = service + .prepare_import_document(document) + .await + .expect("reimport") + .into_accounts() + .pop() + .unwrap(); + let CodexCredentialData::ApiKey(data) = + CodexCredentialCodec::decode_complete(&restored.credential).unwrap() + else { + panic!("API credential") + }; + assert_eq!(data.base_url, "https://relay.example/custom/v2"); + assert_eq!(data.api_key, "sk-test-only"); + assert_eq!( + data.transport, + provider_openai::credential::ApiKeyTransport::Http + ); +} + +#[tokio::test] +async fn api_key_import_rejects_unsafe_urls_and_empty_keys() { + let service = CodexCredentialAdminService::new( + Arc::new(UnusedRefresher), + Arc::new(TestLeaseCoordinator::default()), + runtime_policy(), + ); + for (url, key) in [ + ("http://remote.example/v1", "sk-test"), + ("https://user:password@example.com", "sk-test"), + ("https://example.com?token=secret", "sk-test"), + ("https://example.com/#fragment", "sk-test"), + ("https://example.com", ""), + ("https://example.com", "sk-test\r\nx-header: value"), + ] { + assert!(service.prepare_import_document(serde_json::json!({"authentication_kind":"api_key","base_url":url,"api_key":key})).await.is_err()); + } + let prepared = service.prepare_import_document(serde_json::json!({"platform":"openai","type":"apikey","credentials":{"base_url":"https://example.com","api_key":"sk-test"}})).await.unwrap(); + let value = prepared.accounts()[0].credential.expose_to_provider(); + assert_eq!( + value.get("base_url"), + Some(&serde_json::json!("https://example.com/v1")) + ); +} + +#[tokio::test] +async fn sub2api_api_key_import_preserves_versioned_and_explicit_responses_paths() { + let service = CodexCredentialAdminService::new( + Arc::new(UnusedRefresher), + Arc::new(TestLeaseCoordinator::default()), + runtime_policy(), + ); + for (base, expected) in [ + ("https://example.com", "https://example.com/v1"), + ( + "https://example.com/custom/v4", + "https://example.com/custom/v4", + ), + ( + "https://example.com/custom/responses", + "https://example.com/custom", + ), + ("https://example.com/v1beta", "https://example.com/v1beta"), + ] { + let prepared = service.prepare_import_document(serde_json::json!({"platform":"openai","type":"apikey","credentials":{"base_url":base,"api_key":"sk-test","model_mapping":{}}})).await.unwrap(); + assert_eq!( + prepared.accounts()[0] + .credential + .expose_to_provider() + .get("base_url"), + Some(&serde_json::json!(expected)) + ); + } + assert!(service.prepare_import_document(serde_json::json!({"platform":"openai","type":"apikey","credentials":{"api_key":"sk-test"},"extra":{"openai_api_key_responses_websockets_v2_mode":"http_bridge"}})).await.is_err()); +} diff --git a/backend/crates/providers/openai/tests/credential/catalog.rs b/backend/crates/providers/openai/tests/credential/catalog.rs index 37cde1b4f..94f1c838d 100644 --- a/backend/crates/providers/openai/tests/credential/catalog.rs +++ b/backend/crates/providers/openai/tests/credential/catalog.rs @@ -169,8 +169,11 @@ async fn client_catalog_is_account_and_version_scoped_and_keeps_original_objects .client_model_catalog(&client_scope(&accounts), version) .await .expect("catalog"); - let document: serde_json::Value = - serde_json::from_slice(models[0].payload.body()).expect("document"); + let document: serde_json::Value = serde_json::from_slice(match &models[0].content { + gateway_core::routing::ProviderModelContent::Native(payload) => payload.body(), + _ => panic!("expected native model"), + }) + .expect("document"); assert_eq!( document, serde_json::json!({ @@ -752,3 +755,317 @@ fn invalid_response_etag_is_rejected_without_touching_the_catalog() { assert!(service.cached().expect("cache read").is_none()); assert_eq!(service.catalog_generation().get(), 0); } + +#[tokio::test] +async fn api_key_catalogs_are_isolated_and_join_oauth_without_claiming_native_metadata() { + use provider_openai::credential::{ApiKeyTransport, CodexCatalogScope}; + let oauth = MockServer::start().await; + let first = MockServer::start().await; + let second = MockServer::start().await; + let store = Arc::new(MemoryAccountStore::default()); + let oauth_account = seed_account(&store, "acct_oauth").await; + store + .seed_api_key("acct_first", first.uri(), ApiKeyTransport::Http) + .await; + store + .seed_api_key("acct_second", second.uri(), ApiKeyTransport::Http) + .await; + let a = store.account("acct_first").unwrap(); + let b = store.account("acct_second").unwrap(); + assert_ne!( + CodexCatalogScope::for_account(&a).unwrap(), + CodexCatalogScope::for_account(&b).unwrap() + ); + Mock::given(method("GET")) + .and(path("/codex/models")) + .respond_with(ResponseTemplate::new(200).set_body_json( + serde_json::json!({"models":[{"slug":"oauth-model","display_name":"OAuth model"}]}), + )) + .mount(&oauth) + .await; + Mock::given(method("GET")) + .and(path("/models")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"data":[{"id":"api-first"}]})), + ) + .mount(&first) + .await; + Mock::given(method("GET")) + .and(path("/models")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"data":[{"id":"api-second"}]})), + ) + .mount(&second) + .await; + let service = service_with_catalog_cache(&store, oauth.uri(), catalog_cache()); + service.synchronize().await.unwrap(); + assert_eq!( + service.cached_account_models(&a).unwrap().unwrap(), + ["api-first"] + ); + assert_eq!( + service.cached_account_models(&b).unwrap().unwrap(), + ["api-second"] + ); + let combined = service + .client_model_catalog(&client_scope(&[oauth_account, a.clone()]), "1.0.0") + .await + .unwrap(); + assert_eq!( + combined + .iter() + .map(|entry| entry.model.as_str()) + .collect::>(), + ["oauth-model", "api-first"] + ); + assert!(matches!( + combined[0].content, + gateway_core::routing::ProviderModelContent::Native(_) + )); + assert!(matches!( + combined[1].content, + gateway_core::routing::ProviderModelContent::Adapted(_) + )); + first.reset().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(503)) + .mount(&first) + .await; + service.invalidate().unwrap(); + service + .synchronize() + .await + .expect("failed upstream must not hide other accounts"); + assert_eq!( + service.cached_account_models(&b).unwrap().unwrap(), + ["api-second"] + ); +} + +#[tokio::test] +async fn api_key_catalog_accepts_namespaced_model_ids_without_inventing_capabilities() { + use provider_openai::credential::ApiKeyTransport; + use provider_openai::transport::CodexCatalogCapabilityEvidence; + + let upstream = MockServer::start().await; + let store = Arc::new(MemoryAccountStore::default()); + store + .seed_api_key("acct_api", upstream.uri(), ApiKeyTransport::Http) + .await; + let ids = [ + "gpt-5.4", + "ft:gpt-4.1-mini:example:custom:abc", + "vendor/model", + ]; + Mock::given(method("GET")) + .and(path("/models")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": ids.map(|id| serde_json::json!({"id": id})) + }))) + .mount(&upstream) + .await; + let service = service_with_catalog_cache(&store, upstream.uri(), catalog_cache()); + let snapshot = service.synchronize().await.expect("standard API IDs"); + assert_eq!( + snapshot + .models() + .iter() + .map(|model| model.request_model().as_str()) + .collect::>(), + ids + ); + for model in snapshot.models() { + assert_eq!( + model.capabilities().reasoning(), + CodexCatalogCapabilityEvidence::Unknown + ); + assert_eq!(model.limits().context_window_tokens(), None); + } + let account = store.account("acct_api").expect("API account"); + let models = service + .client_model_catalog(&client_scope(&[account]), "1.0.0") + .await + .expect("client catalog"); + assert_eq!( + models + .iter() + .map(|model| model.model.as_str()) + .collect::>(), + ids.into_iter().collect() + ); +} + +#[tokio::test] +async fn api_key_catalog_rejects_invalid_ids_and_duplicates() { + use provider_openai::credential::ApiKeyTransport; + + let upstream = MockServer::start().await; + let store = Arc::new(MemoryAccountStore::default()); + store + .seed_api_key("acct_api", upstream.uri(), ApiKeyTransport::Http) + .await; + let service = service_with_catalog_cache(&store, upstream.uri(), catalog_cache()); + for invalid in [ + String::new(), + "__reserved".to_owned(), + "model\nname".to_owned(), + " model".to_owned(), + "x".repeat(257), + "valid-model".to_owned(), + ] { + upstream.reset().await; + Mock::given(method("GET")) + .and(path("/models")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": [{"id":"valid-model"}, {"id":invalid}] + }))) + .mount(&upstream) + .await; + assert!( + service.synchronize().await.is_err(), + "invalid entry accepted: {invalid:?}" + ); + assert!(service.cached().expect("cache").is_none()); + } +} + +#[tokio::test] +async fn api_key_catalog_does_not_replace_native_metadata_for_shared_models() { + use gateway_core::routing::ProviderModelContent; + use provider_openai::credential::ApiKeyTransport; + + let oauth = MockServer::start().await; + let upstream = MockServer::start().await; + let store = Arc::new(MemoryAccountStore::default()); + let oauth_account = seed_account(&store, "acct_oauth").await; + Mock::given(method("GET")).and(path("/codex/models")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"models":[{ + "slug":"gpt-5.4", "display_name":"Official Model", "context_window":272000, + "input_modalities":["text","image"], "supported_reasoning_levels":[{"effort":"high"}] + }]}))) + .mount(&oauth).await; + let service = service_with_catalog_cache(&store, oauth.uri(), catalog_cache()); + let before = service.synchronize().await.expect("OAuth directory"); + store + .seed_api_key("acct_api", upstream.uri(), ApiKeyTransport::Http) + .await; + Mock::given(method("GET")) + .and(path("/models")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"data":[{"id":"gpt-5.4"}]})), + ) + .mount(&upstream) + .await; + service.invalidate().expect("invalidate"); + let after = service.synchronize().await.expect("mixed directory"); + assert_eq!( + after.models(), + before.models(), + "preserve the complete native model" + ); + + let api_account = store.account("acct_api").expect("API account"); + assert_eq!( + service.cached_account_models(&api_account).unwrap(), + service.cached_account_models(&oauth_account).unwrap() + ); + let mixed = service + .client_model_catalog( + &client_scope(&[oauth_account, api_account.clone()]), + "1.0.0", + ) + .await + .expect("mixed client catalog"); + assert_eq!(mixed.len(), 1); + let ProviderModelContent::Native(payload) = &mixed[0].content else { + panic!("native metadata expected") + }; + assert_eq!(payload, before.models()[0].document()); + let api_only = service + .client_model_catalog(&client_scope(&[api_account]), "1.0.0") + .await + .expect("API client catalog"); + assert!(matches!( + api_only[0].content, + ProviderModelContent::Adapted(_) + )); +} + +#[tokio::test] +async fn slow_api_key_catalogs_share_a_deadline_and_preserve_healthy_catalogs() { + use provider_openai::credential::ApiKeyTransport; + use std::time::Duration; + + let upstream = MockServer::start().await; + let store = Arc::new(MemoryAccountStore::default()); + let mut accounts = vec![seed_account(&store, "acct_oauth").await]; + Mock::given(method("GET")) + .and(path("/codex/models")) + .respond_with(ResponseTemplate::new(200).set_body_json( + serde_json::json!({"models":[{"slug":"oauth-model","display_name":"OAuth model"}]}), + )) + .mount(&upstream) + .await; + Mock::given(method("GET")) + .and(path("/fast/models")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"data":[{"id":"api-model"}]})), + ) + .mount(&upstream) + .await; + Mock::given(method("GET")) + .and(path("/slow/models")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"data":[{"id":"slow-model"}]})) + .set_delay(Duration::from_secs(60)), + ) + .mount(&upstream) + .await; + store + .seed_api_key( + "acct_fast", + format!("{}/fast", upstream.uri()), + ApiKeyTransport::Http, + ) + .await; + accounts.push(store.account("acct_fast").expect("fast API account")); + for index in 0..6 { + let id = format!("acct_slow_{index}"); + store + .seed_api_key( + &id, + format!("{}/slow", upstream.uri()), + ApiKeyTransport::Http, + ) + .await; + accounts.push(store.account(&id).expect("slow API account")); + } + let service = service_with_catalog_cache(&store, upstream.uri(), catalog_cache()); + let scope = client_scope(&accounts); + let slow_id = ProviderAccountId::new("acct_slow_0").expect("account ID"); + let (client, refresh) = tokio::time::timeout(Duration::from_secs(17), async { + tokio::join!( + service.client_model_catalog(&scope, "1.0.0"), + service.refresh_account_catalog(&slow_id) + ) + }) + .await + .expect("catalog deadline must bound both client reads and account refreshes"); + let models = client.expect("healthy catalogs remain available"); + assert_eq!( + models + .iter() + .map(|model| model.model.as_str()) + .collect::>(), + ["oauth-model", "api-model"] + ); + assert!(matches!( + refresh, + Err(CodexCredentialCatalogError::Upstream { .. }) + )); +} diff --git a/backend/crates/providers/openai/tests/provider/contract.rs b/backend/crates/providers/openai/tests/provider/contract.rs index f734ccf99..6f08b4879 100644 --- a/backend/crates/providers/openai/tests/provider/contract.rs +++ b/backend/crates/providers/openai/tests/provider/contract.rs @@ -6900,7 +6900,11 @@ async fn provider_compiles_catalog_presentation_for_codex_models() { .expect("native catalog") .expect("supported"); let original: Value = serde_json::from_slice(OFFICIAL_FIXTURE).expect("fixture"); - let document: Value = serde_json::from_slice(native[0].payload.body()).expect("document"); + let document: Value = serde_json::from_slice(match &native[0].content { + gateway_core::routing::ProviderModelContent::Native(payload) => payload.body(), + _ => panic!("expected native model"), + }) + .expect("document"); assert_eq!(document, original["models"][0]); assert!( server @@ -7519,3 +7523,183 @@ async fn connection_limit_payload_survives_exhausted_retry_budget() { Some("req-limit-final") ); } + +#[tokio::test] +async fn api_key_default_http_uses_own_prefix_plain_json_and_only_own_authentication() { + for prefix in ["", "/v1", "/custom/v2"] { + let upstream = MockServer::start().await; + let oauth = MockServer::start().await; + let store = Arc::new(MemoryAccountStore::default()); + store + .seed_api_key( + "acct_provider_contract", + format!("{}{prefix}", upstream.uri()), + provider_openai::credential::ApiKeyTransport::Http, + ) + .await; + Mock::given(method("GET")) + .and(path(format!("{prefix}/models"))) + .and(header("authorization", "Bearer sk-api-test-only")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({"data":[{"id":"gpt-5.4"}]})), + ) + .expect(1) + .mount(&upstream) + .await; + Mock::given(method("POST")) + .and(path(format!("{prefix}/responses"))) + .and(header("authorization", "Bearer sk-api-test-only")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(CAPTURE_COMPLETED_SSE, "text/event-stream"), + ) + .expect(1) + .mount(&upstream) + .await; + let (provider, quota) = provider_and_quota_with_affinity_and_base_url_and_leases( + &store, + Arc::new(MemorySessionAffinity::default()), + oauth.uri(), + Arc::new(TestLeaseCoordinator::default()), + DEFAULT_STREAM_MAX_RETRIES as u32, + ); + provider + .query_model_capabilities() + .await + .expect("standard API catalog"); + let catalog = provider + .query_client_model_catalog(&contract_account_scope(), "codex", "1.0.0") + .await + .unwrap() + .unwrap(); + assert_eq!(catalog.len(), 1); + assert!(matches!( + catalog[0].content, + gateway_core::routing::ProviderModelContent::Adapted(_) + )); + let mut stream = provider + .execute( + planned_request("openai", generate_operation()), + context("req_api_http", CancellationToken::new()), + ) + .await + .expect("scheduled API account"); + while let Some(event) = stream.next().await { + event.expect("completed API response"); + } + quota.synchronize().await.expect("skip API account quota"); + assert!(oauth.received_requests().await.unwrap().is_empty()); + let requests = upstream.received_requests().await.unwrap(); + let request = requests + .iter() + .find(|request| request.method == "POST") + .unwrap(); + assert!(!request.headers.contains_key("content-encoding")); + for header in [ + "cookie", + "chatgpt-account-id", + "originator", + "version", + "x-codex-routing-hint", + ] { + assert!(!request.headers.contains_key(header), "unexpected {header}"); + } + let body: Value = serde_json::from_slice(&request.body).expect("ordinary JSON"); + assert_eq!(body["stream"], true); + assert_eq!(body["model"], "gpt-5.4"); + } +} + +#[tokio::test] +async fn api_key_http_account_is_rejected_before_websocket_warmup_or_old_revision_continuation() { + let upstream = MockServer::start().await; + let store = Arc::new(MemoryAccountStore::default()); + store + .seed_api_key( + "acct_provider_contract", + upstream.uri(), + provider_openai::credential::ApiKeyTransport::Http, + ) + .await; + let provider = provider(&store); + let warmup = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "openai", + json!({"model":"gpt-5.4","input":[],"store":false,"generate":false}) + .as_object() + .unwrap() + .clone(), + ) + .unwrap(), + )); + assert!( + provider + .execute( + planned_request("openai", warmup), + diagnostic_context("req_api_warmup", "acct_provider_contract") + ) + .await + .is_err() + ); + let Operation::Generate(generate) = generate_operation() else { + panic!("generate") + }; + let stale = generate.with_provider_session_state(ProviderSessionState::new("openai", json!({"account_id":"acct_provider_contract","conversation_id":"old","credential_revision":9,"continuation_scope":"persisted"}).as_object().unwrap().clone()).unwrap()); + assert!( + provider + .execute( + planned_request("openai", Operation::Generate(stale)), + diagnostic_context("req_api_stale", "acct_provider_contract") + ) + .await + .is_err() + ); + assert!(upstream.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn api_key_websocket_uses_api_path_and_bearer_without_oauth_identity() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}/custom/v2", listener.local_addr().unwrap()); + let store = Arc::new(MemoryAccountStore::default()); + store + .seed_api_key( + "acct_provider_contract", + base, + provider_openai::credential::ApiKeyTransport::PreferWebsocket, + ) + .await; + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = + crate::transport::accept_codex_test_websocket_with(stream, |request, _| { + assert_eq!(request.uri().path(), "/custom/v2/responses"); + assert_eq!( + request.headers()["authorization"], + "Bearer sk-api-test-only" + ); + assert!(!request.headers().contains_key("chatgpt-account-id")); + assert!(!request.headers().contains_key("cookie")); + assert!(!request.headers().contains_key("originator")); + }) + .await; + let frame = websocket.next().await.unwrap().unwrap(); + let payload: Value = serde_json::from_str(frame.to_text().unwrap()).unwrap(); + assert_eq!(payload["type"], "response.create"); + websocket.send(Message::Text(json!({"type":"response.completed","response":{"id":"resp_api_ws","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}).to_string().into())).await.unwrap(); + }); + let provider = provider(&store); + let mut stream = provider + .execute( + planned_request("openai", generate_operation()), + diagnostic_context("req_api_ws", "acct_provider_contract"), + ) + .await + .unwrap(); + while let Some(event) = stream.next().await { + event.expect("API WebSocket completion"); + } + timeout(Duration::from_secs(5), server) + .await + .unwrap() + .unwrap(); +} diff --git a/backend/crates/providers/openai/tests/support.rs b/backend/crates/providers/openai/tests/support.rs index 9066d92fa..2736292e7 100644 --- a/backend/crates/providers/openai/tests/support.rs +++ b/backend/crates/providers/openai/tests/support.rs @@ -62,6 +62,49 @@ impl MemoryAccountStore { .expect("seed test OAuth credential"); } + pub(crate) async fn seed_api_key( + &self, + id: &str, + base_url: String, + transport: provider_openai::credential::ApiKeyTransport, + ) { + let credential = provider_openai::credential::CodexCredentialCodec::encode_complete( + provider_openai::credential::CodexCredentialData::ApiKey( + provider_openai::credential::ApiKeyCredentialData { + schema_version: 1, + installation_id: uuid::Uuid::new_v4().to_string(), + base_url, + api_key: "sk-api-test-only".to_owned(), + transport, + }, + ), + ) + .expect("API Key schema"); + let account = ProviderAccount::new( + ProviderAccountId::new(id).expect("id"), + ProviderKind::new("openai").expect("kind"), + id.to_owned(), + None, + "api_key".to_owned(), + CredentialRevision::new(1).expect("revision"), + None, + ) + .with_account_facts( + true, + CredentialState::Ready, + QuotaState::unknown(), + None, + None, + ); + self.create_account(NewProviderAccount { + account, + credential, + model_access: None, + }) + .await + .expect("seed API account"); + } + pub(crate) fn account(&self, id: &str) -> Option { let id = ProviderAccountId::new(id).ok()?; self.accounts diff --git a/docs/api.md b/docs/api.md index 0eac785fa..b311fc715 100644 --- a/docs/api.md +++ b/docs/api.md @@ -166,12 +166,15 @@ Responses 不透传下游的逐跳头、反代元数据(如 `cf-*`、`x-forwar `cdn-loop`)以及 `Accept-Encoding` / `Content-Encoding`。链路元数据和编解码能力 由各段传输层独立管理;其余业务扩展头继续透传,不使用固定业务头白名单。 此规则同时适用于上游 HTTP 和 WebSocket,不影响上游响应的 `cf-ray` 等诊断信息。 +API Key 上游还会移除 Cookie、ChatGPT 账号身份、`x-codex-*`、`x-openai-internal-*`、会话/线程身份头及 +`X-OpenAI-Actor-Authorization`,避免把 OAuth 或网关托管身份传给第三方 API。 Responses WebSocket 仅接受文本 `response.create`,同一连接串行执行。当前响应期间收到的后续业务帧 留在有界接收队列中,待当前响应完成终结和写出后再逐条校验、准入与执行,不因请求提前到达而断开。 接收队列容量为 32 个事件,超载仍关闭连接;Ping/Pong、客户端关闭和服务关闭不等待队列中的请求执行。 -客户端使用 HTTP/SSE 时,OpenAI Provider 仍可能选择上游 WebSocket。 +OAuth 账号在客户端使用 HTTP/SSE 时仍可能选择上游 WebSocket。API Key 账号默认使用 HTTP/SSE, +可在账号上配置 `prefer_websocket`;必须依赖 WS 的预热、非持久化新链和连接内续接不使用 HTTP-only 账号。 客户端配置的 `supports_websockets` 只控制第一段连接,不是服务端传输策略开关。 上游在响应终态前发送 Close 1000 仍属于失败,不能按“正常关闭”计为成功。 @@ -318,7 +321,7 @@ Token 明细、费用明细、用时/首字与状态。Token 和费用复用现 | `POST` | `/api/admin/accounts/import` | `{ provider, data, settings?, outboundProxyId? }` | 导入或按上游身份更新账号,可同时应用调度、分组设置与默认代理 | | `POST` | `/api/admin/accounts/refresh` | `{ accountId }` | 手工刷新 OAuth credential(`idToken` / `accessToken` / `refreshToken`),不刷新额度 | | `POST` | `/api/admin/accounts/recover` | `{ accountId }` | 管理员显式清除该账号的本地错误/额度/cooldown 事实并重新启用,不访问上游 | -| `POST` | `/api/admin/accounts/rotate` | OpenAI rotation 字段 | 手工替换 OpenAI OAuth token | +| `POST` | `/api/admin/accounts/rotate` | OpenAI rotation 字段 | 更新指定 OpenAI 账号的 OAuth token 或 API Key 上游设置 | | `POST` | `/api/admin/accounts/update` | `{ accountId, enabled, concurrencyLimit, weight, groupIds, notes?, modelAccess?, outboundProxyId?, outboundProxyUrl? }` | 一次更新账号备注、调度状态、并发上限(`null` 表示继承运行参数)、权重(1–100)、所属分组与出站代理 | | `POST` | `/api/admin/accounts/batch-update` | `{ accountIds, enabled?, concurrencyLimit?, weight?, groupIds?, modelAccess?, outboundProxyId?, outboundProxyUrl? }` | 一次事务更新所选账号;仅修改提供的字段,至少提供一项修改 | | `POST` | `/api/admin/accounts/delete` | `{ provider, accountIds }` | 批量删除 1–200 个账号 | @@ -455,7 +458,7 @@ OAuth 等待回调期间不持有保护;提交仍拒绝已删除、连接配 导入的 `data` 必须是 JSON object,Admin API 请求上限为 64 MiB;Provider 可以收紧限制, 当前 xAI 导入上限为 16 MiB。内部 schema 由目标 Provider 独占解释: -- OpenAI 接受单账号 OAuth 文档、`accounts` 数组(最多 200 项)、CPR 账号 bundle 和含代理引用的 sub2api 导出; +- OpenAI 接受单账号 OAuth 或 API Key 文档、`accounts` 数组(最多 200 项)、CPR 账号 bundle 和含代理引用的 sub2api 导出; - OpenAI OAuth token 字段接受 `accessToken`、`refreshToken`、`idToken`,以及官方 `auth.json` 中的 `access_token`、`refresh_token`、`id_token`,可以嵌套在 `tokens` 等账号 object 内; 每项至少包含 AT 或 RT。仅含 `OPENAI_API_KEY` 的客户端代理配置不是 OAuth 账号导入材料; @@ -465,7 +468,38 @@ OAuth 等待回调期间不持有保护;提交仍拒绝已删除、连接配 - xAI 从单账号 object 或 `accounts` 数组中提取 OAuth token;并发、优先级等字段不参与认证; - xAI 批量导入逐条独立校验:失败条目跳过并记录日志,不中断其余条目,仅当没有任何条目成功时整个导入才报错; - xAI API Key 不是受支持的账号 credential; -- 导入不会只凭文件外形写入账号;目标 Provider 使用认证材料完成必要的 token exchange 或已认证账号资料补全。 +- OAuth 导入由目标 Provider 完成必要的 token exchange 或身份投影。API Key 导入校验凭据格式与地址,不调用 OAuth 或 ChatGPT 身份接口;可用性由模型目录和连接测试验证。 + +API Key 账号使用以下独立凭据形态: + +```json +{ + "provider": "openai", + "data": { + "authentication_kind": "api_key", + "name": "团队上游", + "base_url": "https://api.example.com/v1", + "api_key": "", + "transport": "http" + } +} +``` + +`base_url` 是 API 前缀,追加 `responses` 和 `models`,不会自动补 `/v1`;支持根路径和自定义前缀。 +地址仅允许 HTTPS,HTTP 只允许本机回环;拒绝 URL 中的认证信息、查询串和 fragment。 +`transport` 可省略(默认 `http`)或设为 `prefer_websocket`。API Key 使用 Bearer 认证与普通 JSON, +不携带 OAuth Cookie 或 ChatGPT 身份。上游须提供标准 `/models` 列表,模型目录按账号和凭据版本隔离。 +API Key 每次导入创建独立账号;更新已有账号使用 `rotate`。导出会显式包含密钥,沿用敏感导出的确认合同。 + +sub2api 的 `platform=openai`、`type=apikey` 使用 `credentials.base_url` / `credentials.api_key`; +导入时按其端点规则将服务根、版本前缀或完整 `/responses` 地址转换为 API 前缀,缺省地址为官方 `/v1`。 +非空模型映射、请求头覆盖、其他协议和启用的 `extra.openai_*` 设置尚未适配,返回输入错误,需先移除并在本项目重新配置。 + +API Key 账号支持 Responses、模型目录、连接测试和使用统计中的本地用量。OAuth 刷新/重新授权、ChatGPT 额度、个人资料、订阅、重置卡 +及 Images、standalone Search、Responses Lite / compact 专用入口不对这类账号开放。 +客户端的 `image_generation` 和 `X-OpenAI-Actor-Authorization` 声明不改变账号的实际能力。 +账号列表和详情的 API Key `usage` 汇总该账号创建后仍保留的本地请求记录,`windowLabelDisplay` 为 `通用额度`; +日志清理会影响累计范围,不代表上游余额。OAuth 账号仍按实际周/月额度窗口统计。 批量导入 AT / RT 使用 `accounts` JSON 数组,最多 200 项,不接受纯文本 token 列表。例如: @@ -488,7 +522,7 @@ RT-only 使用同一形状,只提交 `refreshToken`。不得把真实 token `weight` 为 1–100,`groupIds` 为完整分组集合。设置应用于本次导入的全部账号,包括匹配到的已有账号, 与凭据在同一事务内提交;分组不存在时整次回滚。省略 `settings` 时新账号使用默认设置并保持未分组, 已有账号保留原有分组、权重与并发设置。可选 `notes` 与编辑备注使用相同的校验和清空语义,省略或 `null` 保留已有备注; -管理端新建表单留空时省略 `notes`。重新授权不接受 `settings`,普通 credential refresh/rotation 也保留账号设置。 +管理端新建表单留空时省略 `notes`。重新授权不接受 `settings`,credential refresh 和未携带 `settings` 的 rotation 保留账号设置。 账号列表的每个 item 返回轻量 `groups: [{ id, name, enabled }]`。 @@ -509,6 +543,13 @@ OpenAI rotation 请求字段为: } ``` +API Key rotation 使用 `{ provider: "openai", accountId, baseUrl, transport, apiKey?, settings? }`。 +省略 `apiKey` 保留当前密钥,空字符串无效;账号 ID 和认证类型不能通过轮换转换。 +rotation 可选携带 `settings`,字段与 `POST /api/admin/accounts/update` 相同,其中 `accountId` 必须与外层一致。 +凭据与设置在同一事务中保存,任一校验或持久化失败均不落库;省略 `settings` 保留现有分组、调度等设置。 +`GET /api/admin/accounts/detail` 对 API Key 账号额外返回 `credentialConfiguration: { base_url, transport }`,不回显密钥。 +更新会推进凭据 revision 并失效目录与连接;旧版本会话不可静默续接到新上游。 + OAuth start 使用: ```json @@ -544,7 +585,7 @@ OAuth start 使用: `refreshToken`。刷新响应中的三个 token 字段均按官方语义独立轮换:返回新值时替换,省略时分别保留 现值。重新授权也保留这些回调保护,但只轮换目标账号的 token。回调地址只承载 `code`/`state`, 不以 host/path 形式作为拒绝条件。 -- 账号文件导入和 OAuth complete(包括重新授权)在 credential 提交后后台尝试一次额度观测,不等待 +- OAuth 账号文件导入和 OAuth complete(包括重新授权)在 credential 提交后后台尝试一次额度观测,不等待 观测完成才返回成功。观测失败只记录告警,不回滚已提交的账号;手工或后台 RT 刷新只更新 token, 不隐式等同于手工额度刷新,也不更新既有账号资料或 OAuth principal。xAI 导入与 OAuth complete 使用相同的提交后观察流程。 diff --git a/docs/architecture.md b/docs/architecture.md index 7566fb4e5..f13604f72 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -186,6 +186,13 @@ Images 与 standalone Search 是 OpenAI Provider 自有端点:两者都不参 Core 只理解 `Operation`、能力要求、Provider 候选、稳定错误和 canonical event,不读取 Provider SDK 类型。Provider 独占 credential schema、OAuth、账号选择、模型目录、额度投影和上游 transport。 +OpenAI 的 OAuth 与 API Key 共用现有账号和事务。API Key 的 Base URL、密钥和传输策略属于 Provider 凭据 JSON, +随 credential revision 更新;普通详情只投影非敏感连接设置。API Key 目录按账号和凭据版本隔离,标准 API 模型列表 +通过通用画像输出客户端目录,OAuth 原生对象保留。通用账号层按 Provider 提交的 credential state 调度,不以是否存在 +上游用户 ID 推断可用性;OAuth 未完成身份投影时由 Provider 保持 `unknown`。状态恢复和未补齐身份的凭据轮换保留 `unknown`。 +API Key 默认 HTTP/SSE,可选 WS 优先;选号先验证传输资格,WS pool 与 continuation 按凭据版本隔离。 + + - OpenAI 是透明边界。Responses 请求保留未知字段和字段顺序;SSE、WebSocket、Images 与 standalone Search 的业务正文按原始字节转发。canonical facts 从同一数据旁路提取,只用于路由、观测和计费。 - Responses 的业务扩展头保留原始多值字节;传输与反代请求头分类由 `gateway-protocol` 统一定义, diff --git a/frontend/src/api/modules/accounts.ts b/frontend/src/api/modules/accounts.ts index bff4c54ba..52983b8f9 100644 --- a/frontend/src/api/modules/accounts.ts +++ b/frontend/src/api/modules/accounts.ts @@ -571,3 +571,25 @@ export function completeAccountOAuth(data: AccountOAuthCompleteParam) { data, }) } + +export interface ApiKeyConfiguration { + base_url: string + transport: 'http' | 'prefer_websocket' +} + +export function getAccountDetail(data: AccountIdParam, options: RequestOptions = {}) { + return request<{ account: Account, credentialConfiguration?: ApiKeyConfiguration }>({ + url: '/api/admin/accounts/detail', + method: 'GET', + params: data, + ...options, + }) +} + +export function updateAccountApiKey(data: { accountId: string, baseUrl: string, transport: ApiKeyConfiguration['transport'], apiKey?: string, settings?: AccountUpdateParam }) { + return request<{ accountId: string }>({ + url: '/api/admin/accounts/rotate', + method: 'POST', + data: { provider: 'openai', ...data }, + }) +} diff --git a/frontend/src/components/base/BaseCheckbox.vue b/frontend/src/components/base/BaseCheckbox.vue index 32bed1da9..7c04b07dd 100644 --- a/frontend/src/components/base/BaseCheckbox.vue +++ b/frontend/src/components/base/BaseCheckbox.vue @@ -56,6 +56,6 @@ const boxClasses = computed(() => [ :class="!indeterminate && model ? 'opacity-100' : 'opacity-0'" /> - {{ label }} + {{ label }} diff --git a/frontend/src/components/base/BaseSegmented.vue b/frontend/src/components/base/BaseSegmented.vue index b1fb8a404..6541d13cf 100644 --- a/frontend/src/components/base/BaseSegmented.vue +++ b/frontend/src/components/base/BaseSegmented.vue @@ -30,10 +30,7 @@ const props = withDefaults( const model = defineModel({ required: true }) -const activeIndex = computed(() => { - const index = props.options.findIndex(option => option.value === model.value) - return index >= 0 ? index : 0 -}) +const activeIndex = computed(() => props.options.findIndex(option => option.value === model.value)) const optionCount = computed(() => clamp(props.options.length, 1, Number.POSITIVE_INFINITY)) const gridStyle = computed(() => ({ gridTemplateColumns: `repeat(${optionCount.value}, minmax(0, 1fr))`, @@ -104,7 +101,7 @@ async function handleKeydown(event: KeyboardEvent, index: number) { :aria-disabled="disabled || undefined" > @@ -123,7 +120,7 @@ async function handleKeydown(event: KeyboardEvent, index: number) { :aria-checked="model === option.value" :aria-label="display === 'icon' ? option.label : undefined" :title="display === 'icon' ? option.label : undefined" - :tabindex="model === option.value || (activeIndex === index && !model) ? 0 : -1" + :tabindex="model === option.value || (activeIndex < 0 && index === 0) ? 0 : -1" :disabled="disabled || option.disabled" @click="selectOption(index)" @keydown="handleKeydown($event, index)" diff --git a/frontend/src/views/accounts/components/AccountApiKeyFields.vue b/frontend/src/views/accounts/components/AccountApiKeyFields.vue new file mode 100644 index 000000000..e9a713397 --- /dev/null +++ b/frontend/src/views/accounts/components/AccountApiKeyFields.vue @@ -0,0 +1,33 @@ + + + diff --git a/frontend/src/views/accounts/components/AccountCreateModal/AccountProviderChooser.vue b/frontend/src/views/accounts/components/AccountCreateModal/AccountProviderChooser.vue index 9a35afe74..f79f5b5e3 100644 --- a/frontend/src/views/accounts/components/AccountCreateModal/AccountProviderChooser.vue +++ b/frontend/src/views/accounts/components/AccountCreateModal/AccountProviderChooser.vue @@ -2,6 +2,7 @@ import type { AccountCreateProvider } from './model' import { Openai, Xai } from '@boxicons/vue' import { LayoutGrid } from '@lucide/vue' +import BaseSegmented from '@/components/base/BaseSegmented.vue' import { PROVIDER_DISPLAY_NAMES } from '@/utils/providers' withDefaults( @@ -35,22 +36,22 @@ const providers = [ icon: Xai, }, ] + +function selectProvider(value: string) { + const provider = providers.find(provider => provider.value === value) + if (provider) + emit('select', provider.value) +} diff --git a/frontend/src/views/accounts/components/AccountCreateModal/index.vue b/frontend/src/views/accounts/components/AccountCreateModal/index.vue index c50c34af9..28a28868f 100644 --- a/frontend/src/views/accounts/components/AccountCreateModal/index.vue +++ b/frontend/src/views/accounts/components/AccountCreateModal/index.vue @@ -10,6 +10,7 @@ import BaseModal from '@/components/base/BaseModal/index.vue' import BaseSegmented from '@/components/base/BaseSegmented.vue' import { accountModelAccessError } from '../../utils/modelAccess' import { parseAccountSchedulingForm } from '../../utils/schedulingForm' +import AccountApiKeyFields from '../AccountApiKeyFields.vue' import AccountImportFields from './AccountImportFields.vue' import AccountOAuthFields from './AccountOAuthFields.vue' import AccountSetupFields from './AccountSetupFields.vue' @@ -47,9 +48,9 @@ const mode = computed({ }, }) const importText = computed({ - get: () => form.value.mode === 'oauth' ? '' : form.value.importTexts[form.value.mode], + get: () => form.value.mode === 'oauth' || form.value.mode === 'api_key' ? '' : form.value.importTexts[form.value.mode], set: (value: string) => { - if (form.value.mode !== 'oauth') + if (form.value.mode !== 'oauth' && form.value.mode !== 'api_key') form.value.importTexts[form.value.mode] = value }, }) @@ -109,6 +110,7 @@ function continueToImport() { :disabled="busy" @regenerate="emit('generateOauth')" /> + +export type AccountImportMode = 'oauth' | 'api_key' | 'access_token' | 'refresh_token' | 'json' +export type AccountImportInputMode = Exclude export interface AccountCreateForm { provider: AccountCreateProvider | '' + apiKey: ApiKeyAccountForm notes: string enabled: boolean concurrencyLimit: string @@ -27,6 +30,7 @@ export interface AccountCreateForm { export function emptyAccountCreateForm(): AccountCreateForm { return { provider: '', + apiKey: emptyApiKeyAccountForm(), notes: '', enabled: true, concurrencyLimit: '', diff --git a/frontend/src/views/accounts/components/AccountCreateModal/presenter.ts b/frontend/src/views/accounts/components/AccountCreateModal/presenter.ts index cff020733..0924903d1 100644 --- a/frontend/src/views/accounts/components/AccountCreateModal/presenter.ts +++ b/frontend/src/views/accounts/components/AccountCreateModal/presenter.ts @@ -1,5 +1,6 @@ import type { AccountRow } from '../../constants' import type { AccountCreateForm, AccountCreateProvider } from './model' +import { apiKeyAccountError } from '../../utils/upstreamApiKey' interface AccountCreatePresentationInput { form: AccountCreateForm @@ -12,6 +13,7 @@ interface AccountCreatePresentationInput { const modeOptions = { openai: [ { label: 'OAuth', value: 'oauth' }, + { label: 'API Key', value: 'api_key' }, { label: 'AT', value: 'access_token' }, { label: 'RT', value: 'refresh_token' }, { label: '账号文件', value: 'json' }, @@ -78,6 +80,8 @@ function resolveModal( description = '通过浏览器授权导入 xAI 账号' else if (provider === 'xai') description = '粘贴或上传 xAI 账号文件,匹配已有账号时更新凭据' + else if (input.form.mode === 'api_key') + description = '接入 OpenAI Responses 兼容上游' else if (input.form.mode === 'oauth') description = '通过浏览器授权导入 OpenAI 账号' else if (input.form.mode === 'access_token') @@ -143,7 +147,7 @@ function resolveImportInput( } return { label: '账号文件', - placeholder: '粘贴包含 accessToken 或 refreshToken(可含 idToken)的 JSON 内容', + placeholder: '粘贴 OAuth 或 API Key 账号 JSON 内容', uploadable: true, } } @@ -168,6 +172,8 @@ function canSubmit( ) { if (!provider || input.saving || input.oauthLoading) return false + if (input.form.mode === 'api_key') + return !apiKeyAccountError(input.form.apiKey) if (input.form.mode !== 'oauth') return input.form.importTexts[input.form.mode].trim().length > 0 return Boolean( diff --git a/frontend/src/views/accounts/components/AccountEditModal.vue b/frontend/src/views/accounts/components/AccountEditModal.vue index 4456a86a3..84e58721e 100644 --- a/frontend/src/views/accounts/components/AccountEditModal.vue +++ b/frontend/src/views/accounts/components/AccountEditModal.vue @@ -1,5 +1,6 @@