diff --git a/README.md b/README.md index 20dda7a53..36b89412e 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,38 @@ curl http://127.0.0.1:8080/v1/responses \ 完整的客户端与管理端路由、鉴权、请求字段和 mutation 语义见 [接口文档](docs/api.md)。 +### Codex 原生生图 + +在管理端的「使用密钥」中复制配置,或通过 CCSwitch 导入,保存后重启 Codex。 +新配置启用 `features.image_generation = true`,使用以下 Provider 认证方式: + +```toml +[model_providers.OpenAI] +name = "OpenAI" +base_url = "http://127.0.0.1:8080/v1" +wire_api = "responses" +requires_openai_auth = false +# 使用管理端创建的代理密钥,不是 ChatGPT 账号的访问令牌。 +experimental_bearer_token = "" + +[model_providers.OpenAI.http_headers] +# 声明账号认证由服务端托管;此标记本身没有鉴权能力。 +X-OpenAI-Actor-Authorization = "proxy-managed" + +[features] +image_generation = true +``` + +合并到已有配置时,请修改当前 `model_provider` 对应的 Provider 段,不要重复添加同名 TOML 表, +并保留其他个性化设置。配置文件包含代理密钥,请勿公开分享。 +无需更改本地 `auth.json`;真实 ChatGPT 登录状态仍由服务端管理。网关始终校验 Bearer Client Key, +并过滤客户端的 Actor 认证标记,上游身份只来自调度选中的服务端账号。 + +此配置使用官方客户端的服务端托管认证能力,不修改或伪造客户端登录状态。 +需要支持该能力的新版 Codex、支持图片输入及命名空间工具的对话模型,以及具备生图权限的 OpenAI 账号。 +模型会在需要图片时调用原生 `image_gen.imagegen`,图片请求通过本代理的 Images 路由完成。 +原来的 `auth.json` + `requires_openai_auth = true` 配置仍可用于已有请求,但不会因此自动获得原生生图能力。 + ## 运行维护 ```bash diff --git a/backend/crates/gateway-api/src/openai/responses/request.rs b/backend/crates/gateway-api/src/openai/responses/request.rs index 3aeaceeb9..f2aadfd54 100644 --- a/backend/crates/gateway-api/src/openai/responses/request.rs +++ b/backend/crates/gateway-api/src/openai/responses/request.rs @@ -483,6 +483,8 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool { // 下游鉴权和账号 cookie 绝不能成为上游账号身份。 | "authorization" | "x-api-key" + // Codex 的服务端托管认证标记只用于客户端能力判断,不代表上游身份。 + | "x-openai-actor-authorization" | "cookie" | "cookie2" | "chatgpt-account-id" diff --git a/backend/crates/gateway-api/tests/openai/auth.rs b/backend/crates/gateway-api/tests/openai/auth.rs index 131fb500e..4bda051b2 100644 --- a/backend/crates/gateway-api/tests/openai/auth.rs +++ b/backend/crates/gateway-api/tests/openai/auth.rs @@ -13,6 +13,32 @@ fn bearer_client_api_key_should_reject_missing_authorization() { ); } +#[test] +fn actor_authorization_marker_should_not_replace_client_api_key() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-openai-actor-authorization", + HeaderValue::from_static("proxy-managed"), + ); + + assert_eq!( + bearer_client_api_key(&headers), + Err(ClientApiKeyAuthError::MissingAuthorization) + ); +} + +#[test] +fn actor_authorization_marker_should_preserve_existing_bearer_authentication() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-openai-actor-authorization", + HeaderValue::from_static("proxy-managed"), + ); + headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer sk_client")); + + assert_eq!(bearer_client_api_key(&headers), Ok("sk_client")); +} + #[test] fn bearer_client_api_key_should_reject_non_utf8_authorization() { let mut headers = HeaderMap::new(); diff --git a/backend/crates/gateway-api/tests/openai/responses/mod.rs b/backend/crates/gateway-api/tests/openai/responses/mod.rs index 6ee13946c..41f316349 100644 --- a/backend/crates/gateway-api/tests/openai/responses/mod.rs +++ b/backend/crates/gateway-api/tests/openai/responses/mod.rs @@ -281,6 +281,10 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() { headers.insert("content-length", HeaderValue::from_static("999")); headers.insert("authorization", HeaderValue::from_static("Bearer client")); headers.insert("x-api-key", HeaderValue::from_static("client-key")); + headers.insert( + "x-openai-actor-authorization", + HeaderValue::from_static("proxy-managed"), + ); headers.insert("cookie", HeaderValue::from_static("client=cookie")); headers.insert( "chatgpt-account-id", @@ -348,6 +352,7 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() { "content-length", "authorization", "x-api-key", + "x-openai-actor-authorization", "cookie", "chatgpt-account-id", "chatgpt-project-id", diff --git a/backend/crates/providers/openai/src/transport/request.rs b/backend/crates/providers/openai/src/transport/request.rs index f3ad5fa0f..873b86bdd 100644 --- a/backend/crates/providers/openai/src/transport/request.rs +++ b/backend/crates/providers/openai/src/transport/request.rs @@ -620,6 +620,7 @@ fn provider_managed_header(name: &str) -> bool { | "x-request-id" | "authorization" | "x-api-key" + | "x-openai-actor-authorization" | "cookie" | "cookie2" | "chatgpt-account-id" diff --git a/backend/crates/providers/openai/tests/transport/headers.rs b/backend/crates/providers/openai/tests/transport/headers.rs index 3a50b68c1..e3fa759bf 100644 --- a/backend/crates/providers/openai/tests/transport/headers.rs +++ b/backend/crates/providers/openai/tests/transport/headers.rs @@ -46,6 +46,10 @@ fn request_with_opaque_headers(use_websocket: bool) -> CodexResponsesRequest { ["x-invalid-base64", "%%%"], ["x-still-valid", STANDARD.encode(b"after-invalid")], ["authorization", STANDARD.encode(b"Bearer client-secret")], + [ + "X-OpenAI-Actor-Authorization", + STANDARD.encode(b"proxy-managed") + ], ["chatgpt-account-id", STANDARD.encode(b"client-account")], [ "x-codex-installation-id", @@ -425,6 +429,7 @@ async fn backend_http_should_restore_opaque_multivalue_header_bytes_and_lease_id ); for dropped in [ "openai-beta", + "x-openai-actor-authorization", "x-oai-attestation", "x-oai-is", "x-oai-is-update", @@ -542,7 +547,12 @@ async fn backend_websocket_should_drop_only_unrepresentable_opaque_header_values values("x-openai-internal-codex-residency"), vec![b"us".to_vec()] ); - for dropped in ["x-oai-attestation", "x-oai-is", "x-oai-is-update"] { + for dropped in [ + "x-openai-actor-authorization", + "x-oai-attestation", + "x-oai-is", + "x-oai-is-update", + ] { assert!(values(dropped).is_empty(), "unexpected {dropped}"); } assert_eq!( diff --git a/frontend/src/views/api-keys/components/ApiKeyUseModal.vue b/frontend/src/views/api-keys/components/ApiKeyUseModal.vue index 04c83e7ca..1f488b1a2 100644 --- a/frontend/src/views/api-keys/components/ApiKeyUseModal.vue +++ b/frontend/src/views/api-keys/components/ApiKeyUseModal.vue @@ -60,7 +60,7 @@ const visibleFiles = computed(() => [
@@ -76,6 +76,10 @@ const visibleFiles = computed(() => [ />
+

+ 配置含密钥,请勿分享,覆盖前请备份。 +

+