From ecea8c112515c3cbebf79fce7c2d7fee04b0615f Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 2 Nov 2025 15:26:29 -0800 Subject: [PATCH] feat: add options to responses-api-proxy to support Azure --- codex-rs/responses-api-proxy/README.md | 18 ++++- codex-rs/responses-api-proxy/src/lib.rs | 72 ++++++++++++++++--- .../responses-api-proxy/src/read_api_key.rs | 64 ++++++++--------- 3 files changed, 108 insertions(+), 46 deletions(-) diff --git a/codex-rs/responses-api-proxy/README.md b/codex-rs/responses-api-proxy/README.md index 1109271c1d..c08f6d4e8f 100644 --- a/codex-rs/responses-api-proxy/README.md +++ b/codex-rs/responses-api-proxy/README.md @@ -40,12 +40,24 @@ 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 ] [--auth-style bearer|api-key] ``` - `--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`. +- `--auth-style`: Controls how the proxy authenticates upstream. `bearer` (default) sends `Authorization: Bearer `, while `api-key` sends `api-key: `. + +For Azure, for example: + +```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" \ + --auth-style api-key +``` ## Notes @@ -57,8 +69,8 @@ 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. -- We then read from `stdin`, copying the contents into the buffer after `"Bearer "`. +- At startup, we allocate a `1024` byte buffer on the stack and copy the configured auth prefix (e.g. `"Bearer "`) into the start of the buffer. +- We then read from `stdin`, copying the contents into the buffer after that prefix. - 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. - We invoke `.leak()` on the `String` so we can treat its contents as a `&'static str`, as it will live for the rest of the process. diff --git a/codex-rs/responses-api-proxy/src/lib.rs b/codex-rs/responses-api-proxy/src/lib.rs index 50d003bc42..c942b3fc76 100644 --- a/codex-rs/responses-api-proxy/src/lib.rs +++ b/codex-rs/responses-api-proxy/src/lib.rs @@ -12,8 +12,9 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use clap::Parser; +use clap::ValueEnum; +use reqwest::Url; use reqwest::blocking::Client; -use reqwest::header::AUTHORIZATION; use reqwest::header::HOST; use reqwest::header::HeaderMap; use reqwest::header::HeaderName; @@ -44,6 +45,14 @@ 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, + + /// Authentication style for the upstream provider. + #[arg(long, value_enum, default_value_t = AuthStyle::Bearer)] + pub auth_style: AuthStyle, } #[derive(Serialize)] @@ -52,9 +61,49 @@ struct ServerInfo { pid: u32, } +#[derive(Clone)] +struct ForwardConfig { + upstream_url: Url, + host_header: HeaderValue, + auth_header_name: HeaderName, + auth_header_value: &'static str, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum AuthStyle { + Bearer, + #[clap(alias = "api-key")] + ApiKey, +} + /// 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 mut host = upstream_url + .host_str() + .map(str::to_string) + .context("upstream URL must include a host")?; + if let Some(port) = upstream_url.port() { + host.push(':'); + host.push_str(&port.to_string()); + } + let host_header = + HeaderValue::from_str(&host).context("constructing Host header from upstream URL")?; + + let (auth_header_name, auth_header_prefix) = match args.auth_style { + AuthStyle::Bearer => (HeaderName::from_static("authorization"), "Bearer "), + AuthStyle::ApiKey => (HeaderName::from_static("api-key"), ""), + }; + + let auth_header = read_auth_header_from_stdin(auth_header_prefix).context("reading API key")?; + + let forward_config = Arc::new(ForwardConfig { + upstream_url, + host_header, + auth_header_name, + auth_header_value: auth_header, + }); let (listener, bound_addr) = bind_listener(args.port)?; if let Some(path) = args.server_info.as_ref() { @@ -75,13 +124,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, &forward_config, request) { eprintln!("forwarding error: {e}"); } }); @@ -115,7 +165,7 @@ 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, 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(); @@ -138,7 +188,10 @@ fn forward_request(client: &Client, auth_header: &'static str, mut req: Request) for header in req.headers() { let name_ascii = header.field.as_str(); let lower = name_ascii.to_ascii_lowercase(); - if lower.as_str() == "authorization" || lower.as_str() == "host" { + if lower.as_str() == "authorization" + || lower.as_str() == config.auth_header_name.as_str() + || lower.as_str() == "host" + { continue; } @@ -153,15 +206,14 @@ fn forward_request(client: &Client, auth_header: &'static str, mut req: Request) // As part of our effort to to keep `auth_header` secret, we use a // combination of `from_static()` and `set_sensitive(true)`. - let mut auth_header_value = HeaderValue::from_static(auth_header); + let mut auth_header_value = HeaderValue::from_static(config.auth_header_value); auth_header_value.set_sensitive(true); - headers.insert(AUTHORIZATION, auth_header_value); + headers.insert(config.auth_header_name.clone(), 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 f3950b540b..3692b2325f 100644 --- a/codex-rs/responses-api-proxy/src/read_api_key.rs +++ b/codex-rs/responses-api-proxy/src/read_api_key.rs @@ -6,19 +6,17 @@ use zeroize::Zeroize; /// Use a generous buffer size to avoid truncation and to allow for longer API /// keys in the future. const BUFFER_SIZE: usize = 1024; -const AUTH_HEADER_PREFIX: &[u8] = b"Bearer "; -/// Reads the auth token from stdin and returns a static `Authorization` header -/// value with the auth token used with `Bearer`. The header value is returned -/// as a `&'static str` whose bytes are locked in memory to avoid accidental -/// exposure. +/// Reads the auth token from stdin and returns a static header value that starts +/// with the provided prefix. The header value is returned as a `&'static str` +/// whose bytes are locked in memory to avoid accidental exposure. #[cfg(unix)] -pub(crate) fn read_auth_header_from_stdin() -> Result<&'static str> { - read_auth_header_with(read_from_unix_stdin) +pub(crate) fn read_auth_header_from_stdin(prefix: &str) -> Result<&'static str> { + read_auth_header_with(prefix, read_from_unix_stdin) } #[cfg(windows)] -pub(crate) fn read_auth_header_from_stdin() -> Result<&'static str> { +pub(crate) fn read_auth_header_from_stdin(prefix: &str) -> Result<&'static str> { use std::io::Read; // Use of `stdio::io::stdin()` has the problem mentioned in the docstring on @@ -26,7 +24,7 @@ pub(crate) fn read_auth_header_from_stdin() -> Result<&'static str> { // be replaced the low-level Windows equivalent. Because we do not have an // equivalent of mlock() on Windows right now, it is not pressing until we // address that issue. - read_auth_header_with(|buffer| std::io::stdin().read(buffer)) + read_auth_header_with(prefix, |buffer| std::io::stdin().read(buffer)) } /// We perform a low-level read with `read(2)` because `stdio::io::stdin()` has @@ -69,7 +67,7 @@ fn read_from_unix_stdin(buffer: &mut [u8]) -> std::io::Result { } } -fn read_auth_header_with(mut read_fn: F) -> Result<&'static str> +fn read_auth_header_with(prefix: &str, mut read_fn: F) -> Result<&'static str> where F: FnMut(&mut [u8]) -> std::io::Result, { @@ -81,9 +79,13 @@ where // one to create the String (with the exact size) for the header value, // which we then immediately protect with mlock(2). let mut buf = [0u8; BUFFER_SIZE]; - buf[..AUTH_HEADER_PREFIX.len()].copy_from_slice(AUTH_HEADER_PREFIX); + let prefix_bytes = prefix.as_bytes(); + if prefix_bytes.len() >= BUFFER_SIZE { + return Err(anyhow!("auth header prefix is too large")); + } + buf[..prefix_bytes.len()].copy_from_slice(prefix_bytes); - let prefix_len = AUTH_HEADER_PREFIX.len(); + let prefix_len = prefix_bytes.len(); let capacity = buf.len() - prefix_len; let mut total_read = 0usize; // number of bytes read into the token region let mut saw_newline = false; @@ -121,7 +123,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 512-byte buffer" )); } @@ -130,14 +132,14 @@ where total -= 1; } - if total == AUTH_HEADER_PREFIX.len() { + if total == 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)" )); } - if let Err(err) = validate_auth_header_bytes(&buf[AUTH_HEADER_PREFIX.len()..total]) { + if let Err(err) = validate_auth_header_bytes(&buf[prefix_len..total]) { buf.zeroize(); return Err(err); } @@ -214,7 +216,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 '_'" )) } @@ -227,7 +229,7 @@ mod tests { #[test] fn reads_key_with_no_newlines() { let mut sent = false; - let result = read_auth_header_with(|buf| { + let result = read_auth_header_with("Bearer ", |buf| { if sent { return Ok(0); } @@ -245,7 +247,7 @@ mod tests { fn reads_key_with_short_reads() { let mut chunks: VecDeque<&[u8]> = VecDeque::from(vec![b"sk-".as_ref(), b"abc".as_ref(), b"123\n".as_ref()]); - let result = read_auth_header_with(|buf| match chunks.pop_front() { + let result = read_auth_header_with("Bearer ", |buf| match chunks.pop_front() { Some(chunk) if !chunk.is_empty() => { buf[..chunk.len()].copy_from_slice(chunk); Ok(chunk.len()) @@ -260,7 +262,7 @@ mod tests { #[test] fn reads_key_and_trims_newlines() { let mut sent = false; - let result = read_auth_header_with(|buf| { + let result = read_auth_header_with("Bearer ", |buf| { if sent { return Ok(0); } @@ -276,26 +278,26 @@ mod tests { #[test] fn errors_when_no_input_provided() { - let err = read_auth_header_with(|_| Ok(0)).unwrap_err(); + let err = read_auth_header_with("Bearer ", |_| Ok(0)).unwrap_err(); let message = format!("{err:#}"); assert!(message.contains("must be provided")); } #[test] fn errors_when_buffer_filled() { - let err = read_auth_header_with(|buf| { - let data = vec![b'a'; BUFFER_SIZE - AUTH_HEADER_PREFIX.len()]; + let err = read_auth_header_with("Bearer ", |buf| { + let data = vec![b'a'; BUFFER_SIZE - "Bearer ".len()]; buf[..data.len()].copy_from_slice(&data); Ok(data.len()) }) .unwrap_err(); let message = format!("{err:#}"); - assert!(message.contains("OPENAI_API_KEY is too large to fit in the 512-byte buffer")); + assert!(message.contains("API key is too large to fit in the 512-byte buffer")); } #[test] fn propagates_io_error() { - let err = read_auth_header_with(|_| Err(io::Error::other("boom"))).unwrap_err(); + let err = read_auth_header_with("Bearer ", |_| Err(io::Error::other("boom"))).unwrap_err(); let io_error = err.downcast_ref::().unwrap(); assert_eq!(io_error.kind(), io::ErrorKind::Other); @@ -305,7 +307,7 @@ mod tests { #[test] fn errors_on_invalid_utf8() { let mut sent = false; - let err = read_auth_header_with(|buf| { + let err = read_auth_header_with("Bearer ", |buf| { if sent { return Ok(0); } @@ -317,15 +319,13 @@ 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] fn errors_on_invalid_characters() { let mut sent = false; - let err = read_auth_header_with(|buf| { + let err = read_auth_header_with("Bearer ", |buf| { if sent { return Ok(0); } @@ -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 '_'")); } }