diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index 66cdb8cb..f1131695 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -14,6 +14,7 @@ These settings configure the proxy process. Claude Code client settings such as "bindAddress": "127.0.0.1", "port": 18765, "aliasProvider": "codex", + "autoReviewModel": "gpt-5.6-terra", "codex": { "originator": "claude-code-proxy", "userAgent": "claude-code-proxy/0.1.24", @@ -58,6 +59,7 @@ All keys are optional. An unreadable file, malformed JSON, or incompatible field | `PORT` | `port` | `18765` | Listener port. | | `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_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`. | @@ -65,6 +67,8 @@ 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 | Environment | Config key | Default | Purpose | diff --git a/src/anthropic/schema.rs b/src/anthropic/schema.rs index cbd57e57..6f35b105 100644 --- a/src/anthropic/schema.rs +++ b/src/anthropic/schema.rs @@ -9,6 +9,8 @@ pub struct MessagesRequest { pub messages: Vec, #[serde(default)] pub stream: bool, + #[serde(skip)] + pub bypass_provider_model_override: bool, #[serde(flatten)] pub extra: serde_json::Map, } diff --git a/src/config.rs b/src/config.rs index 595d75fe..2a1d2e93 100644 --- a/src/config.rs +++ b/src/config.rs @@ -37,6 +37,8 @@ struct FileConfig { pub port: Option, #[serde(rename = "aliasProvider")] pub alias_provider: Option, + #[serde(rename = "autoReviewModel")] + pub auto_review_model: Option, pub log: Option, pub kimi: Option, pub codex: Option, @@ -254,6 +256,12 @@ pub fn config_override_summary_lines(cfg: &LoadedConfig) -> Vec { if env.contains_key("CCP_CODEX_SERVER_COMPACTION") { out.push("CCP_CODEX_SERVER_COMPACTION (env)".to_string()); } + if env + .get("CCP_AUTO_REVIEW_MODEL") + .is_some_and(|raw| !raw.is_empty()) + { + out.push("CCP_AUTO_REVIEW_MODEL (env)".to_string()); + } if let Some(file_cfg) = file { if let Some(bind_address) = file_cfg.bind_address { out.push(format!("bindAddress: {bind_address}")); @@ -264,6 +272,12 @@ pub fn config_override_summary_lines(cfg: &LoadedConfig) -> Vec { if let Some(alias) = file_cfg.alias_provider { out.push(format!("aliasProvider: {alias}")); } + if file_cfg + .auto_review_model + .is_some_and(|model| !model.is_empty()) + { + out.push("autoReviewModel (config)".to_string()); + } if let Some(log) = file_cfg.log { if let Some(v) = log.verbose { out.push(format!("log.verbose: {v}")); @@ -588,6 +602,19 @@ pub fn codex_model() -> Option { None } +pub fn auto_review_model() -> Option { + let env: HashMap<_, _> = std::env::vars().collect(); + if let Some(raw) = env + .get("CCP_AUTO_REVIEW_MODEL") + .filter(|raw| !raw.is_empty()) + { + return Some(raw.clone()); + } + read_file_config(&paths::config_dir()) + .and_then(|file| file.auto_review_model) + .filter(|model| !model.is_empty()) +} + // --------------------------------------------------------------------------- // Codex transport config // --------------------------------------------------------------------------- @@ -702,6 +729,7 @@ mod tests { std::env::remove_var("CCP_CODEX_REASONING_SUMMARY"); std::env::remove_var("CCP_CODEX_SERVER_COMPACTION"); std::env::remove_var("CCP_CODEX_RESPONSES_API"); + std::env::remove_var("CCP_AUTO_REVIEW_MODEL"); } } @@ -933,6 +961,29 @@ mod tests { } } + #[test] + fn auto_review_model_reads_top_level_config_and_env_takes_precedence() { + let _guard = ENV_LOCK.lock().unwrap(); + clear_env(); + let config = tempfile::TempDir::new().unwrap(); + std::fs::write( + config.path().join("config.json"), + r#"{"autoReviewModel":"grok-4.5"}"#, + ) + .unwrap(); + let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path()); + + assert_eq!(auto_review_model().as_deref(), Some("grok-4.5")); + { + let _model_env = EnvGuard::set("CCP_AUTO_REVIEW_MODEL", "gpt-5.6-terra"); + assert_eq!(auto_review_model().as_deref(), Some("gpt-5.6-terra")); + } + { + let _model_env = EnvGuard::set("CCP_AUTO_REVIEW_MODEL", ""); + assert_eq!(auto_review_model().as_deref(), Some("grok-4.5")); + } + } + #[test] fn codex_server_compaction_defaults_and_overrides() { let _guard = ENV_LOCK.lock().unwrap(); diff --git a/src/providers/codex/mod.rs b/src/providers/codex/mod.rs index 107628b1..4a3a96d0 100644 --- a/src/providers/codex/mod.rs +++ b/src/providers/codex/mod.rs @@ -42,7 +42,8 @@ use self::count_tokens::count_translated_tokens; use self::translate::accumulate::accumulate_response_with_traffic; use self::translate::live_stream::LiveStreamTranslator; use self::translate::model_allowlist::{ - assert_allowed_model, full_lane_web_search_model, resolve_model_request, uses_responses_lite, + assert_allowed_model, full_lane_web_search_model, resolve_model_request_with_config_override, + uses_responses_lite, }; use self::translate::reducer::finish_metadata_from_upstream; use self::translate::request::{ @@ -106,7 +107,8 @@ impl Provider for CodexProvider { let want_stream = body.stream; let model = body.model.as_deref().unwrap_or("gpt-5.6-sol"); - let mut resolved = resolve_model_request(model); + let mut resolved = + resolve_model_request_with_config_override(model, !body.bypass_provider_model_override); if let Err(e) = assert_allowed_model(&resolved.model) { return json_error( StatusCode::BAD_REQUEST, @@ -321,7 +323,8 @@ impl Provider for CodexProvider { async fn handle_count_tokens(&self, body: MessagesRequest, ctx: RequestContext) -> Response { let model = body.model.as_deref().unwrap_or("gpt-5.6-sol"); - let mut resolved = resolve_model_request(model); + let mut resolved = + resolve_model_request_with_config_override(model, !body.bypass_provider_model_override); if let Err(e) = assert_allowed_model(&resolved.model) { return json_error( StatusCode::BAD_REQUEST, diff --git a/src/providers/codex/translate/model_allowlist.rs b/src/providers/codex/translate/model_allowlist.rs index 08df37d0..6763105a 100644 --- a/src/providers/codex/translate/model_allowlist.rs +++ b/src/providers/codex/translate/model_allowlist.rs @@ -57,6 +57,13 @@ fn resolve_fast_model_alias(model: &str) -> ResolvedModel { } pub fn resolve_model_request(model: &str) -> ResolvedModel { + resolve_model_request_with_config_override(model, true) +} + +pub fn resolve_model_request_with_config_override( + model: &str, + apply_config_override: bool, +) -> ResolvedModel { let alias = MODEL_ALIASES .iter() .find(|(alias, _)| *alias == model) @@ -65,7 +72,7 @@ pub fn resolve_model_request(model: &str) -> ResolvedModel { let requested = resolve_fast_model_alias(alias); - let override_model = config::codex_model(); + let override_model = apply_config_override.then(config::codex_model).flatten(); let resolved = match override_model { Some(ref val) if !val.is_empty() => resolve_fast_model_alias(val), _ => requested.clone(), diff --git a/src/server.rs b/src/server.rs index d5219802..6ba662ed 100644 --- a/src/server.rs +++ b/src/server.rs @@ -31,6 +31,64 @@ use std::time::Instant; use tokio::net::TcpListener; use uuid::Uuid; +const CLAUDE_AUTO_REVIEW_SYSTEM_PREFIX: &str = + "You are a security monitor for autonomous AI coding agents."; +const CODEX_AUTO_REVIEW_MODEL: &str = "gpt-5.6-luna"; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AutoReviewRoute { + requested_model: String, + override_model: String, +} + +fn is_claude_auto_review_request(body: &crate::anthropic::schema::MessagesRequest) -> bool { + if body.stream { + return false; + } + + let has_tools = body + .extra + .get("tools") + .and_then(Value::as_array) + .is_some_and(|tools| !tools.is_empty()); + if has_tools { + return false; + } + + body.extra + .get("system") + .and_then(Value::as_array) + .is_some_and(|blocks| { + blocks.iter().any(|block| { + block + .get("text") + .and_then(Value::as_str) + .is_some_and(|text| text.starts_with(CLAUDE_AUTO_REVIEW_SYSTEM_PREFIX)) + }) + }) +} + +fn apply_auto_review_model( + body: &mut crate::anthropic::schema::MessagesRequest, + count_tokens: bool, + configured_model: Option<&str>, + original_provider: &str, +) -> Option { + if count_tokens || !is_claude_auto_review_request(body) { + return None; + } + + let override_model = configured_model + .filter(|model| !model.is_empty()) + .or((original_provider == "codex").then_some(CODEX_AUTO_REVIEW_MODEL))?; + let route = AutoReviewRoute { + requested_model: body.model.clone()?, + override_model: override_model.to_string(), + }; + body.model = Some(route.override_model.clone()); + Some(route) +} + pub struct ServerConfig { pub bind_address: String, pub port: u16, @@ -561,20 +619,38 @@ async fn dispatch_request( } }; - let normalized_model = normalize_incoming_model(model); + let mut normalized_model = normalize_incoming_model(model); body.model = Some(normalized_model.clone()); let session_state = if let Some(session_id) = session_id.as_deref() { session::existing_session(Some(session_id), now) } else { None }; + let session_affinity = session_state + .as_ref() + .and_then(|state| state.affinity_provider.as_ref()); + let original_provider = state + .registry + .provider_for_model(&normalized_model, session_affinity); + let configured_auto_review_model = crate::config::auto_review_model(); + let auto_review_route = original_provider.as_ref().and_then(|provider| { + apply_auto_review_model( + &mut body, + count_tokens, + configured_auto_review_model.as_deref(), + provider.name(), + ) + }); + if auto_review_route.is_some() { + normalized_model = normalize_incoming_model(body.model.as_deref().expect("override model")); + body.model = Some(normalized_model.clone()); + } - let provider = state.registry.provider_for_model( - &normalized_model, - session_state - .as_ref() - .and_then(|state| state.affinity_provider.as_ref()), - ); + let provider = if auto_review_route.is_some() { + state.registry.provider_for_model(&normalized_model, None) + } else { + original_provider + }; let provider = match provider { Some(provider) => provider, @@ -630,7 +706,22 @@ async fn dispatch_request( } }; + body.bypass_provider_model_override = auto_review_route.is_some() && provider.name() == "codex"; + + if let Some(route) = auto_review_route.as_ref() { + log.info( + "auto-review route selected", + Some(Map::from_iter([ + ("reqId".to_string(), json!(&req_id)), + ("requestedModel".to_string(), json!(&route.requested_model)), + ("overrideModel".to_string(), json!(&route.override_model)), + ("provider".to_string(), json!(provider.name())), + ])), + ); + } + if !count_tokens + && auto_review_route.is_none() && provider.name() != "codex" && let Some(session_id) = session_id.as_deref() { @@ -641,11 +732,12 @@ async fn dispatch_request( .ok() .flatten() .map(str::to_string); - let current = session::record_session_request( + let current = session::record_session_request_with_affinity_update( session_id.as_deref(), session_state.as_ref(), provider.name(), &normalized_model, + auto_review_route.is_none(), now, ); if let Some(monitor) = state.monitor.as_ref() { @@ -1101,3 +1193,103 @@ fn set_mode(path: &Path, mode: u32) { fn _unused(session_state: Option<&SessionState>) { let _ = session_state; } + +#[cfg(test)] +mod auto_review_tests { + use super::{apply_auto_review_model, is_claude_auto_review_request}; + use crate::anthropic::schema::MessagesRequest; + use serde_json::json; + + fn request(system: &str, stream: bool, tools: serde_json::Value) -> MessagesRequest { + serde_json::from_value(json!({ + "model": "gpt-5.6-sol", + "max_tokens": 2112, + "stream": stream, + "system": [{"type": "text", "text": system}], + "messages": [{"role": "user", "content": "review this Bash command"}], + "tools": tools + })) + .unwrap() + } + + #[test] + fn detects_claude_auto_review_classifier() { + let body = request( + "You are a security monitor for autonomous AI coding agents.\n\n## Context", + false, + json!([]), + ); + assert!(is_claude_auto_review_request(&body)); + } + + #[test] + fn ignores_normal_streaming_and_tool_using_requests() { + assert!(!is_claude_auto_review_request(&request( + "You are an interactive coding agent.", + false, + json!([]), + ))); + assert!(!is_claude_auto_review_request(&request( + "You are a security monitor for autonomous AI coding agents.", + true, + json!([]), + ))); + assert!(!is_claude_auto_review_request(&request( + "You are a security monitor for autonomous AI coding agents.", + false, + json!([{"name": "Bash"}]), + ))); + } + + #[test] + fn codex_classifier_defaults_to_luna() { + let mut classifier = request( + "You are a security monitor for autonomous AI coding agents.", + false, + json!([]), + ); + let route = apply_auto_review_model(&mut classifier, false, None, "codex") + .expect("classifier should be routed"); + assert_eq!(route.requested_model, "gpt-5.6-sol"); + assert_eq!(route.override_model, "gpt-5.6-luna"); + assert_eq!(classifier.model.as_deref(), Some("gpt-5.6-luna")); + } + + #[test] + fn non_codex_classifier_keeps_requested_model_without_override() { + let mut classifier = request( + "You are a security monitor for autonomous AI coding agents.", + false, + json!([]), + ); + classifier.model = Some("kimi-for-coding".to_string()); + assert!(apply_auto_review_model(&mut classifier, false, None, "kimi").is_none()); + assert_eq!(classifier.model.as_deref(), Some("kimi-for-coding")); + } + + #[test] + fn configured_model_overrides_provider_default() { + let mut classifier = request( + "You are a security monitor for autonomous AI coding agents.", + false, + json!([]), + ); + let route = apply_auto_review_model(&mut classifier, false, Some("grok-4.5"), "codex") + .expect("configured classifier should be routed"); + assert_eq!(route.override_model, "grok-4.5"); + assert_eq!(classifier.model.as_deref(), Some("grok-4.5")); + } + + #[test] + fn count_tokens_keeps_requested_model() { + let mut classifier = request( + "You are a security monitor for autonomous AI coding agents.", + false, + json!([]), + ); + assert!( + apply_auto_review_model(&mut classifier, true, Some("grok-4.5"), "codex").is_none() + ); + assert_eq!(classifier.model.as_deref(), Some("gpt-5.6-sol")); + } +} diff --git a/src/session.rs b/src/session.rs index d5559b57..c6b31e92 100644 --- a/src/session.rs +++ b/src/session.rs @@ -52,6 +52,17 @@ pub fn record_session_request( provider_name: &str, model: &str, now: u64, +) -> Option { + record_session_request_with_affinity_update(session_id, prior, provider_name, model, true, now) +} + +pub(crate) fn record_session_request_with_affinity_update( + session_id: Option<&str>, + prior: Option<&SessionState>, + provider_name: &str, + model: &str, + update_affinity: bool, + now: u64, ) -> Option { let id = session_id?; let mut store = SESSIONS.lock().expect("session lock"); @@ -62,7 +73,8 @@ pub fn record_session_request( }); next.seq += 1; next.last_seen = now; - if is_alias_routable_provider(provider_name) + if update_affinity + && is_alias_routable_provider(provider_name) && !crate::registry::is_anthropic_alias(normalize_incoming_model(model).as_str()) { next.affinity_provider = Some(match provider_name { @@ -102,3 +114,28 @@ pub fn reset_sessions_for_test() { pub fn affinity_provider_from_session(session: &SessionState) -> Option { session.affinity_provider } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auxiliary_request_does_not_change_session_affinity() { + let session_id = "session-affinity-auxiliary-request-test"; + let initial = record_session_request(Some(session_id), None, "codex", "gpt-5.6-sol", 1) + .expect("initial session"); + assert_eq!(initial.affinity_provider, Some(AliasProvider::Codex)); + + let after_review = record_session_request_with_affinity_update( + Some(session_id), + Some(&initial), + "kimi", + "kimi-for-coding", + false, + 2, + ) + .expect("updated session"); + assert_eq!(after_review.seq, 2); + assert_eq!(after_review.affinity_provider, Some(AliasProvider::Codex)); + } +} diff --git a/tests/smoke_cutover.rs b/tests/smoke_cutover.rs index d0a6aeb9..bd6fe8b9 100644 --- a/tests/smoke_cutover.rs +++ b/tests/smoke_cutover.rs @@ -693,6 +693,76 @@ async fn smoke_codex_http_messages_uses_mock_upstream() { assert_eq!(sent["stream"], true); } +#[allow(clippy::await_holding_lock)] +#[tokio::test] +async fn smoke_auto_review_uses_codex_default_and_configured_override() { + let _guard = env_lock(); + let config = TempDir::new().unwrap(); + write_auth(config.path(), "codex"); + + 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: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"message\",\"id\":\"msg_up\"}}\n\n", + "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"delta\":\"review ok\"}\n\n", + "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"message\"}}\n\n", + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"usage\":{\"input_tokens\":5,\"output_tokens\":2}}}\n\n" + ) + .as_bytes() + .to_vec() + } + }) + .await; + + let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path()); + 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 classifier_body = || { + json!({ + "model": "gpt-5.6-sol", + "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": [] + }) + }; + + 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 _review_model_env = EnvGuard::set("CCP_AUTO_REVIEW_MODEL", "gpt-5.6-terra"); + 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 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 + .unwrap(); + + let sent = captured.lock().unwrap(); + assert_eq!(sent.len(), 3); + 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"); +} + #[allow(clippy::await_holding_lock)] #[tokio::test] async fn smoke_codex_http_server_compaction_replays_native_history() {