Skip to content

Commit ee68340

Browse files
committed
add native responses api passthrough
Add an optional, default-disabled POST /v1/responses endpoint so native OpenAI clients can use stored Codex authentication without Anthropic protocol translation. Preserve native JSON and SSE behavior, replace incoming credentials, refresh rejected access tokens, validate Codex models, filter response headers, and record bounded monitor and traffic data. Images API and stored response routes remain outside the supported surface.
1 parent c40e992 commit ee68340

10 files changed

Lines changed: 1441 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Native OpenAI Responses clients can use `POST /v1/responses` with existing
6+
Codex authentication, including JSON responses, SSE streaming, and automatic
7+
token refresh. Enable the endpoint with `codex.responsesApi` or
8+
`CCP_CODEX_RESPONSES_API=1`; it is disabled by default.
9+
310
## v0.1.23 (2026-07-22)
411

512
- Codex WebSocket streaming handles pooled connections and HTTP fallback more

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,9 @@ The proxy speaks enough of the Anthropic API for Claude Code:
664664
- `POST /v1/messages?beta=true`: same (Claude Code always sends `?beta=true`)
665665
- `POST /v1/messages/count_tokens`: local token count via `gpt-tokenizer`
666666
(o200k_base); used by Claude Code's compaction logic
667+
- `POST /v1/responses`: optional native OpenAI Responses passthrough using stored
668+
Codex authentication; enable with `codex.responsesApi` or
669+
`CCP_CODEX_RESPONSES_API=1`
667670
- `GET /healthz`: liveness check
668671

