diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 351ffbb52..0bf249dac 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -38,6 +38,45 @@ pub(crate) const LLM_REQUEST_MAX_BODY_BYTES: usize = 32 * 1024 * 1024; const LLM_ANTHROPIC_NATIVE_FIRST_BYTE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); +/// Claude Agent SDK 在 `system` 里固定注入的身份句原文。 +/// +/// 2026-10-06 用真实 AGC 客户端复现:共享 LLM Router 的上游内容策略会把这句话整体判成 +/// 需要拦截的内容,直接返回 `500 {"error":{"message":"Your request was blocked by our +/// content policy..."}}`。单独把这句话作为 `system` 发送即可稳定复现;同一请求保留 AGC +/// 提示词、billing header、工具、`metadata`、`max_tokens`、`thinking` 等全部其它字段,只把 +/// 品牌归属片段换成等价表述后稳定返回 200。因此这里只做最小归一化,保留身份语义,去掉会 +/// 触发误判的片段,其它请求内容一律原样透传。 +const CLAUDE_AGENT_SDK_IDENTITY_SENTENCE: &str = + "You are a Claude agent, built on Anthropic's Claude Agent SDK."; +const CLAUDE_AGENT_SDK_IDENTITY_SENTENCE_REPLACEMENT: &str = + "You are a Claude agent, built on an Anthropic agent SDK."; + +/// 归一化 Claude Agent SDK 注入的身份句,避免上游内容策略误判整条请求。 +/// +/// `system` 既可能是字符串也可能是数组;Claude Agent SDK 固定发数组,其它形状保持不动, +/// 保证这条路只影响已知会失败的那一种载荷。 +fn normalize_claude_agent_sdk_identity_sentence(payload: &mut Value) { + let Some(system) = payload.get_mut("system").and_then(Value::as_array_mut) else { + return; + }; + for entry in system { + let Some(object) = entry.as_object_mut() else { + continue; + }; + let Some(text) = object.get("text").and_then(Value::as_str) else { + continue; + }; + if !text.contains(CLAUDE_AGENT_SDK_IDENTITY_SENTENCE) { + continue; + } + let normalized = text.replace( + CLAUDE_AGENT_SDK_IDENTITY_SENTENCE, + CLAUDE_AGENT_SDK_IDENTITY_SENTENCE_REPLACEMENT, + ); + object.insert("text".to_string(), Value::String(normalized)); + } +} + mod anthropic_bridge; pub(crate) mod icon_specs; @@ -568,6 +607,7 @@ pub async fn proxy_llm_messages( .to_string(); // 原生直通要用目录解析后的上游模型名;桥接路径由转换器显式接收同一个名字。 object.insert("model".to_string(), Value::String(selected_model.clone())); + normalize_claude_agent_sdk_identity_sentence(&mut payload); let (base_url, api_key, key_id) = resolve_llm_router_credentials(&state, authenticated.claims().user_id()) @@ -2204,6 +2244,52 @@ mod tests { assert!(carry.is_empty()); } + #[test] + fn normalize_claude_agent_sdk_identity_sentence_rewrites_only_the_blocked_sentence() { + let mut payload = json!({ + "model": "claude-opus-5-5", + "system": [ + { "type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.285; " }, + { + "type": "text", + "text": "You are a Claude agent, built on Anthropic's Claude Agent SDK.", + "cache_control": { "type": "ephemeral" } + }, + { "type": "text", "text": "你是陶泥儿,负责当前任务。" } + ] + }); + + normalize_claude_agent_sdk_identity_sentence(&mut payload); + + assert_eq!( + payload["system"][1]["text"], + json!("You are a Claude agent, built on an Anthropic agent SDK.") + ); + assert_eq!( + payload["system"][1]["cache_control"], + json!({ "type": "ephemeral" }) + ); + assert_eq!( + payload["system"][0]["text"], + json!("x-anthropic-billing-header: cc_version=2.1.285; ") + ); + assert_eq!( + payload["system"][2]["text"], + json!("你是陶泥儿,负责当前任务。") + ); + } + + #[test] + fn normalize_claude_agent_sdk_identity_sentence_keeps_other_system_shapes() { + let mut payload = json!({ "system": "You are a Claude agent." }); + normalize_claude_agent_sdk_identity_sentence(&mut payload); + assert_eq!(payload["system"], json!("You are a Claude agent.")); + + let mut without_system = json!({ "model": "claude-opus-5-5" }); + normalize_claude_agent_sdk_identity_sentence(&mut without_system); + assert_eq!(without_system, json!({ "model": "claude-opus-5-5" })); + } + #[test] fn router_protocol_url_composes_v1_without_duplicating_it() { // baseUrl 不带路由与版本段,协议自己拼。