From f78b1ca487880c6be142ecd53f8812fe3ba69219 Mon Sep 17 00:00:00 2001 From: Zyy <39424122+zyycn@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:27:21 +0800 Subject: [PATCH] feat(openai): allow HTTP upstream API key accounts (#372) Allow HTTP API key base URLs for private networks and container services while retaining OAuth startup URL restrictions. Keep URL credential, query and fragment validation, and cover import, export and address rotation. Co-authored-by: Zyy Co-authored-by: Codex --- backend/crates/providers/openai/src/config.rs | 11 +- .../providers/openai/src/credential/admin.rs | 2 +- .../openai/src/credential/api_key.rs | 2 +- .../openai/src/transport/endpoints.rs | 20 ++- .../providers/openai/src/transport/mod.rs | 2 +- .../crates/providers/openai/tests/admin.rs | 52 ++++---- .../openai/tests/credential/admin.rs | 119 +++++++++++------- docs/api.md | 3 +- .../views/accounts/utils/upstreamApiKey.ts | 5 +- 9 files changed, 126 insertions(+), 90 deletions(-) diff --git a/backend/crates/providers/openai/src/config.rs b/backend/crates/providers/openai/src/config.rs index 9e907bbf1..87c8355ee 100644 --- a/backend/crates/providers/openai/src/config.rs +++ b/backend/crates/providers/openai/src/config.rs @@ -145,7 +145,16 @@ impl Default for CodexApiConfig { impl CodexApiConfig { fn validate(&self) -> Result<(), OpenAiConfigError> { - if !crate::transport::valid_upstream_base_url(&self.base_url) { + let url = crate::transport::parse_upstream_base_url(&self.base_url) + .ok_or(OpenAiConfigError::InvalidField("openai.api.base_url"))?; + // OAuth 启动地址的 HTTP 例外仅用于本机联调,不沿用 API Key 中转账号的传输选择 + 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, + }; + if url.scheme() == "http" && !loopback { 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 b13abf925..7ff697fb3 100644 --- a/backend/crates/providers/openai/src/credential/admin.rs +++ b/backend/crates/providers/openai/src/credential/admin.rs @@ -1484,7 +1484,7 @@ fn parse_api_key_import(value: &Value) -> Result bool { self.base_url.len() <= 2048 && !self.base_url.chars().any(char::is_control) - && crate::transport::valid_upstream_base_url(&self.base_url) + && crate::transport::parse_upstream_base_url(&self.base_url).is_some() } } diff --git a/backend/crates/providers/openai/src/transport/endpoints.rs b/backend/crates/providers/openai/src/transport/endpoints.rs index a307f241c..a36cb154a 100644 --- a/backend/crates/providers/openai/src/transport/endpoints.rs +++ b/backend/crates/providers/openai/src/transport/endpoints.rs @@ -31,22 +31,16 @@ 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)) +/// 共用上游地址格式校验;启动配置可在解析结果上施加更严格的传输约束 +pub(crate) fn parse_upstream_base_url(value: &str) -> Option { + let url = url::Url::parse(value).ok()?; + (matches!(url.scheme(), "https" | "http") + && url.host_str().is_some() && url.username().is_empty() && url.password().is_none() && url.query().is_none() - && url.fragment().is_none() + && url.fragment().is_none()) + .then_some(url) } /// 返回与 base path 对应的唯一 usage endpoint diff --git a/backend/crates/providers/openai/src/transport/mod.rs b/backend/crates/providers/openai/src/transport/mod.rs index 5aec44d08..cfe57ea97 100644 --- a/backend/crates/providers/openai/src/transport/mod.rs +++ b/backend/crates/providers/openai/src/transport/mod.rs @@ -21,7 +21,7 @@ mod response_meta; pub(crate) mod session; pub mod subscription; pub(crate) use downstream::normalize_selected_codex_downstream_body; -pub(crate) use endpoints::valid_upstream_base_url; +pub(crate) use endpoints::parse_upstream_base_url; pub mod tls; pub mod usage; pub mod websocket; diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index c63a666f2..ddd7ba99a 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -2600,30 +2600,34 @@ async fn api_key_admin_exposes_only_configuration_and_preserves_key_when_rotatin 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()); + for base_url in [ + "https://second.example/root", + "http://10.0.0.7:8080/v1", + "http://[fd00::7]:8080/v1", + "http://relay:8080/v1", + ] { + let prepared = admin + .prepare_rotation(PrepareCredentialRotation { + account: account_record(&account), + provider_material: ProviderDocument::new(OpaqueProviderData::new( + json!({"base_url":base_url, "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!(base_url))); + assert!(!prepared.facts().has_refresh_token); + assert_eq!(prepared.facts().account_id, *account.id()); + } assert_eq!( admin .prepare_refresh(PrepareCredentialRefresh { diff --git a/backend/crates/providers/openai/tests/credential/admin.rs b/backend/crates/providers/openai/tests/credential/admin.rs index 67c7714d4..e280441d3 100644 --- a/backend/crates/providers/openai/tests/credential/admin.rs +++ b/backend/crates/providers/openai/tests/credential/admin.rs @@ -943,50 +943,73 @@ async fn api_key_import_export_preserves_target_without_oauth_exchange() { runtime_policy(), Arc::new(crate::RecordingDiagnostics::default()), ); - let imported = service.prepare_import_document_with_proxy(serde_json::json!({ - "provider": "openai", "authentication_kind": "api_key", "name": "relay", "base_url": "https://relay.example/custom/v2", "api_key": "sk-test-only" - }), None).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_with_proxy(document, None) - .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::ResponsesTransport::Http - ); + for base_url in [ + "https://relay.example/custom/v2", + "http://10.0.0.7:8080/v1", + "http://172.18.0.2:8080/v1", + "http://192.168.1.10:8080/v1", + "http://[fd00::7]:8080/v1", + "http://relay:8080/v1", + "http://remote.example/v1", + ] { + let imported = service + .prepare_import_document_with_proxy( + serde_json::json!({ + "provider": "openai", + "authentication_kind": "api_key", + "name": "relay", + "base_url": base_url, + "api_key": "sk-test-only" + }), + None, + ) + .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_with_proxy(document, None) + .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, base_url); + assert_eq!(data.api_key, "sk-test-only"); + assert_eq!( + data.transport, + provider_openai::credential::ResponsesTransport::Http + ); + } } #[tokio::test] @@ -998,7 +1021,10 @@ async fn api_key_import_rejects_unsafe_urls_and_empty_keys() { Arc::new(crate::RecordingDiagnostics::default()), ); for (url, key) in [ - ("http://remote.example/v1", "sk-test"), + ("ftp://remote.example/v1", "sk-test"), + ("http://user:password@relay:8080/v1", "sk-test"), + ("http://relay:8080/v1?token=secret", "sk-test"), + ("http://relay:8080/v1#fragment", "sk-test"), ("https://user:password@example.com", "sk-test"), ("https://example.com?token=secret", "sk-test"), ("https://example.com/#fragment", "sk-test"), @@ -1025,6 +1051,9 @@ async fn sub2api_api_key_import_preserves_versioned_and_explicit_responses_paths ); for (base, expected) in [ ("https://example.com", "https://example.com/v1"), + ("http://relay:8080", "http://relay:8080/v1"), + ("http://10.0.0.7:8080/v1", "http://10.0.0.7:8080/v1"), + ("http://relay:8080/v1/responses", "http://relay:8080/v1"), ( "https://example.com/custom/v4", "https://example.com/custom/v4", diff --git a/docs/api.md b/docs/api.md index ba202f8c4..9d23782d7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -887,7 +887,8 @@ API Key 账号使用以下独立凭据形态: `base_url` 是 API 前缀,追加 `responses`、`models`、`images/generations`、`images/edits` 或 `alpha/search`, 不会自动补 `/v1`;支持根路径和自定义前缀。 -地址仅允许 HTTPS,HTTP 只允许本机回环;拒绝 URL 中的认证信息、查询串和 fragment。 +地址支持 HTTP 和 HTTPS,包括内网 IP 与容器名,拒绝 URL 中的认证信息、查询串和 fragment。 +HTTP 会明文传输 API Key 和请求内容,仅用于可信网络。 `transport` 可省略(默认 `http`)或设为 `prefer_websocket`。API Key 使用 Bearer 认证与普通 JSON, 不携带 OAuth Cookie 或 ChatGPT 身份。上游模型列表使用标准 `/models` 格式,按账号和凭据版本隔离; 目录用于模型发现,不作为能力白名单,未列出的模型仍交由上游判断。 diff --git a/frontend/src/views/accounts/utils/upstreamApiKey.ts b/frontend/src/views/accounts/utils/upstreamApiKey.ts index 3eb9f2686..89ec320b0 100644 --- a/frontend/src/views/accounts/utils/upstreamApiKey.ts +++ b/frontend/src/views/accounts/utils/upstreamApiKey.ts @@ -30,9 +30,8 @@ export function apiKeyAccountError(form: ApiKeyAccountForm, editing = false): st return '上游 API 地址不能超过 2048 个字符' try { const url = new URL(form.base_url) - const loopback = url.hostname === 'localhost' || url.hostname === '[::1]' || /^127(?:\.\d{1,3}){3}$/.test(url.hostname) - if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || url.username || url.password || url.search || url.hash) - return '请输入不含认证、查询参数或片段的 HTTPS 地址' + if (!['https:', 'http:'].includes(url.protocol) || !url.hostname || url.username || url.password || url.search || url.hash) + return '请输入不含认证、查询参数或片段的 HTTP 或 HTTPS 地址' } catch { return '请输入完整的上游 API 地址'