feat(llm): 三 provider 统一支持自定义 HTTP 请求头

在 GenericOpenaiProvider / OpenaiResponseProvider / AnthropicProvider 中新增双层自定义 HTTP 头机制:

- Provider 级固定头:extra_headers 字段,构造时通过 with_extra_headers() 链式注入
- 请求级临时头:extra.custom_headers,通过 set_extra 透传,#[serde(skip)] 隔离 JSON body

AnthropicProvider 前置提取 build_request_builder 统一方法,使三 provider 的请求构造模式对齐。

头融合顺序(一致):认证头 → Provider 级头 → 请求级头,后注入覆盖前注入。

新增 14 个测试覆盖提取 / 序列化隔离 / 类型降级 / 透传 / 覆盖优先级 / 认证头可覆盖等维度。
This commit is contained in:
徐涛
2026-07-20 14:47:38 +08:00
parent 939dcf0f9a
commit 77321db8f6
5 changed files with 1095 additions and 15 deletions
+238 -9
View File
@@ -4,6 +4,7 @@
//! → `content_block_stop` → `message_delta` → `message_stop`。与 OpenAI 不同,
//! Anthropic 提供显式 block 边界事件,状态机相对简单。
use std::collections::HashMap;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
@@ -43,6 +44,8 @@ pub struct AnthropicProvider {
/// 在 `LlmError::Timeout { duration }` 中回显。`reqwest::Client` 不暴露 timeout getter
/// 因此单独存储以便错误消息与配置保持一致。
timeout_secs: u64,
/// Provider 级别固定请求头(如平台标识头),所有请求自动携带。
extra_headers: Vec<(String, String)>,
}
impl AnthropicProvider {
@@ -72,6 +75,7 @@ impl AnthropicProvider {
api_key,
model,
timeout_secs,
extra_headers: Vec::new(),
}
}
@@ -131,6 +135,7 @@ impl AnthropicProvider {
model: String,
http_client: Client,
timeout_secs: u64,
extra_headers: Vec<(String, String)>,
) -> Self {
Self {
http_client,
@@ -142,9 +147,16 @@ impl AnthropicProvider {
api_key,
model,
timeout_secs,
extra_headers,
}
}
/// 注入 Provider 级别固定头。返回 self 以支持链式调用。
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
self.extra_headers = headers;
self
}
fn resolve_max_tokens(&self, request: &MessageRequest) -> u32 {
request.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS)
}
@@ -214,6 +226,10 @@ impl AnthropicProvider {
let max_tokens = self.resolve_max_tokens(&request);
// ponytail: 提前抽取 custom_headers,避免后续 into_iter 消耗 request.tools 后借用失败。
let custom_headers: HashMap<String, String> =
request.get_extra_opt("custom_headers").unwrap_or_default();
let tools = if request.tools.is_empty() {
None
} else {
@@ -251,19 +267,40 @@ impl AnthropicProvider {
tools,
thinking,
stream: if request.stream { Some(true) } else { None },
custom_headers,
})
}
/// 构造 HTTP POST 请求 builder(含认证头 + 自定义头)。
/// 认证头(x-api-key / anthropic-version)已由 Client 的 default_headers 提供。
///
/// 头融合顺序:认证头(default_headers)→ Provider 级 extra_headers → 请求级 custom_headers
/// 后者覆盖前者。
fn build_request_builder(
&self,
body: &AnthropicRequestBody,
) -> Result<reqwest::RequestBuilder, LlmError> {
let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
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());
}
for (key, value) in &body.custom_headers {
builder = builder.header(key.as_str(), value.as_str());
}
Ok(builder)
}
async fn chat_blocking(&self, request: MessageRequest) -> Result<MessageResponse, LlmError> {
let body = self.build_request_body(request)?;
let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
info!(model = %body.model, "Anthropic: 发送非流式请求");
let response = self
.http_client
.post(&url)
.json(&body)
.build_request_builder(&body)?
.send()
.await
.map_err(|e| self.map_reqwest_error(e))?;
@@ -291,14 +328,10 @@ impl AnthropicProvider {
let mut body = self.build_request_body(request)?;
body.stream = Some(true);
let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
info!(model = %body.model, "Anthropic: 发送流式请求");
let response = self
.http_client
.post(&url)
.json(&body)
.build_request_builder(&body)?
.send()
.await
.map_err(|e| self.map_reqwest_error(e))?;
@@ -448,6 +481,9 @@ struct AnthropicRequestBody {
thinking: Option<AnthropicThinking>,
#[serde(skip_serializing_if = "Option::is_none")]
stream: Option<bool>,
/// 请求级别自定义 HTTP 头。运行时注入,不进入 JSON 请求体。
#[serde(skip)]
custom_headers: HashMap<String, String>,
}
#[derive(Debug, Serialize)]
@@ -1207,4 +1243,197 @@ event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
other => panic!("expected RateLimit, got {other:?}"),
}
}
// ===== custom_headers (Phase 8 Step 8.7) =====
fn mock_messages_body() -> serde_json::Value {
json!({
"id": "msg_test",
"type": "message",
"model": "claude-sonnet-4-20250514",
"content": [{"type": "text", "text": "OK"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 1, "output_tokens": 1}
})
}
fn make_provider_with_extra_headers(
base_url: String,
extra_headers: Vec<(String, String)>,
) -> AnthropicProvider {
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()
.expect("create http client");
AnthropicProvider::from_parts(
base_url,
"sk-ant-test".into(),
"claude-sonnet-4-20250514".into(),
client,
30,
extra_headers,
)
}
#[test]
fn anthropic_custom_headers_from_extra() {
let provider = make_provider_with_extra_headers("http://x".into(), Vec::new());
let mut req = MessageRequest {
model: "claude-sonnet-4-20250514".into(),
messages: vec![Message::user_text("hi")],
..Default::default()
};
req.set_extra("custom_headers", json!({"X-Custom": "v1", "X-Other": "v2"}));
let body = provider.build_request_body(req).unwrap();
assert_eq!(body.custom_headers.get("X-Custom").unwrap(), "v1");
assert_eq!(body.custom_headers.get("X-Other").unwrap(), "v2");
}
#[test]
fn anthropic_custom_headers_skipped_in_json_body() {
let provider = make_provider_with_extra_headers("http://x".into(), Vec::new());
let mut req = MessageRequest {
model: "claude-sonnet-4-20250514".into(),
messages: vec![Message::user_text("hi")],
..Default::default()
};
req.set_extra("custom_headers", json!({"X-Custom": "v1"}));
let body = provider.build_request_body(req).unwrap();
let value = serde_json::to_value(&body).unwrap();
assert!(
value.get("custom_headers").is_none(),
"custom_headers 不应进入 JSON body"
);
}
#[test]
fn anthropic_custom_headers_invalid_type_fallback() {
let provider = make_provider_with_extra_headers("http://x".into(), Vec::new());
let mut req = MessageRequest {
model: "claude-sonnet-4-20250514".into(),
messages: vec![Message::user_text("hi")],
..Default::default()
};
req.set_extra("custom_headers", json!("not_an_object"));
let body = provider.build_request_body(req).unwrap();
assert!(body.custom_headers.is_empty());
}
#[tokio::test]
async fn anthropic_custom_headers_are_sent() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/messages"))
.and(header("X-Custom", "v1"))
.and(header("anthropic-version", "2023-06-01"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_messages_body()))
.mount(&server)
.await;
let provider = make_provider(server.uri());
let mut req = MessageRequest {
model: "claude-sonnet-4-20250514".into(),
messages: vec![Message::user_text("hi")],
..Default::default()
};
req.set_extra("custom_headers", json!({"X-Custom": "v1"}));
let resp = provider.chat_blocking(req).await.unwrap();
assert_eq!(resp.text(), "OK");
}
#[tokio::test]
async fn anthropic_provider_level_headers_are_sent() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/messages"))
.and(header("X-Platform", "anthropic-test"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_messages_body()))
.mount(&server)
.await;
let provider = make_provider_with_extra_headers(
server.uri(),
vec![("X-Platform".into(), "anthropic-test".into())],
);
let req = MessageRequest {
model: "claude-sonnet-4-20250514".into(),
messages: vec![Message::user_text("hi")],
..Default::default()
};
let resp = provider.chat_blocking(req).await.unwrap();
assert_eq!(resp.text(), "OK");
}
#[tokio::test]
async fn anthropic_custom_headers_override_provider_headers() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/messages"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_messages_body()))
.mount(&server)
.await;
let provider = make_provider_with_extra_headers(
server.uri(),
vec![("X-Platform".into(), "provider-level".into())],
);
let mut req = MessageRequest {
model: "claude-sonnet-4-20250514".into(),
messages: vec![Message::user_text("hi")],
..Default::default()
};
req.set_extra("custom_headers", json!({"X-Platform": "request-wins"}));
let resp = provider.chat_blocking(req).await.unwrap();
assert_eq!(resp.text(), "OK");
let received = server.received_requests().await.unwrap();
assert_eq!(received.len(), 1);
let platforms: Vec<&str> = received[0]
.headers
.get_all("X-Platform")
.iter()
.filter_map(|v| v.to_str().ok())
.collect();
assert!(platforms.contains(&"request-wins"));
}
#[tokio::test]
async fn anthropic_custom_headers_can_override_auth_header() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/messages"))
.respond_with(ResponseTemplate::new(200).set_body_json(mock_messages_body()))
.mount(&server)
.await;
let provider = make_provider_with_extra_headers(server.uri(), Vec::new());
let mut req = MessageRequest {
model: "claude-sonnet-4-20250514".into(),
messages: vec![Message::user_text("hi")],
..Default::default()
};
req.set_extra(
"custom_headers",
json!({"x-api-key": "from-custom-headers"}),
);
let resp = provider.chat_blocking(req).await.unwrap();
assert_eq!(resp.text(), "OK");
let received = server.received_requests().await.unwrap();
assert_eq!(received.len(), 1);
let keys: Vec<&str> = received[0]
.headers
.get_all("x-api-key")
.iter()
.filter_map(|v| v.to_str().ok())
.collect();
assert!(
keys.contains(&"from-custom-headers"),
"custom_headers 应能覆盖 x-api-key 头,实际收到: {keys:?}"
);
}
}