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:
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+207
-1
@@ -8,6 +8,7 @@
|
||||
//! - `MessageComplete { full_response }` 由 `PartialMessageResponse::finalize()` 产出。
|
||||
//! - `capabilities()` 报告 OpenAI Chat 协议的能力。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
@@ -145,6 +146,11 @@ pub(crate) struct OpenaiChatRequest {
|
||||
pub extra_headers: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub extra_body: Option<Value>,
|
||||
/// 请求级别自定义 HTTP 头。运行时注入,不进入 JSON 请求体。
|
||||
/// ⚠️ 与 struct 已有的 `extra_headers: Option<Value>`(OpenAI API 自身的 wire 格式字段)
|
||||
/// 不同——后者是 OpenAI API 参数,本字段是 reqwest 层的 HTTP 头注入。
|
||||
#[serde(skip)]
|
||||
pub custom_headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -437,6 +443,12 @@ impl GenericOpenaiProvider {
|
||||
)
|
||||
}
|
||||
|
||||
/// 注入 Provider 级别固定头。返回 self 以支持链式调用。
|
||||
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
|
||||
self.extra_headers = headers;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_client(mut self, client: Client) -> Self {
|
||||
self.http_client = client;
|
||||
self
|
||||
@@ -451,10 +463,13 @@ impl GenericOpenaiProvider {
|
||||
}
|
||||
|
||||
/// 构造 HTTP POST 请求 builder(含认证头与额外请求头)。
|
||||
///
|
||||
/// 头融合顺序:Authorization → Provider 级 extra_headers → 请求级 custom_headers
|
||||
/// 后者覆盖前者。
|
||||
fn build_request_builder(
|
||||
&self,
|
||||
url: &str,
|
||||
body: &impl Serialize,
|
||||
body: &OpenaiChatRequest,
|
||||
) -> Result<reqwest::RequestBuilder, LlmError> {
|
||||
let mut builder = self
|
||||
.http_client
|
||||
@@ -463,6 +478,9 @@ impl GenericOpenaiProvider {
|
||||
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.json(body))
|
||||
}
|
||||
|
||||
@@ -557,6 +575,8 @@ impl GenericOpenaiProvider {
|
||||
let seed = request.get_extra_opt("seed");
|
||||
let response_format = request.get_extra_opt("response_format");
|
||||
let parallel_tool_calls = request.get_extra_opt("parallel_tool_calls");
|
||||
let custom_headers: HashMap<String, String> =
|
||||
request.get_extra_opt("custom_headers").unwrap_or_default();
|
||||
|
||||
Ok(OpenaiChatRequest {
|
||||
model,
|
||||
@@ -573,6 +593,7 @@ impl GenericOpenaiProvider {
|
||||
seed,
|
||||
response_format,
|
||||
parallel_tool_calls,
|
||||
custom_headers,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
@@ -1695,4 +1716,189 @@ data: [DONE]\n\n";
|
||||
"expected an Error event for malformed SSE chunk"
|
||||
);
|
||||
}
|
||||
|
||||
// ===== custom_headers (Phase 8 Step 8.7) =====
|
||||
|
||||
fn make_provider_for_header_tests(base_url: String) -> GenericOpenaiProvider {
|
||||
GenericOpenaiProvider::new_with_name(
|
||||
base_url,
|
||||
"sk-test".into(),
|
||||
"gpt-4o".into(),
|
||||
"openai",
|
||||
30,
|
||||
)
|
||||
}
|
||||
|
||||
fn mock_chat_completions_body() -> serde_json::Value {
|
||||
json!({
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"created": 1,
|
||||
"model": "gpt-4o",
|
||||
"choices": [{"index": 0, "message": {"role": "assistant", "content": "OK"}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_custom_headers_from_extra() {
|
||||
let provider = make_provider_for_header_tests("http://x".into());
|
||||
let mut req = MessageRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![Message::user_text("hi")],
|
||||
..Default::default()
|
||||
};
|
||||
req.set_extra("custom_headers", json!({"X-Custom": "v1", "X-Other": "v2"}));
|
||||
|
||||
let body = provider.convert_request(req).unwrap();
|
||||
assert_eq!(body.custom_headers.get("X-Custom").unwrap(), "v1");
|
||||
assert_eq!(body.custom_headers.get("X-Other").unwrap(), "v2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_custom_headers_skipped_in_json_body() {
|
||||
let provider = make_provider_for_header_tests("http://x".into());
|
||||
let mut req = MessageRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![Message::user_text("hi")],
|
||||
..Default::default()
|
||||
};
|
||||
req.set_extra("custom_headers", json!({"X-Custom": "v1"}));
|
||||
let body = provider.convert_request(req).unwrap();
|
||||
|
||||
let value = serde_json::to_value(&body).unwrap();
|
||||
assert!(
|
||||
value.get("custom_headers").is_none(),
|
||||
"custom_headers 不应进入 JSON body"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_custom_headers_invalid_type_fallback() {
|
||||
let provider = make_provider_for_header_tests("http://x".into());
|
||||
let mut req = MessageRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![Message::user_text("hi")],
|
||||
..Default::default()
|
||||
};
|
||||
req.set_extra("custom_headers", json!("not_an_object"));
|
||||
let body = provider.convert_request(req).unwrap();
|
||||
assert!(body.custom_headers.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_chat_custom_headers_are_sent() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/chat/completions"))
|
||||
.and(header("authorization", "Bearer sk-test"))
|
||||
.and(header("X-Custom", "v1"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(mock_chat_completions_body()))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = make_provider_for_header_tests(server.uri());
|
||||
let mut req = MessageRequest {
|
||||
model: "gpt-4o".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 openai_chat_provider_level_headers_are_sent() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/chat/completions"))
|
||||
.and(header("X-Platform", "doubao"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(mock_chat_completions_body()))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = make_provider_for_header_tests(server.uri())
|
||||
.with_extra_headers(vec![("X-Platform".into(), "doubao".into())]);
|
||||
let req = MessageRequest {
|
||||
model: "gpt-4o".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 openai_chat_custom_headers_override_provider_headers() {
|
||||
// ponytail: wiremock 的 `header()` 是精确匹配(顺序敏感),同 key 多值无法匹配。
|
||||
// 因此 override 测试用通用 mock + server.received_requests() 事后验证实际请求头。
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/chat/completions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(mock_chat_completions_body()))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = make_provider_for_header_tests(server.uri())
|
||||
.with_extra_headers(vec![("X-Platform".into(), "provider-level".into())]);
|
||||
let mut req = MessageRequest {
|
||||
model: "gpt-4o".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 openai_chat_custom_headers_can_override_auth_header() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/chat/completions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(mock_chat_completions_body()))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = make_provider_for_header_tests(server.uri());
|
||||
let mut req = MessageRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![Message::user_text("hi")],
|
||||
..Default::default()
|
||||
};
|
||||
req.set_extra(
|
||||
"custom_headers",
|
||||
json!({"authorization": "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 auth_values: Vec<&str> = received[0]
|
||||
.headers
|
||||
.get_all("authorization")
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.collect();
|
||||
assert!(
|
||||
auth_values.contains(&"from-custom-headers"),
|
||||
"custom_headers 应能覆盖 Authorization 头,实际收到: {auth_values:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! - assistant 文本回传用 `output_text`(区别于 user 的 `input_text`),对齐 OpenAI wire 格式
|
||||
//! - 未知 `item_type` 兜底为 `ContentBlock::Extension` 保持前向兼容
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
@@ -71,6 +72,10 @@ pub(crate) struct OpenaiResponseRequest {
|
||||
pub metadata: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning: Option<Value>,
|
||||
/// 请求级别自定义 HTTP 头。序列化时跳过,仅运行时由 build_request_builder 消费。
|
||||
/// stream 模式的修改不影响该字段——header 由 convert_request 在请求构造时注入。
|
||||
#[serde(skip)]
|
||||
pub custom_headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Request input item —— untagged 枚举。
|
||||
@@ -292,6 +297,8 @@ pub struct OpenaiResponseProvider {
|
||||
/// ponytail: 单独存储以便 `LlmError::Timeout { duration }` 与配置保持一致。
|
||||
/// `reqwest::Client` 不暴露 timeout getter。
|
||||
timeout_secs: u64,
|
||||
/// Provider 级别固定请求头(如平台标识头),所有请求自动携带。
|
||||
extra_headers: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl OpenaiResponseProvider {
|
||||
@@ -302,6 +309,7 @@ impl OpenaiResponseProvider {
|
||||
model: String,
|
||||
http_client: Client,
|
||||
timeout_secs: u64,
|
||||
extra_headers: Vec<(String, String)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
http_client,
|
||||
@@ -309,22 +317,38 @@ impl OpenaiResponseProvider {
|
||||
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 endpoint_url(&self) -> String {
|
||||
format!("{}/responses", self.base_url.trim_end_matches('/'))
|
||||
}
|
||||
|
||||
fn build_request_builder(
|
||||
&self,
|
||||
body: &impl Serialize,
|
||||
body: &OpenaiResponseRequest,
|
||||
) -> Result<reqwest::RequestBuilder, LlmError> {
|
||||
Ok(self
|
||||
let mut builder = self
|
||||
.http_client
|
||||
.post(self.endpoint_url())
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.json(body))
|
||||
.header("Authorization", format!("Bearer {}", self.api_key));
|
||||
|
||||
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.json(body))
|
||||
}
|
||||
|
||||
fn map_reqwest_error(&self, e: reqwest::Error) -> LlmError {
|
||||
@@ -403,6 +427,14 @@ impl OpenaiResponseProvider {
|
||||
let metadata: Option<Value> = extra.get("metadata").cloned();
|
||||
let reasoning: Option<Value> = extra.get("reasoning").cloned();
|
||||
|
||||
// 注意:OpenaiResponseProvider 的 `convert_request` 在顶部 destructure 了 `request`,
|
||||
// 因此使用 `extra.get()` 而非 `request.get_extra_opt()`。两者语义一致,
|
||||
// 均反序列化为 `HashMap<String, String>`,失败时静默降级为空 HashMap。
|
||||
let custom_headers: HashMap<String, String> = extra
|
||||
.get("custom_headers")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
// ponytail: tool_choice 直通映射 —— ToolChoice::None → "none"(与 GenericOpenaiProvider 行为一致),
|
||||
// Auto/Required → 字符串,Named → 对象。SA Director 审查 Round 1 修复。
|
||||
let tool_choice_value = match tool_choice {
|
||||
@@ -611,6 +643,7 @@ impl OpenaiResponseProvider {
|
||||
truncation,
|
||||
metadata,
|
||||
reasoning,
|
||||
custom_headers,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1020,7 +1053,14 @@ mod tests {
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("create http client");
|
||||
OpenaiResponseProvider::from_parts(base_url, "sk-test".into(), "gpt-4o".into(), client, 30)
|
||||
OpenaiResponseProvider::from_parts(
|
||||
base_url,
|
||||
"sk-test".into(),
|
||||
"gpt-4o".into(),
|
||||
client,
|
||||
30,
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
|
||||
// ===== convert_request 单元测试 =====
|
||||
@@ -1975,4 +2015,185 @@ data: {\"type\":\"response.failed\",\"error\":{\"message\":\"server failed mid-s
|
||||
assert!(caps.features.streaming);
|
||||
assert_eq!(caps.features.max_context_window, 200_000);
|
||||
}
|
||||
|
||||
// ===== custom_headers (Phase 8 Step 8.7) =====
|
||||
|
||||
fn mock_responses_body() -> serde_json::Value {
|
||||
json!({
|
||||
"id": "resp-test",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"model": "gpt-4o",
|
||||
"output": [{
|
||||
"id": "msg_1", "type": "message", "role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "OK"}]
|
||||
}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_response_custom_headers_from_extra() {
|
||||
let provider = make_provider("http://x".into());
|
||||
let mut req = MessageRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![Message::user_text("hi")],
|
||||
..Default::default()
|
||||
};
|
||||
req.set_extra("custom_headers", json!({"X-Custom": "v1", "X-Other": "v2"}));
|
||||
let body = provider.convert_request(req).unwrap();
|
||||
assert_eq!(body.custom_headers.get("X-Custom").unwrap(), "v1");
|
||||
assert_eq!(body.custom_headers.get("X-Other").unwrap(), "v2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_response_custom_headers_skipped_in_json_body() {
|
||||
let provider = make_provider("http://x".into());
|
||||
let mut req = MessageRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![Message::user_text("hi")],
|
||||
..Default::default()
|
||||
};
|
||||
req.set_extra("custom_headers", json!({"X-Custom": "v1"}));
|
||||
let body = provider.convert_request(req).unwrap();
|
||||
let value = serde_json::to_value(&body).unwrap();
|
||||
assert!(
|
||||
value.get("custom_headers").is_none(),
|
||||
"custom_headers 不应进入 JSON body"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_response_custom_headers_invalid_type_fallback() {
|
||||
let provider = make_provider("http://x".into());
|
||||
let mut req = MessageRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![Message::user_text("hi")],
|
||||
..Default::default()
|
||||
};
|
||||
req.set_extra("custom_headers", json!("not_an_object"));
|
||||
let body = provider.convert_request(req).unwrap();
|
||||
assert!(body.custom_headers.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_response_custom_headers_are_sent() {
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/responses"))
|
||||
.and(header("authorization", "Bearer sk-test"))
|
||||
.and(header("X-Custom", "v1"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(mock_responses_body()))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = make_provider(server.uri());
|
||||
let mut req = MessageRequest {
|
||||
model: "gpt-4o".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 openai_response_provider_level_headers_are_sent() {
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/responses"))
|
||||
.and(header("X-Platform", "doubao"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(mock_responses_body()))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = make_provider(server.uri())
|
||||
.with_extra_headers(vec![("X-Platform".into(), "doubao".into())]);
|
||||
let req = MessageRequest {
|
||||
model: "gpt-4o".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 openai_response_custom_headers_override_provider_headers() {
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/responses"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(mock_responses_body()))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = make_provider(server.uri())
|
||||
.with_extra_headers(vec![("X-Platform".into(), "provider-level".into())]);
|
||||
let mut req = MessageRequest {
|
||||
model: "gpt-4o".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 openai_response_custom_headers_can_override_auth_header() {
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/responses"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(mock_responses_body()))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = make_provider(server.uri());
|
||||
let mut req = MessageRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![Message::user_text("hi")],
|
||||
..Default::default()
|
||||
};
|
||||
req.set_extra(
|
||||
"custom_headers",
|
||||
json!({"authorization": "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 auth_values: Vec<&str> = received[0]
|
||||
.headers
|
||||
.get_all("authorization")
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.collect();
|
||||
assert!(
|
||||
auth_values.contains(&"from-custom-headers"),
|
||||
"custom_headers 应能覆盖 Authorization 头,实际收到: {auth_values:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user