diff --git a/docs/src/content/docs/providers/codex.md b/docs/src/content/docs/providers/codex.md index cca0fc42..00ca81b7 100644 --- a/docs/src/content/docs/providers/codex.md +++ b/docs/src/content/docs/providers/codex.md @@ -54,6 +54,8 @@ Claude Code's `/effort` value maps to Codex `reasoning.effort`: `low`, `medium`, When reasoning is enabled, the proxy requests an automatic reasoning summary and translates summary deltas into Claude Code thinking blocks. Codex may omit a summary for a simple prompt. `CCP_CODEX_REASONING_SUMMARY=off` suppresses summaries while preserving effort and encrypted continuation content. +Detected auto-review classifier requests default to `low` effort, the effort Codex's own approval reviewer uses, unless the request already sets `output_config.effort`. + Claude Code summary compaction requests are capped at low effort by default because they perform extraction over a large transcript. `CCP_COMPACT_EFFORT=off` disables the cap, `none` removes reasoning, and another valid effort sets a different maximum. The cap never raises effort. ## Usage reporting @@ -117,7 +119,7 @@ WebSocket setup honors `HTTP_PROXY` for `ws://`, `HTTPS_PROXY` for the default ` An absent, malformed, or ambiguous identity does not reject the HTTP request; that request proceeds without continuation or WebSocket reuse. If the originating socket is missing, dead, or has been replaced, the proxy retries once with the full translated input and without the stale response ID. Continuation and connection state is held only in memory and is lost when the proxy restarts. -Detected auto-review classifier subrequests are intentionally stateless even when valid session and Agent headers are present. They neither consume nor publish continuation or WebSocket ownership. +Detected auto-review classifier subrequests are intentionally stateless even when valid session and Agent headers are present. They neither consume nor publish continuation or WebSocket ownership. Their `prompt_cache_key` is `guardian:`, the reviewer-scoped key Codex uses for its own approval reviewer, so they do not share the main conversation's cache key. ## Server compaction diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index 08514b10..8cd301a4 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -75,7 +75,7 @@ All keys are optional. An unreadable file, malformed JSON, or incompatible field `CCP_CONFIG_DIR` affects `config.json` and file-backed provider auth. It does not relocate the state directory. -Auto-review routing resolves the classifier's requested model first, not the main conversation's model. Recognized Claude aliases follow Codex/Kimi session affinity or `aliasProvider`; Grok and other providers do not establish alias affinity. Codex auto-review classifier requests use `gpt-6-luna` by default. Requests routed through other providers retain their requested model. `CCP_AUTO_REVIEW_MODEL` or `autoReviewModel` selects an explicit registered model for all detected classifier requests without changing the session's provider affinity. Normal messages, streaming requests, tool-using requests, and token counting retain their requested model. +Auto-review routing resolves the classifier's requested model first, not the main conversation's model. Recognized Claude aliases follow Codex/Kimi session affinity or `aliasProvider`; Grok and other providers do not establish alias affinity. Codex auto-review classifier requests use `gpt-6-luna` by default. Requests routed through other providers retain their requested model. `CCP_AUTO_REVIEW_MODEL` or `autoReviewModel` selects an explicit registered model for all detected classifier requests without changing the session's provider affinity. Normal messages, streaming requests, tool-using requests, and token counting retain their requested model. On Codex, detected classifier requests also default to `low` effort and use a `guardian:` prompt cache key, as Codex's own approval reviewer does. ## Outbound proxies diff --git a/src/anthropic/schema.rs b/src/anthropic/schema.rs index 6f35b105..3b0a70d9 100644 --- a/src/anthropic/schema.rs +++ b/src/anthropic/schema.rs @@ -11,6 +11,10 @@ pub struct MessagesRequest { pub stream: bool, #[serde(skip)] pub bypass_provider_model_override: bool, + #[serde(skip)] + pub prompt_cache_key_override: Option, + #[serde(skip)] + pub omit_reasoning_artifacts: bool, #[serde(flatten)] pub extra: serde_json::Map, } diff --git a/src/openai_compat/request.rs b/src/openai_compat/request.rs index fac3aa6d..072a84d8 100644 --- a/src/openai_compat/request.rs +++ b/src/openai_compat/request.rs @@ -168,6 +168,8 @@ pub fn parse_request( messages, stream: true, bypass_provider_model_override: false, + prompt_cache_key_override: None, + omit_reasoning_artifacts: false, extra, }, requested_model, diff --git a/src/providers/codex/translate/request.rs b/src/providers/codex/translate/request.rs index fea9a779..4f40b5d9 100644 --- a/src/providers/codex/translate/request.rs +++ b/src/providers/codex/translate/request.rs @@ -579,6 +579,9 @@ fn translate_request_inner( if let Some(sid) = opts.session_id { out.prompt_cache_key = Some(sid); } + if let Some(key) = req.prompt_cache_key_override.clone() { + out.prompt_cache_key = Some(key); + } if apply_codex_config { let service_tier = resolve_service_tier(opts.service_tier, &out.model)?; @@ -600,6 +603,7 @@ fn translate_request_inner( let wants_reasoning = reasoning_requested(resolved_effort.as_ref()); if resolved_effort.is_some() || opts.use_responses_lite { let summary = if wants_reasoning + && !req.omit_reasoning_artifacts && (!apply_codex_config || reasoning_summary_requested(config::codex_reasoning_summary().as_deref())) { @@ -613,7 +617,7 @@ fn translate_request_inner( context: opts.use_responses_lite.then_some("all_turns".to_string()), }); } - if wants_reasoning { + if wants_reasoning && !req.omit_reasoning_artifacts { out.include = Some(vec!["reasoning.encrypted_content".to_string()]); } diff --git a/src/server.rs b/src/server.rs index a12db0c8..ea6d1171 100644 --- a/src/server.rs +++ b/src/server.rs @@ -107,6 +107,25 @@ fn apply_auto_review_model( Some(route) } +/// Mirrors Codex's own approval reviewer: Low effort (`ext/guardian-reviewer/src/model.rs`) +/// and a reviewer-scoped `guardian:{parent}` prompt cache key (`core/src/guardian/review_session.rs`). +fn apply_codex_auto_review_defaults( + body: &mut crate::anthropic::schema::MessagesRequest, + session_id: Option<&str>, +) { + let output_config = body + .extra + .entry("output_config") + .or_insert_with(|| json!({})); + if let Some(config) = output_config.as_object_mut() { + config.entry("effort").or_insert_with(|| json!("low")); + } + body.prompt_cache_key_override = session_id.map(|id| format!("guardian:{id}")); + // The classifier reads only its text verdict; a reasoning summary or encrypted + // reasoning would be generated and returned for nothing. + body.omit_reasoning_artifacts = true; +} + pub struct ServerConfig { pub bind_address: String, pub port: u16, @@ -1718,6 +1737,9 @@ async fn dispatch_request( }; body.bypass_provider_model_override = auto_review_route.is_some() && provider.name() == "codex"; + if body.bypass_provider_model_override { + apply_codex_auto_review_defaults(&mut body, session_id.as_deref()); + } if let Some(route) = auto_review_route.as_ref() { log.info( @@ -2318,7 +2340,10 @@ mod request_id_header_tests { #[cfg(test)] mod auto_review_tests { - use super::{apply_auto_review_model, headers_to_record, is_claude_auto_review_request}; + use super::{ + apply_auto_review_model, apply_codex_auto_review_defaults, headers_to_record, + is_claude_auto_review_request, + }; use crate::anthropic::schema::MessagesRequest; use crate::request_identity::{CLAUDE_AGENT_HEADER, CLAUDE_PARENT_AGENT_HEADER}; use http::{HeaderMap, HeaderValue}; @@ -2379,6 +2404,58 @@ mod auto_review_tests { assert_eq!(classifier.model.as_deref(), Some("gpt-6-luna")); } + #[test] + fn codex_classifier_gets_low_effort_and_guardian_cache_key() { + let mut classifier = request( + "You are a security monitor for autonomous AI coding agents.", + false, + json!([]), + ); + apply_codex_auto_review_defaults(&mut classifier, Some("sess-1")); + assert_eq!(classifier.extra["output_config"]["effort"], "low"); + assert_eq!( + classifier.prompt_cache_key_override.as_deref(), + Some("guardian:sess-1") + ); + let translated = crate::providers::codex::translate::request::translate_request( + &classifier, + crate::providers::codex::translate::request::TranslateOptions { + session_id: Some("sess-1".to_string()), + service_tier: None, + model: "gpt-6-luna".to_string(), + use_responses_lite: true, + }, + ) + .unwrap(); + assert_eq!( + translated.prompt_cache_key.as_deref(), + Some("guardian:sess-1") + ); + let reasoning = serde_json::to_value(&translated.reasoning).unwrap(); + assert_eq!(reasoning["effort"], "low"); + assert!( + reasoning + .get("summary") + .is_none_or(serde_json::Value::is_null) + ); + assert!(translated.include.is_none()); + } + + #[test] + fn codex_classifier_keeps_explicit_effort() { + let mut classifier = request( + "You are a security monitor for autonomous AI coding agents.", + false, + json!([]), + ); + classifier + .extra + .insert("output_config".to_string(), json!({"effort": "high"})); + apply_codex_auto_review_defaults(&mut classifier, None); + assert_eq!(classifier.extra["output_config"]["effort"], "high"); + assert!(classifier.prompt_cache_key_override.is_none()); + } + #[test] fn non_codex_classifier_keeps_requested_model_without_override() { let mut classifier = request(