mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
feat: add options to responses-api-proxy to support Azure
This commit is contained in:
@@ -40,12 +40,26 @@ curl --fail --silent --show-error "${PROXY_BASE_URL}/shutdown"
|
||||
## CLI
|
||||
|
||||
```
|
||||
codex-responses-api-proxy [--port <PORT>] [--server-info <FILE>] [--http-shutdown]
|
||||
codex-responses-api-proxy [--port <PORT>] [--server-info <FILE>] [--http-shutdown] [--upstream-url <URL>] [--auth-header-name <NAME>] [--auth-header-prefix <PREFIX>]
|
||||
```
|
||||
|
||||
- `--port <PORT>`: Port to bind on `127.0.0.1`. If omitted, an ephemeral port is chosen.
|
||||
- `--server-info <FILE>`: If set, the proxy writes a single line of JSON with `{ "port": <PORT>, "pid": <PID> }` once listening.
|
||||
- `--http-shutdown`: If set, enables `GET /shutdown` to exit the process with code `0`.
|
||||
- `--upstream-url <URL>`: Absolute URL to forward requests to. Defaults to `https://api.openai.com/v1/responses`.
|
||||
- `--auth-header-name <NAME>`: Header name used for authentication upstream. Defaults to `Authorization`.
|
||||
- `--auth-header-prefix <PREFIX>`: Prefix prepended to the API key when forming the header value. Defaults to `Bearer ` (note the trailing space). Pass an empty string for providers like Azure that expect the raw key value.
|
||||
|
||||
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-header-name api-key \
|
||||
--auth-header-prefix ''
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ 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;
|
||||
use reqwest::header::HeaderMap;
|
||||
use reqwest::header::HeaderName;
|
||||
@@ -44,6 +44,18 @@ 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,
|
||||
|
||||
/// Header name used for authentication when forwarding upstream.
|
||||
#[arg(long, default_value = "Authorization")]
|
||||
pub auth_header_name: String,
|
||||
|
||||
/// Prefix prepended to the API key when building the auth header value.
|
||||
#[arg(long, default_value = "Bearer ")]
|
||||
pub auth_header_prefix: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -52,9 +64,42 @@ struct ServerInfo {
|
||||
pid: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ForwardConfig {
|
||||
upstream_url: Url,
|
||||
host_header: HeaderValue,
|
||||
auth_header_name: HeaderName,
|
||||
auth_header_value: &'static str,
|
||||
}
|
||||
|
||||
/// 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_lower = args.auth_header_name.to_ascii_lowercase();
|
||||
let auth_header_name = HeaderName::from_bytes(auth_header_name_lower.as_bytes())
|
||||
.context("parsing --auth-header-name")?;
|
||||
|
||||
let auth_header =
|
||||
read_auth_header_from_stdin(&args.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 +120,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 +161,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 +184,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 +202,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()
|
||||
|
||||
@@ -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<usize> {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_auth_header_with<F>(mut read_fn: F) -> Result<&'static str>
|
||||
fn read_auth_header_with<F>(prefix: &str, mut read_fn: F) -> Result<&'static str>
|
||||
where
|
||||
F: FnMut(&mut [u8]) -> std::io::Result<usize>,
|
||||
{
|
||||
@@ -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::<io::Error>().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 '_'"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user