From 1f011ec2e0ff187d3f2d13c08261370a00f84690 Mon Sep 17 00:00:00 2001 From: mulfyx Date: Tue, 21 Jul 2026 03:00:35 +0500 Subject: [PATCH] feat: configure auto-review classifier model --- README.md | 7 ++ src/config.rs | 51 ++++++++++++ src/server.rs | 179 +++++++++++++++++++++++++++++++++++++++-- src/session.rs | 39 ++++++++- tests/smoke_cutover.rs | 58 +++++++++++++ 5 files changed, 328 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b8ecdb96..0f294e2b 100644 --- a/README.md +++ b/README.md @@ -683,6 +683,7 @@ Windows, and at "bindAddress": "127.0.0.1", "port": 18765, "aliasProvider": "codex", + "autoReviewModel": "gpt-5.6-terra", "codex": { "originator": "claude-code-proxy", "userAgent": "claude-code-proxy/dev", @@ -725,6 +726,7 @@ Windows, and at | `CCP_LOG_VERBOSE` | `log.verbose` | unset | Preserve full string fields in `proxy.log`; any env value enables it | | `CCP_TRAFFIC_LOG` | — | unset | Write full per-request traffic captures under `traffic/` for session debugging (`1`, `true`, or `yes`) | | `CCP_ALIAS_PROVIDER` | `aliasProvider` | `codex` | Route Anthropic-style aliases (`haiku`, `sonnet`, `opus`, `claude-*`) through `codex` or `kimi` | +| `CCP_AUTO_REVIEW_MODEL` | `autoReviewModel` | unset | Route Claude Code's autonomous Bash security-review classifier through any registered model; use a direct model ID for deterministic provider selection | | `CCP_KIMI_OAUTH_HOST` | `kimi.oauthHost` | `https://auth.kimi.com` | Override Kimi's OAuth host (debugging only) | | `CCP_KIMI_BASE_URL` | `kimi.baseUrl` | `https://api.kimi.com/coding/v1` | Override Kimi's API base URL | | `CCP_CODEX_MODEL` | `codex.model` | unset | Force all Codex requests to this model (`gpt-5.2`, `gpt-5.3-codex`, `gpt-5.3-codex-spark`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`) | @@ -750,6 +752,11 @@ A malformed `config.json` is reported on stderr and ignored; defaults are used in its place. Invalid types for individual keys are warned and skipped without affecting other keys. +`CCP_AUTO_REVIEW_MODEL` only applies to Claude Code's non-streaming, tool-free +Bash security-review classifier request. It does not affect normal messages or +token counting, and the one-off route does not change the session's provider +affinity. When unset, routing is unchanged. + Codex uses the WebSocket Responses transport by default. Set `CCP_CODEX_TRANSPORT=http` to use the older HTTP SSE transport for debugging or compatibility, or `CCP_CODEX_TRANSPORT=auto` to try WebSocket with HTTP fallback diff --git a/src/config.rs b/src/config.rs index df1b0288..62ba5d31 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, @@ -244,6 +246,12 @@ pub fn config_override_summary_lines(cfg: &LoadedConfig) -> Vec { { out.push("CCP_CODEX_REASONING_SUMMARY (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}")); @@ -254,6 +262,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}")); @@ -480,6 +494,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 // --------------------------------------------------------------------------- @@ -592,6 +619,7 @@ mod tests { std::env::remove_var("CCP_LOG_VERBOSE"); std::env::remove_var("CCP_LOG_STDERR"); std::env::remove_var("CCP_CODEX_REASONING_SUMMARY"); + std::env::remove_var("CCP_AUTO_REVIEW_MODEL"); } } @@ -782,4 +810,27 @@ mod tests { assert_eq!(codex_reasoning_summary().as_deref(), Some("off")); } } + + #[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")); + } + } } diff --git a/src/server.rs b/src/server.rs index c7f59f90..0bb25284 100644 --- a/src/server.rs +++ b/src/server.rs @@ -28,6 +28,15 @@ 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."; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AutoReviewRoute { + requested_model: Option, + override_model: String, +} + pub struct ServerConfig { pub bind_address: String, pub port: u16, @@ -166,6 +175,50 @@ async fn handler_count_tokens(State(state): State>, req: Request 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, + override_model: Option<&str>, +) -> Option { + if count_tokens || !is_claude_auto_review_request(body) { + return None; + } + let override_model = override_model.filter(|model| !model.is_empty())?; + let route = AutoReviewRoute { + requested_model: body.model.clone(), + override_model: override_model.to_string(), + }; + body.model = Some(route.override_model.clone()); + Some(route) +} + async fn dispatch_request( state: Arc, req: Request, @@ -295,6 +348,10 @@ async fn dispatch_request( monitor.project_resolved(&req_id, project); } + let auto_review_model = crate::config::auto_review_model(); + let auto_review_route = + apply_auto_review_model(&mut body, count_tokens, auto_review_model.as_deref()); + let model = match body.model.as_deref() { Some(model) => model, None => { @@ -350,12 +407,16 @@ async fn dispatch_request( None }; - let provider = state.registry.provider_for_model( - &normalized_model, + let session_affinity = if auto_review_route.is_some() { + None + } else { session_state .as_ref() - .and_then(|state| state.affinity_provider.as_ref()), - ); + .and_then(|state| state.affinity_provider.as_ref()) + }; + let provider = state + .registry + .provider_for_model(&normalized_model, session_affinity); let provider = match provider { Some(provider) => provider, @@ -411,15 +472,28 @@ async fn dispatch_request( } }; + 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())), + ])), + ); + } + let effort = crate::providers::translate_shared::read_effort(&body) .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() { @@ -862,3 +936,98 @@ 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 crate::config::AliasProvider; + use crate::registry::Registry; + 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 override_replaces_model_only_for_message_classifier_requests() { + 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("gpt-5.6-terra")) + .expect("classifier should be routed"); + assert_eq!(route.requested_model.as_deref(), Some("gpt-5.6-sol")); + assert_eq!(route.override_model, "gpt-5.6-terra"); + assert_eq!(classifier.model.as_deref(), Some("gpt-5.6-terra")); + + let mut count_tokens = request( + "You are a security monitor for autonomous AI coding agents.", + false, + json!([]), + ); + assert!(apply_auto_review_model(&mut count_tokens, true, Some("gpt-5.6-terra")).is_none()); + assert_eq!(count_tokens.model.as_deref(), Some("gpt-5.6-sol")); + } + + #[test] + fn direct_override_models_select_each_registered_provider() { + let registry = Registry::new(AliasProvider::Codex); + for (model, expected_provider) in [ + ("gpt-5.6-luna", "codex"), + ("grok-4.5", "grok"), + ("kimi-for-coding", "kimi"), + ("cursor", "cursor"), + ] { + let mut body = request( + "You are a security monitor for autonomous AI coding agents.", + false, + json!([]), + ); + apply_auto_review_model(&mut body, false, Some(model)) + .expect("classifier should be routed"); + let provider = registry + .provider_for_model(body.model.as_deref().expect("override model"), None) + .expect("registered provider"); + assert_eq!(provider.name(), expected_provider, "model {model}"); + } + } +} 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 ea544567..2d817b43 100644 --- a/tests/smoke_cutover.rs +++ b/tests/smoke_cutover.rs @@ -688,6 +688,64 @@ 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_model_only_overrides_classifier_request() { + 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 _review_model_env = EnvGuard::set("CCP_AUTO_REVIEW_MODEL", "gpt-5.6-terra"); + + let classifier = call_messages_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": [] + })) + .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(), 2); + assert_eq!(sent[0]["model"], "gpt-5.6-terra"); + assert_eq!(sent[1]["model"], "gpt-5.6-sol"); +} + #[allow(clippy::await_holding_lock)] #[tokio::test] async fn smoke_codex_http_context_window_error_requests_compaction() {