669672
## Configuration
@@ -692,7 +695,8 @@ Windows, and at
692695
"serviceTier": "fast",
693696
"baseUrl": "https://chatgpt.com/backend-api/codex/responses",
694697
"transport": "websocket",
695-
"previousResponseId": false
698+
"previousResponseId": false,
699+
"responsesApi": false
696700
},
697701
"kimi": {
698702
"userAgent": "KimiCLI/1.37.0",
@@ -734,6 +738,7 @@ Windows, and at
734738
| `CCP_CODEX_BASE_URL` | `codex.baseUrl` | `https://chatgpt.com/backend-api/codex/responses` | Override the Codex Responses endpoint |
735739
| `CCP_CODEX_TRANSPORT` | `codex.transport` | `websocket` | Codex transport: `websocket`, `http`, or `auto` |
736740
| `CCP_CODEX_PREVIOUS_RESPONSE_ID` | `codex.previousResponseId` | `false` | Enable WebSocket continuation with `previous_response_id` when the request is append-only |
741+
| `CCP_CODEX_RESPONSES_API` | `codex.responsesApi` | `false` | Enable `POST /v1/responses` with native OpenAI Responses request, JSON response, and SSE behavior using stored Codex authentication |
737742
| `CCP_CODEX_ORIGINATOR` | `codex.originator` | `claude-code-proxy` | Override the `originator` header sent to Codex |
738743
| `CCP_CODEX_USER_AGENT` | `codex.userAgent` | `claude-code-proxy/<version>` | Override the `User-Agent` header sent to Codex |
739744
| `CCP_KIMI_USER_AGENT` | `kimi.userAgent` | `KimiCLI/1.37.0` | Override the `User-Agent` header sent to Kimi |
@@ -750,6 +755,14 @@ A malformed `config.json` is reported on stderr and ignored; defaults are used
750755
in its place. Invalid types for individual keys are warned and skipped without
751756
affecting other keys.
752757

758+
`CCP_CODEX_RESPONSES_API=1` enables `POST /v1/responses` for clients that use
759+
OpenAI's native Responses protocol. The route replaces incoming credentials with
760+
the proxy's stored Codex authentication, refreshes rejected access tokens before
761+
forwarding a response, and preserves native JSON or SSE response bodies. The
762+
route accepts Codex models listed by `claude-code-proxy models`; Images API,
763+
stored response retrieval and deletion, and WebSocket ingress are outside its
764+
scope.
765+
753766
Codex uses the WebSocket Responses transport by default. Set
754767
`CCP_CODEX_TRANSPORT=http` to use the older HTTP SSE transport for debugging or
755768
compatibility, or `CCP_CODEX_TRANSPORT=auto` to try WebSocket with HTTP fallback

src/config.rs

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ struct CodexConfig {
5454
pub user_agent: Option<String>,
5555
#[serde(rename = "previousResponseId")]
5656
pub previous_response_id: Option<bool>,
57+
#[serde(rename = "responsesApi")]
58+
pub responses_api: Option<bool>,
5759
#[serde(rename = "serviceTier")]
5860
pub service_tier: Option<String>,
5961
#[serde(rename = "reasoningSummary")]
@@ -217,6 +219,9 @@ pub fn config_override_summary_lines(cfg: &LoadedConfig) -> Vec<String> {
217219
if env.contains_key("CCP_LOG_STDERR") {
218220
out.push("log.stderr (env)".to_string());
219221
}
222+
if env.contains_key("CCP_CODEX_RESPONSES_API") {
223+
out.push("codex.responsesApi (env)".to_string());
224+
}
220225
if env.contains_key("CCP_KIMI_OAUTH_HOST") {
221226
out.push("kimi.oauthHost (env)".to_string());
222227
}
@@ -262,11 +267,15 @@ pub fn config_override_summary_lines(cfg: &LoadedConfig) -> Vec<String> {
262267
out.push(format!("log.stderr: {v}"));
263268
}
264269
}
265-
if let Some(codex) = file_cfg.codex
266-
&& let Some(summary) = codex.reasoning_summary
267-
&& !summary.is_empty()
268-
{
269-
out.push("codex.reasoningSummary (config)".to_string());
270+
if let Some(codex) = file_cfg.codex {
271+
if codex.responses_api == Some(true) {
272+
out.push("codex.responsesApi: true".to_string());
273+
}
274+
if let Some(summary) = codex.reasoning_summary
275+
&& !summary.is_empty()
276+
{
277+
out.push("codex.reasoningSummary (config)".to_string());
278+
}
270279
}
271280
}
272281
out
@@ -420,6 +429,21 @@ pub fn codex_previous_response_id() -> bool {
420429
false
421430
}
422431

432+
pub fn codex_responses_api() -> bool {
433+
let env: HashMap<_, _> = std::env::vars().collect();
434+
if let Some(raw) = env.get("CCP_CODEX_RESPONSES_API") {
435+
return matches!(raw.to_ascii_lowercase().as_str(), "1" | "true" | "yes");
436+
}
437+
let config_dir = paths::config_dir();
438+
if let Some(file) = read_file_config(&config_dir)
439+
&& let Some(codex) = file.codex
440+
&& let Some(enabled) = codex.responses_api
441+
{
442+
return enabled;
443+
}
444+
false
445+
}
446+
423447
pub fn codex_service_tier() -> Option<String> {
424448
let env: HashMap<_, _> = std::env::vars().collect();
425449
if let Some(raw) = env.get("CCP_CODEX_SERVICE_TIER") {
@@ -592,6 +616,7 @@ mod tests {
592616
std::env::remove_var("CCP_LOG_VERBOSE");
593617
std::env::remove_var("CCP_LOG_STDERR");
594618
std::env::remove_var("CCP_CODEX_REASONING_SUMMARY");
619+
std::env::remove_var("CCP_CODEX_RESPONSES_API");
595620
}
596621
}
597622

@@ -747,6 +772,46 @@ mod tests {
747772
assert!(loaded.log_stderr);
748773
}
749774

775+
#[test]
776+
fn codex_responses_api_defaults_to_disabled() {
777+
let _guard = ENV_LOCK.lock().unwrap();
778+
clear_env();
779+
let config = tempfile::TempDir::new().unwrap();
780+
let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path());
781+
782+
assert!(!codex_responses_api());
783+
}
784+
785+
#[test]
786+
fn codex_responses_api_reads_config_and_env_takes_precedence() {
787+
let _guard = ENV_LOCK.lock().unwrap();
788+
clear_env();
789+
let config = tempfile::TempDir::new().unwrap();
790+
std::fs::write(
791+
config.path().join("config.json"),
792+
r#"{"codex":{"responsesApi":true}}"#,
793+
)
794+
.unwrap();
795+
let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path());
796+
797+
assert!(codex_responses_api());
798+
let _responses_env = EnvGuard::set("CCP_CODEX_RESPONSES_API", "false");
799+
assert!(!codex_responses_api());
800+
}
801+
802+
#[test]
803+
fn codex_responses_api_accepts_enabled_env_values() {
804+
let _guard = ENV_LOCK.lock().unwrap();
805+
clear_env();
806+
let config = tempfile::TempDir::new().unwrap();
807+
let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path());
808+
809+
for value in ["1", "true", "TRUE", "yes"] {
810+
let _responses_env = EnvGuard::set("CCP_CODEX_RESPONSES_API", value);
811+
assert!(codex_responses_api(), "{value}");
812+
}
813+
}
814+
750815
#[test]
751816
fn codex_reasoning_summary_reads_config() {
752817
let _guard = ENV_LOCK.lock().unwrap();

src/monitor.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ pub const SESSION_TOKEN_BUCKET_SECS: u64 = 10;
1616
pub enum EndpointKind {
1717
Messages,
1818
CountTokens,
19+
Responses,
1920
}
2021

2122
impl EndpointKind {
2223
pub fn label(self) -> &'static str {
2324
match self {
2425
Self::Messages => "messages",
2526
Self::CountTokens => "count_tokens",
27+
Self::Responses => "responses",
2628
}
2729
}
2830
}

src/providers/codex/auth/manager.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::sync::Arc;
2+
use std::sync::LazyLock;
23
#[cfg(test)]
34
use std::sync::Mutex;
45
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -9,6 +10,9 @@ use super::jwt::{TokenResponse, extract_account_id, validate_token_response};
910
use super::token_store::{CodexTokenStore, StoredAuth};
1011
use crate::auth::AuthStorage;
1112

13+
static CODEX_REFRESH_LOCK: LazyLock<Arc<AsyncMutex<()>>> =
14+
LazyLock::new(|| Arc::new(AsyncMutex::new(())));
15+
1216
pub struct CodexAuthManager<S: AuthStorage<StoredAuth>> {
1317
pub store: CodexTokenStore<S>,
1418
#[cfg(test)]
@@ -28,7 +32,7 @@ impl<S: AuthStorage<StoredAuth>> CodexAuthManager<S> {
2832
store,
2933
#[cfg(test)]
3034
test_auth: Arc::new(Mutex::new(None)),
31-
refresh_lock: Arc::new(AsyncMutex::new(())),
35+
refresh_lock: CODEX_REFRESH_LOCK.clone(),
3236
refresh_client: reqwest::Client::builder()
3337
.connect_timeout(Duration::from_secs(15))
3438
.timeout(Duration::from_secs(30))

0 commit comments

Comments
 (0)