From e1f098b9b721d473fb500fb16bd38b4edfe9b394 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 3 Nov 2025 10:06:00 -0800 Subject: [PATCH] feat: add options to responses-api-proxy to support Azure (#6129) This PR introduces an `--upstream-url` option to the proxy CLI that determines the URL that Responses API requests should be forwarded to. To preserve existing behavior, the default value is `"https://api.openai.com/v1/responses"`. The motivation for this change is that the [Codex GitHub Action](https://github.com/openai/codex-action) should support those who use the OpenAI Responses API via Azure. Relevant issues: - https://github.com/openai/codex-action/issues/28 - https://github.com/openai/codex-action/issues/38 - https://github.com/openai/codex-action/pull/44 Though rather than introduce a bunch of new Azure-specific logic in the action as https://github.com/openai/codex-action/pull/44 proposes, we should leverage our Responses API proxy to get the _hardening_ benefits it provides: https://github.com/openai/codex/blob/d5853d9c47b1badad183f62622745cf47e6ff0f4/codex-rs/responses-api-proxy/README.md#hardening-details This PR should make this straightforward to incorporate in the action. To see how the updated version of the action would consume these new options, see https://github.com/openai/codex-action/pull/47. --- codex-rs/responses-api-proxy/README.md | 15 ++++++- codex-rs/responses-api-proxy/src/lib.rs | 39 ++++++++++++++++--- .../responses-api-proxy/src/read_api_key.rs | 18 ++++----- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/codex-rs/responses-api-proxy/README.md b/codex-rs/responses-api-proxy/README.md index 1109271c..8a99c41a 100644 --- a/codex-rs/responses-api-proxy/README.md +++ b/codex-rs/responses-api-proxy/README.md @@ -40,12 +40,23 @@ curl --fail --silent --show-error "${PROXY_BASE_URL}/shutdown" ## CLI ``` -codex-responses-api-proxy [--port ] [--server-info ] [--http-shutdown] +codex-responses-api-proxy [--port ] [--server-info ] [--http-shutdown] [--upstream-url ] ``` - `--port `: Port to bind on `127.0.0.1`. If omitted, an ephemeral port is chosen. - `--server-info `: If set, the proxy writes a single line of JSON with `{ "port": , "pid": }` once listening. - `--http-shutdown`: If set, enables `GET /shutdown` to exit the process with code `0`. +- `--upstream-url `: Absolute URL to forward requests to. Defaults to `https://api.openai.com/v1/responses`. +- Authentication is fixed to `Authorization: Bearer ` to match the Codex CLI expectations. + +For Azure, for example (ensure your deployment accepts `Authorization: Bearer `): + +```shell +printenv AZURE_OPENAI_API_KEY | env -u AZURE_OPENAI_API_KEY codex-responses-api-proxy \ + --http-shutdown \ + --server-info /tmp/server-info.json \ + --upstream-url "https://YOUR_PROJECT_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT/responses?api-version=2025-04-01-preview" +``` ## Notes @@ -57,7 +68,7 @@ codex-responses-api-proxy [--port ] [--server-info ] [--http-shutdow Care is taken to restrict access/copying to the value of `OPENAI_API_KEY` retained in memory: - We leverage [`codex_process_hardening`](https://github.com/openai/codex/blob/main/codex-rs/process-hardening/README.md) so `codex-responses-api-proxy` is run with standard process-hardening techniques. -- At startup, we allocate a `1024` byte buffer on the stack and write `"Bearer "` as the first `7` bytes. +- At startup, we allocate a `1024` byte buffer on the stack and copy `"Bearer "` into the start of the buffer. - We then read from `stdin`, copying the contents into the buffer after `"Bearer "`. - After verifying the key matches `/^[a-zA-Z0-9_-]+$/` (and does not exceed the buffer), we create a `String` from that buffer (so the data is now on the heap). - We zero out the stack-allocated buffer using https://crates.io/crates/zeroize so it is not optimized away by the compiler. diff --git a/codex-rs/responses-api-proxy/src/lib.rs b/codex-rs/responses-api-proxy/src/lib.rs index 50d003bc..bbe0484d 100644 --- a/codex-rs/responses-api-proxy/src/lib.rs +++ b/codex-rs/responses-api-proxy/src/lib.rs @@ -12,6 +12,7 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use clap::Parser; +use reqwest::Url; use reqwest::blocking::Client; use reqwest::header::AUTHORIZATION; use reqwest::header::HOST; @@ -44,6 +45,10 @@ pub struct Args { /// Enable HTTP shutdown endpoint at GET /shutdown #[arg(long)] pub http_shutdown: bool, + + /// Absolute URL the proxy should forward requests to (defaults to OpenAI). + #[arg(long, default_value = "https://api.openai.com/v1/responses")] + pub upstream_url: String, } #[derive(Serialize)] @@ -52,10 +57,29 @@ struct ServerInfo { pid: u32, } +struct ForwardConfig { + upstream_url: Url, + host_header: HeaderValue, +} + /// Entry point for the library main, for parity with other crates. pub fn run_main(args: Args) -> Result<()> { let auth_header = read_auth_header_from_stdin()?; + let upstream_url = Url::parse(&args.upstream_url).context("parsing --upstream-url")?; + let host = match (upstream_url.host_str(), upstream_url.port()) { + (Some(host), Some(port)) => format!("{host}:{port}"), + (Some(host), None) => host.to_string(), + _ => return Err(anyhow!("upstream URL must include a host")), + }; + let host_header = + HeaderValue::from_str(&host).context("constructing Host header from upstream URL")?; + + let forward_config = Arc::new(ForwardConfig { + upstream_url, + host_header, + }); + let (listener, bound_addr) = bind_listener(args.port)?; if let Some(path) = args.server_info.as_ref() { write_server_info(path, bound_addr.port())?; @@ -75,13 +99,14 @@ pub fn run_main(args: Args) -> Result<()> { let http_shutdown = args.http_shutdown; for request in server.incoming_requests() { let client = client.clone(); + let forward_config = forward_config.clone(); std::thread::spawn(move || { if http_shutdown && request.method() == &Method::Get && request.url() == "/shutdown" { let _ = request.respond(Response::new_empty(StatusCode(200))); std::process::exit(0); } - if let Err(e) = forward_request(&client, auth_header, request) { + if let Err(e) = forward_request(&client, auth_header, &forward_config, request) { eprintln!("forwarding error: {e}"); } }); @@ -115,7 +140,12 @@ fn write_server_info(path: &Path, port: u16) -> Result<()> { Ok(()) } -fn forward_request(client: &Client, auth_header: &'static str, mut req: Request) -> Result<()> { +fn forward_request( + client: &Client, + auth_header: &'static str, + config: &ForwardConfig, + mut req: Request, +) -> Result<()> { // Only allow POST /v1/responses exactly, no query string. let method = req.method().clone(); let url_path = req.url().to_string(); @@ -157,11 +187,10 @@ fn forward_request(client: &Client, auth_header: &'static str, mut req: Request) auth_header_value.set_sensitive(true); headers.insert(AUTHORIZATION, auth_header_value); - headers.insert(HOST, HeaderValue::from_static("api.openai.com")); + headers.insert(HOST, config.host_header.clone()); - let upstream = "https://api.openai.com/v1/responses"; let upstream_resp = client - .post(upstream) + .post(config.upstream_url.clone()) .headers(headers) .body(body) .send() diff --git a/codex-rs/responses-api-proxy/src/read_api_key.rs b/codex-rs/responses-api-proxy/src/read_api_key.rs index f3950b54..df5e2106 100644 --- a/codex-rs/responses-api-proxy/src/read_api_key.rs +++ b/codex-rs/responses-api-proxy/src/read_api_key.rs @@ -121,7 +121,7 @@ where if total_read == capacity && !saw_newline && !saw_eof { buf.zeroize(); return Err(anyhow!( - "OPENAI_API_KEY is too large to fit in the 512-byte buffer" + "API key is too large to fit in the {BUFFER_SIZE}-byte buffer" )); } @@ -133,7 +133,7 @@ where if total == AUTH_HEADER_PREFIX.len() { buf.zeroize(); return Err(anyhow!( - "OPENAI_API_KEY must be provided via stdin (e.g. printenv OPENAI_API_KEY | codex responses-api-proxy)" + "API key must be provided via stdin (e.g. printenv OPENAI_API_KEY | codex responses-api-proxy)" )); } @@ -214,7 +214,7 @@ fn validate_auth_header_bytes(key_bytes: &[u8]) -> Result<()> { } Err(anyhow!( - "OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'" + "API key may only contain ASCII letters, numbers, '-' or '_'" )) } @@ -290,7 +290,9 @@ mod tests { }) .unwrap_err(); let message = format!("{err:#}"); - assert!(message.contains("OPENAI_API_KEY is too large to fit in the 512-byte buffer")); + let expected_error = + format!("API key is too large to fit in the {BUFFER_SIZE}-byte buffer"); + assert!(message.contains(&expected_error)); } #[test] @@ -317,9 +319,7 @@ mod tests { .unwrap_err(); let message = format!("{err:#}"); - assert!( - message.contains("OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'") - ); + assert!(message.contains("API key may only contain ASCII letters, numbers, '-' or '_'")); } #[test] @@ -337,8 +337,6 @@ mod tests { .unwrap_err(); let message = format!("{err:#}"); - assert!( - message.contains("OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'") - ); + assert!(message.contains("API key may only contain ASCII letters, numbers, '-' or '_'")); } }