Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions changelog.d/20260712_090000_codex_version_header.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 36 additions & 7 deletions src/subscription_proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -366,7 +373,7 @@ fn input_item_text(item: &serde_json::Value) -> Option<String> {
/// - **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.
Expand All @@ -382,17 +389,17 @@ 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<String> = 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);
}
false
}
_ => true,
}
});
},
);
}

// Merge existing instructions + hoisted system turns; fall back to a default.
Expand All @@ -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)]
Expand Down Expand Up @@ -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();
Expand Down
Loading