mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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 <zyycn@users.noreply.github.com> Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
committed by
Zyy
co-authored by
Zyy
Codex
parent
661649cc90
commit
f78b1ca487
@@ -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(())
|
||||
|
||||
@@ -1484,7 +1484,7 @@ fn parse_api_key_import(value: &Value) -> Result<ApiKeyCredentialData, CodexCred
|
||||
.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) {
|
||||
if crate::transport::parse_upstream_base_url(&base_url).is_none() {
|
||||
return Err(CodexCredentialAdminError::InvalidInput);
|
||||
}
|
||||
// sub2api 接受服务根、版本前缀或完整 Responses 端点;仅在导入边界归一化
|
||||
|
||||
@@ -22,7 +22,7 @@ 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)
|
||||
&& crate::transport::parse_upstream_base_url(&self.base_url).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<url::Url> {
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+2
-1
@@ -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` 格式,按账号和凭据版本隔离;
|
||||
目录用于模型发现,不作为能力白名单,未列出的模型仍交由上游判断。
|
||||
|
||||
@@ -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 地址'
|
||||
|
||||
Reference in New Issue
Block a user