From 4035958ed57d45d8ad1f71aaa6ced4112133112e Mon Sep 17 00:00:00 2001 From: Albert Zhang Date: Fri, 31 Jul 2026 10:08:49 +0800 Subject: [PATCH] feat(auto-review): add classifier effort override Add CCP_AUTO_REVIEW_EFFORT and autoReviewEffort for detected auto-review classifier requests. Apply the configured effort before provider dispatch, preserving exclusions for streaming, tool-using, and token-counting requests. When applied to Codex, bypass the generic Codex effort override while retaining request validation and compaction caps. Document precedence and add configuration, routing, and smoke coverage. Co-Authored-By: Claude --- docs/src/content/docs/providers/codex.md | 2 + .../content/docs/reference/configuration.md | 8 +- src/anthropic/schema.rs | 2 + src/config.rs | 75 ++++++ src/openai_compat/request.rs | 1 + src/providers/codex/translate/request.rs | 35 ++- src/server.rs | 161 ++++++++++++- tests/smoke_cutover.rs | 219 +++++++++++++++++- 8 files changed, 481 insertions(+), 22 deletions(-) diff --git a/docs/src/content/docs/providers/codex.md b/docs/src/content/docs/providers/codex.md index 0541c0c2..ce7d2b42 100644 --- a/docs/src/content/docs/providers/codex.md +++ b/docs/src/content/docs/providers/codex.md @@ -32,6 +32,8 @@ When reasoning is enabled, the proxy requests an automatic reasoning summary and 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. +`CCP_AUTO_REVIEW_EFFORT` or the top-level `autoReviewEffort` key can replace the ordinary request effort for detected auto-review classifier requests before provider dispatch. Both are disabled by default; the environment setting takes precedence, and `off` explicitly disables a file-configured value. When the final provider is Codex, this more-specific value bypasses `CCP_CODEX_EFFORT` and `codex.effort`, while the normal request-effort validation and compaction cap still apply. See [Configuration](/reference/configuration/) for the provider-neutral routing behavior. + ## Tools and multimodal input - Claude function tools and tool results map to Responses API function calls and outputs. diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index 90c21619..f4c6e5ad 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -15,6 +15,7 @@ These settings configure the proxy process. Claude Code client settings such as "port": 18765, "aliasProvider": "codex", "autoReviewModel": "gpt-5.6-terra", + "autoReviewEffort": "low", "codex": { "originator": "claude-code-proxy", "userAgent": "claude-code-proxy/0.1.24", @@ -62,6 +63,7 @@ All keys are optional. An unreadable file, malformed JSON, or incompatible field | `CCP_CONFIG_DIR` | none | Platform config directory | Replaces the configuration and file-backed auth root. | | `CCP_ALIAS_PROVIDER` | `aliasProvider` | `codex` | Routes recognized Anthropic-style aliases through `codex` or `kimi`. | | `CCP_AUTO_REVIEW_MODEL` | `autoReviewModel` | `gpt-5.6-luna` for Codex | Routes Claude Code's non-streaming, tool-free Bash security-review classifier through a registered model. | +| `CCP_AUTO_REVIEW_EFFORT` | `autoReviewEffort` | `off` | Replaces `output_config.effort` on detected auto-review classifier requests. | | `CCP_LOG_STDERR` | `log.stderr` | `false` | Mirrors logs to stderr when present in the environment, regardless of its value. | | `CCP_LOG_VERBOSE` | `log.verbose` | `false` | Preserves full string fields in structured logs when present, regardless of its value. | | `CCP_TRAFFIC_LOG` | none | `false` | Enables full request captures for `1`, `true`, or `yes`. | @@ -69,7 +71,9 @@ 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. -Codex auto-review classifier requests use `gpt-5.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. +Codex auto-review classifier requests use `gpt-5.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. + +`CCP_AUTO_REVIEW_EFFORT` and the top-level `autoReviewEffort` key are disabled by default. A non-empty value other than `off` replaces the request's ordinary `output_config.effort` for every detected classifier request, independently of whether its model changes. The environment setting takes precedence; `off` disables a file-configured effort, while an empty environment value falls through to the file like `CCP_AUTO_REVIEW_MODEL`. The value is not validated at startup: after model routing succeeds, the final provider handles it exactly as if the client had supplied that request effort. Provider routing remains controlled only by the model setting. Normal messages, streaming requests, tool-using requests, and token counting retain their requested model and effort. ## Outbound proxies @@ -114,6 +118,8 @@ Proxy URLs may use `http`, `https`, `socks4`, `socks4a`, `socks5`, or `socks5h`. `CLAUDE_CODE_PROXY_CODEX_BASE_URL` remains an accepted fallback for the Codex base URL. `CCP_CODEX_BASE_URL` takes precedence. +When an active auto-review effort reaches Codex, it bypasses `CCP_CODEX_EFFORT` and `codex.effort` so the more specific request value is preserved, just as an auto-review model route bypasses `CCP_CODEX_MODEL`. The ordinary request-effort validator and compaction cap still apply. + ## Kimi | Environment | Config key | Default | Purpose | diff --git a/src/anthropic/schema.rs b/src/anthropic/schema.rs index 6f35b105..7a31e5e0 100644 --- a/src/anthropic/schema.rs +++ b/src/anthropic/schema.rs @@ -11,6 +11,8 @@ pub struct MessagesRequest { pub stream: bool, #[serde(skip)] pub bypass_provider_model_override: bool, + #[serde(skip)] + pub bypass_provider_effort_override: bool, #[serde(flatten)] pub extra: serde_json::Map, } diff --git a/src/config.rs b/src/config.rs index f11a1f9d..e3f9971b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -39,6 +39,8 @@ struct FileConfig { pub alias_provider: Option, #[serde(rename = "autoReviewModel")] pub auto_review_model: Option, + #[serde(rename = "autoReviewEffort")] + pub auto_review_effort: Option, pub log: Option, pub kimi: Option, pub codex: Option, @@ -291,6 +293,12 @@ pub fn config_override_summary_lines(cfg: &LoadedConfig) -> Vec { { out.push("CCP_AUTO_REVIEW_MODEL (env)".to_string()); } + if env + .get("CCP_AUTO_REVIEW_EFFORT") + .is_some_and(|raw| !raw.is_empty()) + { + out.push("CCP_AUTO_REVIEW_EFFORT (env)".to_string()); + } if let Some(file_cfg) = file { if let Some(bind_address) = file_cfg.bind_address { out.push(format!("bindAddress: {bind_address}")); @@ -307,6 +315,12 @@ pub fn config_override_summary_lines(cfg: &LoadedConfig) -> Vec { { out.push("autoReviewModel (config)".to_string()); } + if file_cfg + .auto_review_effort + .is_some_and(|raw| !matches!(raw.as_str(), "" | "off")) + { + out.push("autoReviewEffort (config)".to_string()); + } if let Some(log) = file_cfg.log { if let Some(v) = log.verbose { out.push(format!("log.verbose: {v}")); @@ -680,6 +694,21 @@ pub fn auto_review_model() -> Option { .filter(|model| !model.is_empty()) } +pub fn auto_review_effort() -> Option { + let env: HashMap<_, _> = std::env::vars().collect(); + if let Some(raw) = env.get("CCP_AUTO_REVIEW_EFFORT") { + if raw == "off" { + return None; + } + if !raw.is_empty() { + return Some(raw.clone()); + } + } + read_file_config(&paths::config_dir()) + .and_then(|file| file.auto_review_effort) + .filter(|raw| !matches!(raw.as_str(), "" | "off")) +} + // --------------------------------------------------------------------------- // Codex transport config // --------------------------------------------------------------------------- @@ -817,6 +846,7 @@ mod tests { std::env::remove_var("CCP_CODEX_IMAGES_API"); std::env::remove_var("CCP_CODEX_IMAGES_BASE_URL"); std::env::remove_var("CCP_AUTO_REVIEW_MODEL"); + std::env::remove_var("CCP_AUTO_REVIEW_EFFORT"); } } @@ -1099,6 +1129,51 @@ mod tests { } } + #[test] + fn auto_review_effort_reads_top_level_config_with_env_precedence() { + let _guard = ENV_LOCK.lock().unwrap(); + clear_env(); + let config = tempfile::TempDir::new().unwrap(); + let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path()); + + assert_eq!(auto_review_effort(), None); + for value in ["", "off"] { + std::fs::write( + config.path().join("config.json"), + format!(r#"{{"autoReviewEffort":"{value}"}}"#), + ) + .unwrap(); + assert_eq!(auto_review_effort(), None, "file value {value:?}"); + } + std::fs::write( + config.path().join("config.json"), + r#"{"autoReviewEffort":"medium"}"#, + ) + .unwrap(); + assert_eq!(auto_review_effort().as_deref(), Some("medium")); + assert!( + config_override_summary_lines(&load_config()) + .contains(&"autoReviewEffort (config)".to_string()) + ); + + for value in ["low", "none", "bogus", " LOW "] { + let _effort_env = EnvGuard::set("CCP_AUTO_REVIEW_EFFORT", value); + assert_eq!(auto_review_effort().as_deref(), Some(value)); + } + { + let _effort_env = EnvGuard::set("CCP_AUTO_REVIEW_EFFORT", ""); + assert_eq!(auto_review_effort().as_deref(), Some("medium")); + } + { + let _effort_env = EnvGuard::set("CCP_AUTO_REVIEW_EFFORT", "off"); + assert_eq!(auto_review_effort(), None); + assert!( + config_override_summary_lines(&load_config()) + .contains(&"CCP_AUTO_REVIEW_EFFORT (env)".to_string()) + ); + } + } + #[test] fn codex_server_compaction_defaults_and_overrides() { let _guard = ENV_LOCK.lock().unwrap(); diff --git a/src/openai_compat/request.rs b/src/openai_compat/request.rs index 659651b6..df93b13f 100644 --- a/src/openai_compat/request.rs +++ b/src/openai_compat/request.rs @@ -167,6 +167,7 @@ pub fn parse_request( messages, stream: true, bypass_provider_model_override: false, + bypass_provider_effort_override: false, extra, }, requested_model, diff --git a/src/providers/codex/translate/request.rs b/src/providers/codex/translate/request.rs index b9980397..6c9033d5 100644 --- a/src/providers/codex/translate/request.rs +++ b/src/providers/codex/translate/request.rs @@ -276,10 +276,6 @@ pub(crate) fn to_codex_effort(effort: Option<&str>) -> Option { } } -fn resolve_effort(effort: Option) -> Result, anyhow::Error> { - resolve_effort_override(effort, config::codex_effort().as_deref()) -} - pub(crate) fn resolve_effort_override( effort: Option, override_effort: Option<&str>, @@ -526,10 +522,13 @@ pub fn translate_request( let effort = read_effort(req)?; let codex_effort = to_codex_effort(effort); - let mut resolved_effort = resolve_effort(codex_effort)?; + let global_effort = (!req.bypass_provider_effort_override) + .then(config::codex_effort) + .flatten(); + let mut resolved_effort = resolve_effort_override(codex_effort, global_effort.as_deref())?; if is_compact && let Some(cap) = compact_effort_cap() - && resolved_effort.as_ref().is_some_and(|e| *e > cap) + && resolved_effort.as_ref().is_some_and(|effort| *effort > cap) { resolved_effort = Some(cap); } @@ -1572,6 +1571,30 @@ mod tests { assert!(matches!(effort, Some(Effort::Max))); } + #[test] + fn provider_effort_bypass_marker_cannot_be_spoofed_or_serialized() { + let spoofed: MessagesRequest = serde_json::from_value(json!({ + "model": "gpt-5.6-sol", + "messages": [{"role":"user", "content":"hello"}], + "bypass_provider_effort_override": true + })) + .unwrap(); + assert!(!spoofed.bypass_provider_effort_override); + + let mut internal: MessagesRequest = serde_json::from_value(json!({ + "model": "gpt-5.6-sol", + "messages": [{"role":"user", "content":"hello"}] + })) + .unwrap(); + internal.bypass_provider_effort_override = true; + assert!( + serde_json::to_value(internal) + .unwrap() + .get("bypass_provider_effort_override") + .is_none() + ); + } + #[test] fn compact_request_detected_from_system_marker() { assert!(is_compact_request(Some( diff --git a/src/server.rs b/src/server.rs index 545ca0a6..1fdf002f 100644 --- a/src/server.rs +++ b/src/server.rs @@ -102,6 +102,32 @@ fn apply_auto_review_model( Some(route) } +fn apply_auto_review_effort( + body: &mut crate::anthropic::schema::MessagesRequest, + count_tokens: bool, + configured_effort: Option<&str>, +) -> bool { + if count_tokens || !is_claude_auto_review_request(body) { + return false; + } + let Some(effort) = configured_effort else { + return false; + }; + + let output_config = body + .extra + .entry("output_config".to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if !output_config.is_object() { + *output_config = Value::Object(Map::new()); + } + output_config + .as_object_mut() + .expect("output_config was normalized to an object") + .insert("effort".to_string(), Value::String(effort.to_string())); + true +} + pub struct ServerConfig { pub bind_address: String, pub port: u16, @@ -1400,7 +1426,16 @@ async fn dispatch_request( } }; - body.bypass_provider_model_override = auto_review_route.is_some() && provider.name() == "codex"; + let codex_auto_review_model = auto_review_route.is_some() && provider.name() == "codex"; + body.bypass_provider_model_override = codex_auto_review_model; + + let configured_auto_review_effort = crate::config::auto_review_effort(); + let auto_review_effort_applied = apply_auto_review_effort( + &mut body, + count_tokens, + configured_auto_review_effort.as_deref(), + ); + body.bypass_provider_effort_override = auto_review_effort_applied && provider.name() == "codex"; if let Some(route) = auto_review_route.as_ref() { log.info( @@ -1890,9 +1925,9 @@ fn _unused(session_state: Option<&SessionState>) { #[cfg(test)] mod auto_review_tests { - use super::{apply_auto_review_model, is_claude_auto_review_request}; + use super::{apply_auto_review_effort, apply_auto_review_model, is_claude_auto_review_request}; use crate::anthropic::schema::MessagesRequest; - use serde_json::json; + use serde_json::{Value, json}; fn request(system: &str, stream: bool, tools: serde_json::Value) -> MessagesRequest { serde_json::from_value(json!({ @@ -1974,6 +2009,126 @@ mod auto_review_tests { assert_eq!(classifier.model.as_deref(), Some("grok-4.5")); } + #[test] + fn configured_effort_rewrites_the_ordinary_request_field() { + 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", + "format": {"type": "json_object"} + }), + ); + + assert!(apply_auto_review_effort( + &mut classifier, + false, + Some("bogus") + )); + assert_eq!(classifier.extra["output_config"]["effort"], json!("bogus")); + assert_eq!( + classifier.extra["output_config"]["format"], + json!({"type": "json_object"}) + ); + } + + #[test] + fn configured_effort_normalizes_missing_or_malformed_output_config() { + for initial in [None, Some(Value::Null), Some(json!("invalid"))] { + let mut classifier = request( + "You are a security monitor for autonomous AI coding agents.", + false, + json!([]), + ); + if let Some(initial) = initial { + classifier + .extra + .insert("output_config".to_string(), initial); + } + + assert!(apply_auto_review_effort( + &mut classifier, + false, + Some("low") + )); + assert_eq!(classifier.extra["output_config"], json!({"effort": "low"})); + } + } + + #[test] + fn effort_override_follows_model_routing_but_does_not_require_it() { + let mut kimi_classifier = request( + "You are a security monitor for autonomous AI coding agents.", + false, + json!([]), + ); + kimi_classifier.model = Some("kimi-for-coding".to_string()); + assert!(apply_auto_review_model(&mut kimi_classifier, false, None, "kimi").is_none()); + assert!(apply_auto_review_effort( + &mut kimi_classifier, + false, + Some("medium") + )); + assert_eq!(kimi_classifier.model.as_deref(), Some("kimi-for-coding")); + assert_eq!( + kimi_classifier.extra["output_config"]["effort"], + json!("medium") + ); + + let mut rerouted = request( + "You are a security monitor for autonomous AI coding agents.", + false, + json!([]), + ); + let route = apply_auto_review_model(&mut rerouted, false, Some("grok-4.5"), "codex") + .expect("configured model should route"); + assert_eq!(route.override_model, "grok-4.5"); + assert!(apply_auto_review_effort( + &mut rerouted, + false, + Some("xhigh") + )); + assert_eq!(rerouted.model.as_deref(), Some("grok-4.5")); + assert_eq!(rerouted.extra["output_config"]["effort"], json!("xhigh")); + } + + #[test] + fn effort_override_keeps_pr72_exclusions() { + let cases = [ + request("You are an interactive coding agent.", false, json!([])), + request( + "You are a security monitor for autonomous AI coding agents.", + true, + json!([]), + ), + request( + "You are a security monitor for autonomous AI coding agents.", + false, + json!([{"name": "Bash"}]), + ), + ]; + for mut body in cases { + assert!(!apply_auto_review_effort(&mut body, false, Some("low"))); + assert!(body.extra.get("output_config").is_none()); + } + + let mut count_tokens = request( + "You are a security monitor for autonomous AI coding agents.", + false, + json!([]), + ); + assert!(!apply_auto_review_effort( + &mut count_tokens, + true, + Some("low") + )); + assert!(count_tokens.extra.get("output_config").is_none()); + } + #[test] fn count_tokens_keeps_requested_model() { let mut classifier = request( diff --git a/tests/smoke_cutover.rs b/tests/smoke_cutover.rs index 717c8b0d..0650cecc 100644 --- a/tests/smoke_cutover.rs +++ b/tests/smoke_cutover.rs @@ -7,7 +7,7 @@ use axum::response::Response; use claude_code_proxy::providers::codex::compaction::clear_all_compactions_for_tests; use claude_code_proxy::providers::codex::continuation::clear_all_continuations_for_tests; use claude_code_proxy::providers::codex::websocket::clear_codex_websocket_pool_for_tests; -use claude_code_proxy::{registry::Registry, server::app}; +use claude_code_proxy::{config::AliasProvider, registry::Registry, server::app}; use futures_util::{SinkExt, StreamExt}; use http_body_util::BodyExt; use serde_json::{Value, json}; @@ -61,6 +61,14 @@ impl EnvGuard { } Self { key, previous } } + + fn remove(key: &'static str) -> Self { + let previous = std::env::var_os(key); + unsafe { + std::env::remove_var(key); + } + Self { key, previous } + } } impl Drop for EnvGuard { @@ -85,14 +93,26 @@ async fn call_messages(model: &str) -> Response { } async fn call_messages_body(body: Value) -> Response { + call_messages_body_for_session(body, "smoke-session").await +} + +async fn call_count_tokens_body(body: Value) -> Response { + call_messages_body_for_uri(body, "/v1/messages/count_tokens", "smoke-session").await +} + +async fn call_messages_body_for_session(body: Value, session_id: &str) -> Response { + call_messages_body_for_uri(body, "/v1/messages", session_id).await +} + +async fn call_messages_body_for_uri(body: Value, uri: &str, session_id: &str) -> Response { let _no_proxy_env = EnvGuard::set("NO_PROXY", "127.0.0.1,localhost"); app(Arc::new(Registry::with_default_alias())) .oneshot( Request::builder() .method(Method::POST) - .uri("/v1/messages") + .uri(uri) .header("content-type", "application/json") - .header("x-claude-code-session-id", "smoke-session") + .header("x-claude-code-session-id", session_id) .body(Body::from(body.to_string())) .unwrap(), ) @@ -784,6 +804,96 @@ async fn smoke_kimi_messages_uses_mock_upstream() { assert!(!sent.to_string().contains("compaction_trigger")); } +#[allow(clippy::await_holding_lock)] +#[tokio::test(flavor = "multi_thread")] +async fn smoke_auto_review_effort_follows_kimi_routes() { + let _guard = env_lock(); + let config = TempDir::new().unwrap(); + write_auth(config.path(), "kimi"); + + let captured = Arc::new(Mutex::new(Vec::new())); + let upstream = spawn_http_upstream({ + let captured = captured.clone(); + move |body: Value| { + captured.lock().unwrap().push(body); + concat!( + "data: {\"choices\":[{\"delta\":{\"content\":\"review ok\"}}]}\n\n", + "data: {\"choices\":[{\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2}}\n\n", + "data: [DONE]\n\n" + ) + .as_bytes() + .to_vec() + } + }) + .await; + + let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path()); + let _base_url_env = EnvGuard::set("CCP_KIMI_BASE_URL", &upstream); + let _review_model_env = EnvGuard::remove("CCP_AUTO_REVIEW_MODEL"); + let _review_effort_env = EnvGuard::set("CCP_AUTO_REVIEW_EFFORT", "low"); + let classifier_body = |model: &str, effort: &str| { + json!({ + "model": model, + "max_tokens": 64, + "stream": false, + "system": [{ + "type": "text", + "text": "You are a security monitor for autonomous AI coding agents.\n\n## Context" + }], + "messages": [{"role":"user","content":"review this Bash command"}], + "tools": [], + "output_config": {"effort": effort} + }) + }; + + let effort_only = call_messages_body_for_session( + classifier_body("kimi-for-coding", "high"), + "smoke-effort-only-affinity", + ) + .await; + assert_eq!(effort_only.status(), StatusCode::OK); + let _ = axum::body::to_bytes(effort_only.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!( + claude_code_proxy::session::existing_session_now(Some("smoke-effort-only-affinity")) + .and_then(|state| state.affinity_provider), + Some(AliasProvider::Kimi), + "an effort-only auto-review request must retain normal affinity bookkeeping" + ); + + { + let _model_env = EnvGuard::set("CCP_AUTO_REVIEW_MODEL", "kimi-for-coding"); + let _effort_env = EnvGuard::set("CCP_AUTO_REVIEW_EFFORT", "xhigh"); + let rerouted = call_messages_body(classifier_body("gpt-5.6-sol", "medium")).await; + assert_eq!(rerouted.status(), StatusCode::OK); + let _ = axum::body::to_bytes(rerouted.into_body(), usize::MAX) + .await + .unwrap(); + } + + let normal = call_messages_body(json!({ + "model": "kimi-for-coding", + "max_tokens": 64, + "messages": [{"role":"user","content":"hello"}], + "output_config": {"effort": "high"} + })) + .await; + assert_eq!(normal.status(), StatusCode::OK); + let _ = axum::body::to_bytes(normal.into_body(), usize::MAX) + .await + .unwrap(); + + let sent = captured.lock().unwrap(); + assert_eq!(sent.len(), 3); + assert_eq!(sent[0]["model"], "kimi-for-coding"); + assert_eq!(sent[0]["reasoning_effort"], "low"); + assert_eq!(sent[1]["model"], "kimi-for-coding"); + assert_eq!(sent[1]["reasoning_effort"], "high"); + assert_eq!(sent[2]["model"], "kimi-for-coding"); + assert_eq!(sent[2]["reasoning_effort"], "high"); +} + // --------------------------------------------------------------------------- // Codex HTTP smoke: mock upstream verifies request shape and returns // Responses SSE events. @@ -1093,6 +1203,10 @@ async fn smoke_auto_review_uses_codex_default_and_configured_override() { let _base_url_env = EnvGuard::set("CCP_CODEX_BASE_URL", &upstream); let _transport_env = EnvGuard::set("CCP_CODEX_TRANSPORT", "http"); let _codex_model_env = EnvGuard::set("CCP_CODEX_MODEL", "gpt-5.6-sol"); + let _review_model_env = EnvGuard::remove("CCP_AUTO_REVIEW_MODEL"); + let _review_effort_env = EnvGuard::remove("CCP_AUTO_REVIEW_EFFORT"); + let _codex_effort_env = EnvGuard::remove("CCP_CODEX_EFFORT"); + let _compact_effort_env = EnvGuard::remove("CCP_COMPACT_EFFORT"); let classifier_body = || { json!({ "model": "gpt-5.6-sol", @@ -1103,7 +1217,8 @@ async fn smoke_auto_review_uses_codex_default_and_configured_override() { "text": "You are a security monitor for autonomous AI coding agents.\n\n## Context" }], "messages": [{"role":"user","content":"review this Bash command"}], - "tools": [] + "tools": [], + "output_config": {"effort": "high"} }) }; @@ -1114,7 +1229,8 @@ async fn smoke_auto_review_uses_codex_default_and_configured_override() { .unwrap(); { - let _review_model_env = EnvGuard::set("CCP_AUTO_REVIEW_MODEL", "gpt-5.6-terra"); + let _effort_env = EnvGuard::set("CCP_AUTO_REVIEW_EFFORT", "low"); + let _global_effort_env = EnvGuard::set("CCP_CODEX_EFFORT", "bogus"); let classifier = call_messages_body(classifier_body()).await; assert_eq!(classifier.status(), StatusCode::OK); let _ = axum::body::to_bytes(classifier.into_body(), usize::MAX) @@ -1122,17 +1238,96 @@ async fn smoke_auto_review_uses_codex_default_and_configured_override() { .unwrap(); } - let normal = call_messages("gpt-5.6-sol").await; - assert_eq!(normal.status(), StatusCode::OK); - let _ = axum::body::to_bytes(normal.into_body(), usize::MAX) - .await + { + let _effort_env = EnvGuard::set("CCP_AUTO_REVIEW_EFFORT", "none"); + let classifier = call_messages_body(classifier_body()).await; + assert_eq!(classifier.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(classifier.into_body(), usize::MAX) + .await + .unwrap(); + assert!( + String::from_utf8_lossy(&body).contains("Invalid output_config.effort: none"), + "unexpected response: {}", + String::from_utf8_lossy(&body) + ); + + let count_tokens = call_count_tokens_body(classifier_body()).await; + assert_eq!(count_tokens.status(), StatusCode::OK); + let body: Value = serde_json::from_slice( + &axum::body::to_bytes(count_tokens.into_body(), usize::MAX) + .await + .unwrap(), + ) .unwrap(); + assert!( + body["input_tokens"] + .as_u64() + .is_some_and(|tokens| tokens > 0) + ); + } + + { + let _effort_env = EnvGuard::set("CCP_AUTO_REVIEW_EFFORT", "off"); + let _global_effort_env = EnvGuard::set("CCP_CODEX_EFFORT", "low"); + let classifier = call_messages_body(classifier_body()).await; + assert_eq!(classifier.status(), StatusCode::OK); + let _ = axum::body::to_bytes(classifier.into_body(), usize::MAX) + .await + .unwrap(); + } + + { + let _model_env = EnvGuard::set("CCP_AUTO_REVIEW_MODEL", "gpt-5.6-terra"); + let _effort_env = EnvGuard::set("CCP_AUTO_REVIEW_EFFORT", "max"); + let classifier = call_messages_body(classifier_body()).await; + assert_eq!(classifier.status(), StatusCode::OK); + let _ = axum::body::to_bytes(classifier.into_body(), usize::MAX) + .await + .unwrap(); + } + + { + let _model_env = EnvGuard::set("CCP_AUTO_REVIEW_MODEL", "unknown-review-model"); + let _effort_env = EnvGuard::set("CCP_AUTO_REVIEW_EFFORT", "low"); + let classifier = call_messages_body(classifier_body()).await; + assert_eq!(classifier.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(classifier.into_body(), usize::MAX) + .await + .unwrap(); + assert!( + String::from_utf8_lossy(&body).contains("unknown-review-model"), + "unexpected response: {}", + String::from_utf8_lossy(&body) + ); + } + + { + let _effort_env = EnvGuard::set("CCP_AUTO_REVIEW_EFFORT", "low"); + let normal = call_messages_body(json!({ + "model": "gpt-5.6-sol", + "max_tokens": 64, + "messages": [{"role":"user","content":"hello"}], + "output_config": {"effort": "high"} + })) + .await; + assert_eq!(normal.status(), StatusCode::OK); + let _ = axum::body::to_bytes(normal.into_body(), usize::MAX) + .await + .unwrap(); + } let sent = captured.lock().unwrap(); - assert_eq!(sent.len(), 3); + assert_eq!(sent.len(), 5); assert_eq!(sent[0]["model"], "gpt-5.6-luna"); - assert_eq!(sent[1]["model"], "gpt-5.6-terra"); - assert_eq!(sent[2]["model"], "gpt-5.6-sol"); + assert_eq!(sent[0]["reasoning"]["effort"], "high"); + assert_eq!(sent[1]["model"], "gpt-5.6-luna"); + assert_eq!(sent[1]["reasoning"]["effort"], "low"); + assert_eq!(sent[2]["model"], "gpt-5.6-luna"); + assert_eq!(sent[2]["reasoning"]["effort"], "low"); + assert_eq!(sent[3]["model"], "gpt-5.6-terra"); + assert_eq!(sent[3]["reasoning"]["effort"], "max"); + assert_eq!(sent[4]["model"], "gpt-5.6-sol"); + assert_eq!(sent[4]["reasoning"]["effort"], "high"); } #[allow(clippy::await_holding_lock)]