diff --git a/src/llm/provider/anthropic.rs b/src/llm/provider/anthropic.rs index 52fcf02..742015f 100644 --- a/src/llm/provider/anthropic.rs +++ b/src/llm/provider/anthropic.rs @@ -14,7 +14,7 @@ use bytes::Bytes; use futures_core::Stream; use futures_util::StreamExt; use reqwest::Client; -use reqwest::header::{HeaderMap, HeaderValue}; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tracing::{debug, error, info, warn}; @@ -151,7 +151,8 @@ impl AnthropicProvider { } } - /// 注入 Provider 级别固定头。返回 self 以支持链式调用。 + /// 设置 Provider 级别固定头,替换已有的 extra_headers(如有)。 + /// 返回 self 以支持链式调用。如需追加语义,在外部自行 `extend`。 pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self { self.extra_headers = headers; self @@ -276,6 +277,11 @@ impl AnthropicProvider { /// /// 头融合顺序:认证头(default_headers)→ Provider 级 extra_headers → 请求级 custom_headers /// 后者覆盖前者。 + /// 构造 HTTP POST 请求 builder(含认证头 + 自定义头)。 + /// 认证头(x-api-key / anthropic-version)已由 Client 的 default_headers 提供。 + /// + /// 头融合顺序:认证头(default_headers)→ Provider 级 extra_headers → 请求级 custom_headers + /// 后者覆盖前者。非法 header 名/值(如控制字符)静默跳过 + warn,避免 reqwest panic。 fn build_request_builder( &self, body: &AnthropicRequestBody, @@ -284,11 +290,25 @@ impl AnthropicProvider { let mut builder = self.http_client.post(&url).json(body); for (k, v) in &self.extra_headers { - builder = builder.header(k.as_str(), v.as_str()); + if let (Ok(name), Ok(value)) = ( + HeaderName::from_bytes(k.as_bytes()), + HeaderValue::from_str(v), + ) { + builder = builder.header(name, value); + } else { + warn!(header = %k, "skipping invalid extra_header (key or value contains illegal characters)"); + } } for (key, value) in &body.custom_headers { - builder = builder.header(key.as_str(), value.as_str()); + if let (Ok(name), Ok(value)) = ( + HeaderName::from_bytes(key.as_bytes()), + HeaderValue::from_str(value), + ) { + builder = builder.header(name, value); + } else { + warn!(header = %key, "skipping invalid custom_header (key or value contains illegal characters)"); + } } Ok(builder) @@ -1436,4 +1456,78 @@ event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"; "custom_headers 应能覆盖 x-api-key 头,实际收到: {keys:?}" ); } + + #[test] + fn anthropic_extra_headers_from_constructor_unit() { + // ponytail: 用 RequestBuilder::build() 直检 headers,无需 wiremock。 + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .expect("create http client"); + let provider = AnthropicProvider::from_parts( + "http://x".into(), + "sk-ant-test".into(), + "claude-sonnet-4-20250514".into(), + client, + 30, + vec![("X-Platform".into(), "anthropic-test".into())], + ); + let body = AnthropicRequestBody { + model: "claude-sonnet-4-20250514".into(), + max_tokens: 4096, + system: None, + messages: Vec::new(), + tools: None, + thinking: None, + stream: None, + custom_headers: HashMap::new(), + }; + let req = provider + .build_request_builder(&body) + .unwrap() + .build() + .unwrap(); + assert_eq!(req.headers().get("X-Platform").unwrap(), "anthropic-test"); + } + + #[test] + fn anthropic_invalid_header_name_is_skipped() { + // ponytail: extra_headers 含非法 key 应被跳过,不应让 reqwest panic。 + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .expect("create http client"); + let provider = AnthropicProvider::from_parts( + "http://x".into(), + "sk-ant-test".into(), + "claude-sonnet-4-20250514".into(), + client, + 30, + Vec::new(), + ) + .with_extra_headers(vec![ + ("X-Valid".into(), "v1".into()), + ("bad\nname".into(), "v2".into()), + ]); + let body = AnthropicRequestBody { + model: "claude-sonnet-4-20250514".into(), + max_tokens: 4096, + system: None, + messages: Vec::new(), + tools: None, + thinking: None, + stream: None, + custom_headers: HashMap::new(), + }; + let req = provider + .build_request_builder(&body) + .unwrap() + .build() + .unwrap(); + assert_eq!(req.headers().get("X-Valid").unwrap(), "v1"); + assert!( + req.headers().get("bad\nname").is_none(), + "非法 header 名应被静默跳过" + ); + } } diff --git a/src/llm/provider/openai.rs b/src/llm/provider/openai.rs index dd533f5..42e5930 100644 --- a/src/llm/provider/openai.rs +++ b/src/llm/provider/openai.rs @@ -18,9 +18,10 @@ use bytes::Bytes; use futures_core::Stream; use futures_util::StreamExt; use reqwest::Client; +use reqwest::header::{HeaderName, HeaderValue}; use serde::Serialize; use serde_json::Value; -use tracing::{debug, error, info}; +use tracing::{debug, error, info, warn}; use crate::llm::convert::{from_openai, to_openai}; use crate::llm::error::LlmError; @@ -443,7 +444,8 @@ impl GenericOpenaiProvider { ) } - /// 注入 Provider 级别固定头。返回 self 以支持链式调用。 + /// 设置 Provider 级别固定头,替换已有的 extra_headers(如有)。 + /// 返回 self 以支持链式调用。如需追加语义,在外部自行 `extend`。 pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self { self.extra_headers = headers; self @@ -465,7 +467,7 @@ impl GenericOpenaiProvider { /// 构造 HTTP POST 请求 builder(含认证头与额外请求头)。 /// /// 头融合顺序:Authorization → Provider 级 extra_headers → 请求级 custom_headers - /// 后者覆盖前者。 + /// 后者覆盖前者。非法 header 名/值(如控制字符)静默跳过 + warn,避免 reqwest panic。 fn build_request_builder( &self, url: &str, @@ -476,10 +478,24 @@ impl GenericOpenaiProvider { .post(url) .header("Authorization", format!("Bearer {}", self.api_key)); for (k, v) in &self.extra_headers { - builder = builder.header(k.as_str(), v.as_str()); + if let (Ok(name), Ok(value)) = ( + HeaderName::from_bytes(k.as_bytes()), + HeaderValue::from_str(v), + ) { + builder = builder.header(name, value); + } else { + warn!(header = %k, "skipping invalid extra_header (key or value contains illegal characters)"); + } } for (key, value) in &body.custom_headers { - builder = builder.header(key.as_str(), value.as_str()); + if let (Ok(name), Ok(value)) = ( + HeaderName::from_bytes(key.as_bytes()), + HeaderValue::from_str(value), + ) { + builder = builder.header(name, value); + } else { + warn!(header = %key, "skipping invalid custom_header (key or value contains illegal characters)"); + } } Ok(builder.json(body)) } @@ -1901,4 +1917,55 @@ data: [DONE]\n\n"; "custom_headers 应能覆盖 Authorization 头,实际收到: {auth_values:?}" ); } + + #[test] + fn openai_chat_extra_headers_from_constructor_unit() { + // 验证 `from_parts` 传入的 extra_headers 在 build_request_builder 中被注入。 + // ponytail: 用 RequestBuilder::build() 直检 headers,无需 wiremock。 + let provider = GenericOpenaiProvider::from_parts( + "http://x".into(), + "sk-test".into(), + "gpt-4o".into(), + "openai", + Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .expect("create http client"), + vec![("X-Platform".into(), "doubao".into())], + 30, + ); + let body = OpenaiChatRequest { + model: "gpt-4o".into(), + ..Default::default() + }; + let req = provider + .build_request_builder("http://x/chat/completions", &body) + .unwrap() + .build() + .unwrap(); + assert_eq!(req.headers().get("X-Platform").unwrap(), "doubao"); + } + + #[test] + fn openai_chat_invalid_header_name_is_skipped() { + // ponytail: extra_headers 含非法 key(如换行符)应被跳过,不应让 reqwest panic。 + let provider = make_provider_for_header_tests("http://x".into()).with_extra_headers(vec![ + ("X-Valid".into(), "v1".into()), + ("bad\nname".into(), "v2".into()), + ]); + let body = OpenaiChatRequest { + model: "gpt-4o".into(), + ..Default::default() + }; + let req = provider + .build_request_builder("http://x/chat/completions", &body) + .unwrap() + .build() + .unwrap(); + assert_eq!(req.headers().get("X-Valid").unwrap(), "v1"); + assert!( + req.headers().get("bad\nname").is_none(), + "非法 header 名应被静默跳过" + ); + } } diff --git a/src/llm/provider/openai_response.rs b/src/llm/provider/openai_response.rs index 8f8a7d5..97fdd5f 100644 --- a/src/llm/provider/openai_response.rs +++ b/src/llm/provider/openai_response.rs @@ -19,6 +19,7 @@ use bytes::Bytes; use futures_core::Stream; use futures_util::StreamExt; use reqwest::Client; +use reqwest::header::{HeaderName, HeaderValue}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tracing::{debug, error, info, warn}; @@ -321,7 +322,8 @@ impl OpenaiResponseProvider { } } - /// 注入 Provider 级别固定头。返回 self 以支持链式调用。 + /// 设置 Provider 级别固定头,替换已有的 extra_headers(如有)。 + /// 返回 self 以支持链式调用。如需追加语义,在外部自行 `extend`。 pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self { self.extra_headers = headers; self @@ -331,6 +333,8 @@ impl OpenaiResponseProvider { format!("{}/responses", self.base_url.trim_end_matches('/')) } + /// 头融合顺序:Authorization → Provider 级 extra_headers → 请求级 custom_headers + /// 后者覆盖前者。非法 header 名/值(如控制字符)静默跳过 + warn,避免 reqwest panic。 fn build_request_builder( &self, body: &OpenaiResponseRequest, @@ -341,11 +345,25 @@ impl OpenaiResponseProvider { .header("Authorization", format!("Bearer {}", self.api_key)); for (k, v) in &self.extra_headers { - builder = builder.header(k.as_str(), v.as_str()); + if let (Ok(name), Ok(value)) = ( + HeaderName::from_bytes(k.as_bytes()), + HeaderValue::from_str(v), + ) { + builder = builder.header(name, value); + } else { + warn!(header = %k, "skipping invalid extra_header (key or value contains illegal characters)"); + } } for (key, value) in &body.custom_headers { - builder = builder.header(key.as_str(), value.as_str()); + if let (Ok(name), Ok(value)) = ( + HeaderName::from_bytes(key.as_bytes()), + HeaderValue::from_str(value), + ) { + builder = builder.header(name, value); + } else { + warn!(header = %key, "skipping invalid custom_header (key or value contains illegal characters)"); + } } Ok(builder.json(body)) @@ -2196,4 +2214,96 @@ data: {\"type\":\"response.failed\",\"error\":{\"message\":\"server failed mid-s "custom_headers 应能覆盖 Authorization 头,实际收到: {auth_values:?}" ); } + + #[test] + fn openai_response_extra_headers_from_constructor_unit() { + // ponytail: 用 RequestBuilder::build() 直检 headers,无需 wiremock。 + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .expect("create http client"); + let provider = OpenaiResponseProvider::from_parts( + "http://x".into(), + "sk-test".into(), + "gpt-4o".into(), + client, + 30, + vec![("X-Platform".into(), "doubao".into())], + ); + let body = OpenaiResponseRequest { + model: "gpt-4o".into(), + instructions: None, + input: Vec::new(), + tools: None, + tool_choice: None, + text: None, + max_output_tokens: None, + temperature: None, + top_p: None, + stop: None, + stream: None, + previous_response_id: None, + store: None, + truncation: None, + metadata: None, + reasoning: None, + custom_headers: HashMap::new(), + }; + let req = provider + .build_request_builder(&body) + .unwrap() + .build() + .unwrap(); + assert_eq!(req.headers().get("X-Platform").unwrap(), "doubao"); + } + + #[test] + fn openai_response_invalid_header_name_is_skipped() { + // ponytail: extra_headers 含非法 key 应被跳过,不应让 reqwest panic。 + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .expect("create http client"); + let provider = OpenaiResponseProvider::from_parts( + "http://x".into(), + "sk-test".into(), + "gpt-4o".into(), + client, + 30, + Vec::new(), + ) + .with_extra_headers(vec![ + ("X-Valid".into(), "v1".into()), + ("bad\nname".into(), "v2".into()), + ]); + let body = OpenaiResponseRequest { + model: "gpt-4o".into(), + instructions: None, + input: Vec::new(), + tools: None, + tool_choice: None, + text: None, + max_output_tokens: None, + temperature: None, + top_p: None, + stop: None, + stream: None, + previous_response_id: None, + store: None, + truncation: None, + metadata: None, + reasoning: None, + custom_headers: HashMap::new(), + }; + let req = provider + .build_request_builder(&body) + .unwrap() + .build() + .unwrap(); + assert_eq!(req.headers().get("X-Valid").unwrap(), "v1"); + assert!( + req.headers().get("bad\nname").is_none(), + "非法 header 名应被静默跳过" + ); + } }