diff --git a/Cargo.lock b/Cargo.lock index 42f4be3..67c596e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1341,7 +1341,7 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "link-assistant-router" -version = "0.19.0" +version = "0.20.0" dependencies = [ "aes-gcm", "async-trait", diff --git a/changelog.d/20260712_090000_codex_version_header.md b/changelog.d/20260712_090000_codex_version_header.md new file mode 100644 index 0000000..6f7e30d --- /dev/null +++ b/changelog.d/20260712_090000_codex_version_header.md @@ -0,0 +1,10 @@ +--- +bump: minor +--- + +### Added +- Codex subscriptions now send a `version` header (default `0.144.1`, overridable via + `CODEX_CLIENT_VERSION`) when proxying to the ChatGPT backend. The backend gates newer + models (e.g. `gpt-5.6-luna`) behind a recent client version; without the header + `POST /responses` returns `Model not found`. This mirrors the Codex CLI so newer models + are usable through the router. diff --git a/src/subscription_proxy.rs b/src/subscription_proxy.rs index da6c8ed..0379570 100644 --- a/src/subscription_proxy.rs +++ b/src/subscription_proxy.rs @@ -211,7 +211,7 @@ pub async fn forward_subscription_openai( /// Collapse a Codex Responses SSE body into the final Responses JSON object. /// -/// The ChatGPT Codex backend only streams (`text/event-stream`-style `event:` / +/// The `ChatGPT` Codex backend only streams (`text/event-stream`-style `event:` / /// `data:` lines). For non-streaming clients we extract the `response` object /// carried by the terminal `response.completed` event and return it verbatim, so /// the client receives the single JSON object it expects. Returns `None` if no @@ -273,6 +273,13 @@ fn subscription_headers( // identifies the originating client. out.push(("openai-beta", "responses=experimental".to_string())); out.push(("originator", "codex_cli_rs".to_string())); + // Codex gates newer models (e.g. gpt-5.6-luna) behind a recent client version + // advertised via the `version` header; without it the backend replies "Model not + // found". Mirror the Codex CLI. Overridable via CODEX_CLIENT_VERSION. + out.push(( + "version", + std::env::var("CODEX_CLIENT_VERSION").unwrap_or_else(|_| "0.144.1".to_string()), + )); } out } @@ -366,7 +373,7 @@ fn input_item_text(item: &serde_json::Value) -> Option { /// - **requires** a non-empty `instructions` field (HTTP 400 "Instructions are /// required"). /// -/// Standard Responses clients (e.g. OpenClaw's gateway) send `max_output_tokens` +/// Standard Responses clients (e.g. `OpenClaw`'s gateway) send `max_output_tokens` /// and put the system prompt as a `system` message in `input`, so without this /// shaping the backend rejects every request. System turns are hoisted into /// `instructions`; a default is used only if nothing remains. @@ -382,8 +389,8 @@ fn normalize_codex_responses_body(body: &mut serde_json::Value) { // Hoist system/developer turns out of `input` (Codex forbids them there). let mut hoisted: Vec = Vec::new(); if let Some(serde_json::Value::Array(items)) = obj.get_mut("input") { - items.retain(|item| { - match item.get("role").and_then(serde_json::Value::as_str) { + items.retain( + |item| match item.get("role").and_then(serde_json::Value::as_str) { Some("system" | "developer") => { if let Some(text) = input_item_text(item) { hoisted.push(text); @@ -391,8 +398,8 @@ fn normalize_codex_responses_body(body: &mut serde_json::Value) { false } _ => true, - } - }); + }, + ); } // Merge existing instructions + hoisted system turns; fall back to a default. @@ -408,7 +415,10 @@ fn normalize_codex_responses_body(body: &mut serde_json::Value) { } else { parts.join("\n\n") }; - obj.insert("instructions".to_string(), serde_json::Value::String(instructions)); + obj.insert( + "instructions".to_string(), + serde_json::Value::String(instructions), + ); } #[cfg(test)] @@ -537,6 +547,25 @@ mod tests { ); } + #[test] + fn codex_headers_include_version() { + let token = SubscriptionToken { + access_token: "a".into(), + refresh_token: None, + expires_at_ms: None, + account_id: Some("acct_9".into()), + resource_url: None, + }; + let headers = subscription_headers(SubscriptionProvider::Codex, &token); + // The Codex backend gates newer models behind a recent client version + // advertised via the `version` header. + assert!( + headers + .iter() + .any(|(k, v)| *k == "version" && !v.is_empty()) + ); + } + #[test] fn rate_limit_headers_are_selected() { let mut headers = HeaderMap::new();