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
@@ -0,0 +1,422 @@
# OpenAI Response Provider 自定义请求头支持
## 背景
OpenAI Responses API 的部分实现(如火山引擎豆包)需要携带特殊的 HTTP 请求头(如 `ark-beta-doubao-app: true`)来启用平台特定功能。当前 `OpenaiResponseProvider``build_request_builder()` 中只设置了 `Authorization` 头,没有途径注入自定义请求头。
原方案只覆盖 OpenAI Response Provider。经讨论后扩展为**三 Provider 统一**方案:OpenAI Chat`GenericOpenaiProvider`)、OpenAI Response`OpenaiResponseProvider`)、Anthropic`AnthropicProvider`)。
核心动机:
- OpenAI Responses API 的部分实现需要携带特殊 HTTP 请求头来启用平台特定功能
- 三种基础协议中,自定义头注入能力不一致
- 统一 API 让调用方用 `set_extra("custom_headers", ...)` 即可,与底层协议无关
## 需求
### 功能需求
双层自定义头机制:
- **Provider 级固定头**`extra_headers: Vec<(String, String)>`,构造时注入,所有请求自动携带。用于该 provider 所有请求都需要的固定标识头(如平台接入标记)
- **请求级临时头**`extra.custom_headers: HashMap<String, String>`,通过 `set_extra` 注入。用于特定请求需要覆盖或追加的头
### 约束
- 不可引入任何平台特定逻辑(火山、豆包等字符串不得出现)
- 自定义头仅运行时生效,不进入 JSON 序列化的请求体
- 兼容已有的 extra 逃生舱机制(builtin_tools、text_format 等)
- agcore 是支持库,不提供运行时敏感头过滤保护(如 Authorization/Cookie),但文档中应说明风险
- 不修改 `LlmProvider` trait、`ProviderType` 枚举
- `create_provider()` 工厂函数只传 `Vec::new()` 作为 extra_headers 默认值,不暴露配置能力;调用方如需 Provider 级固定头,直接构造 provider 后链式调用 `.with_extra_headers()`
### 用户故事
1. 作为集成者,我想对任意 provider 的请求注入自定义 HTTP 头,以启用平台特有功能(请求级)
2. 作为集成者,我想在 provider 构造时注入固定头,让所有请求自动携带,避免每次重复指定(Provider 级)
3. 作为维护者,我想三种基础协议使用统一的 API,调用方无需关心底层 provider 类型
## 方案设计
### 统一设计原则
```
调用方视角(统一 API):
request.set_extra("custom_headers", json!({"X-Foo": "bar"}));
// 不管底层是 OpenAI Chat / OpenAI Response / Anthropic,都能工作
构造方视角(Provider 级):
OpenaiResponseProvider::from_parts(..., extra_headers).with_extra_headers(...);
GenericOpenaiProvider::from_parts(..., extra_headers); // 已有
AnthropicProvider::from_parts(..., extra_headers);
头融合顺序(三 provider 一致):
认证头 (Authorization / x-api-key) → Provider 级 extra_headers → 请求级 custom_headers
↑ 后者覆盖前者
```
### 改动一:GenericOpenaiProvideropenai.rs
**`OpenaiChatRequest` 新增字段**
`extra_body`(第 147 行)之后:
```rust
/// 请求级别自定义 HTTP 头。运行时注入,不进入 JSON 请求体。
/// ⚠️ 与 struct 已有的 `extra_headers: Option<Value>`OpenAI API 自身的 wire 格式字段)
/// 不同——后者是 OpenAI API 参数,本字段是 reqwest 层的 HTTP 头注入。
#[serde(skip)]
pub custom_headers: HashMap<String, String>,
```
`#[serde(skip)]` 确保该字段不会出现在序列化后的 JSON body 中。
**`convert_request()` 从 extra 提取**
`parallel_tool_calls`(第 559 行)之后:
```rust
let custom_headers: HashMap<String, String> = request
.get_extra_opt("custom_headers")
.unwrap_or_default();
```
**`build_request_builder()` 签名改具体类型 + 注入逻辑**
第 454 行,签名从 `&impl Serialize` 改为 `&OpenaiChatRequest`(两处调用点传入的均为该类型,安全):
```rust
fn build_request_builder(
&self,
url: &str,
body: &OpenaiChatRequest, // 从 &impl Serialize 改为具体类型
) -> Result<reqwest::RequestBuilder, LlmError> {
let mut builder = self
.http_client
.post(url)
.header("Authorization", format!("Bearer {}", self.api_key));
// 头融合顺序见上方「统一设计原则」。
// Provider 级固定头先注入,请求级临时头后注入(后者覆盖前者)。
Ok(builder.json(body))
}
```
两处调用点(`chat_blocking` 第 628 行、`chat_stream_inner` 第 669 行)传入的都是 `&OpenaiChatRequest`,零影响。
**`with_extra_headers()` builder 方法**
```rust
/// 注入 Provider 级别固定头。返回 self 以支持链式调用。
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
self.extra_headers = headers;
self
}
```
### 改动二:OpenaiResponseProvideropenai_response.rs
**① struct 新增 `extra_headers` 字段**
第 287 行,`pub struct OpenaiResponseProvider` 增加:
```rust
pub struct OpenaiResponseProvider {
// ... 已有字段 ...
extra_headers: Vec<(String, String)>,
}
```
**`from_parts()` 新增参数**
第 299 行:
```rust
pub(crate) fn from_parts(
base_url: String,
api_key: String,
model: String,
http_client: Client,
timeout_secs: u64,
extra_headers: Vec<(String, String)>, // 新增
) -> Self { ... }
```
**`with_extra_headers()` builder 方法**
```rust
/// 注入 Provider 级别固定头。返回 self 以支持链式调用。
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
self.extra_headers = headers;
self
}
```
**`OpenaiResponseRequest` 新增字段**
第 73 行,`reasoning` 之后:
```rust
/// 请求级别自定义 HTTP 头。序列化时跳过,仅运行时由 build_request_builder 消费。
/// stream 模式的修改不影响该字段——header 由 convert_request 在请求构造时注入。
#[serde(skip)]
pub custom_headers: HashMap<String, String>,
```
`#[serde(skip)]` 确保该字段不会出现在序列化后的 JSON body 中。
**`convert_request()` 从 extra 提取**
第 404 行,`reasoning` 之后:
```rust
let custom_headers: HashMap<String, String> = extra
.get("custom_headers")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
```
> **注意**OpenaiResponseProvider 的 `convert_request` 在顶部 destructure 了 `request`,因此使用 `extra.get()` 而非 `request.get_extra_opt()`。两者语义一致,均反序列化为 `HashMap<String, String>`,失败时静默降级为空 HashMap。
**`build_request_builder()` 签名 + 注入逻辑**
第 319 行,签名从 `&impl Serialize` 改为 `&OpenaiResponseRequest`(两处调用点传入的均为该类型,安全):
```rust
/// 构造 HTTP POST 请求 builder(含认证头与额外请求头)。
///
/// 头融合顺序:Authorization → Provider 级 extra_headers → 请求级 custom_headers
/// 后者覆盖前者。
fn build_request_builder(
&self,
body: &OpenaiResponseRequest, // 从 &impl Serialize 改为具体类型
) -> Result<reqwest::RequestBuilder, LlmError> {
let mut builder = self
.http_client
.post(self.endpoint_url())
.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))
}
```
两处调用点(`chat_blocking` 第 708 行、`chat_stream_inner` 第 741 行)传入的都是 `&OpenaiResponseRequest`,零影响。
### 改动三:AnthropicProvideranthropic.rs
AnthropicProvider 是唯一没有统一 `build_request_builder` 方法的 provider,需要**前置重构**。
**① struct 新增 `extra_headers` 字段**
第 36 行:
```rust
pub struct AnthropicProvider {
// ... 已有字段 ...
extra_headers: Vec<(String, String)>,
}
```
**`from_parts()` 新增参数**
第 128 行:
```rust
pub(crate) fn from_parts(
base_url: String,
api_key: String,
model: String,
http_client: Client,
timeout_secs: u64,
extra_headers: Vec<(String, String)>, // 新增
) -> Self { ... }
```
**`with_extra_headers()` builder 方法**
```rust
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
self.extra_headers = headers;
self
}
```
**`AnthropicRequestBody` 新增字段**
第 450 行,`stream` 之后:
```rust
struct AnthropicRequestBody {
model: String,
max_tokens: u32,
// ... 已有字段 ...
/// 请求级别自定义 HTTP 头。运行时注入,不进入 JSON 请求体。
#[serde(skip)]
custom_headers: HashMap<String, String>,
}
```
`#[serde(skip)]` 确保该字段不会出现在序列化后的 JSON body 中。
**`build_request_body()` 从 extra 提取**
```rust
let custom_headers: HashMap<String, String> = request
.get_extra_opt("custom_headers")
.unwrap_or_default();
```
**⑥ 提取 `build_request_builder()` 统一方法(前置重构)**
```rust
/// 构造 HTTP POST 请求 builder(含认证头 + 自定义头)。
/// 认证头(x-api-key / anthropic-version)已由 Client 的 default_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)
}
```
**⑦ 改造 `chat_blocking()``chat_stream_inner()`**
改造前(`chat_blocking`,第 263-269 行):
```rust
let response = self
.http_client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| self.map_reqwest_error(e))?;
```
改造后:
```rust
let response = self
.build_request_builder(&body)?
.send()
.await
.map_err(|e| self.map_reqwest_error(e))?;
```
`chat_stream_inner`(第 298-304 行)同理。
### 改动四:create_provider()provider.rs
依据约束「`create_provider()` 工厂函数不暴露配置能力」,三处分支适配 `from_parts` 的新签名时全部传 `Vec::new()`
```rust
// OpenaiResponse(第 199-207 行)
openai_response::OpenaiResponseProvider::from_parts(
config.base_url, config.api_key, config.model,
client, config.timeout_secs,
Vec::new(), // extra_headers 默认空
)
// Anthropic(第 215-221 行)
anthropic::AnthropicProvider::from_parts(
config.base_url, config.api_key, config.model,
client, config.timeout_secs,
Vec::new(), // extra_headers 默认空
)
// OpenAI Chat(第 185-194 行)— 已有 Vec::new(),无需改动
```
### 调用方式
**请求级临时头**(统一 API,三 provider 通用):
```rust
request.set_extra("custom_headers", serde_json::json!({
"ark-beta-doubao-app": "true"
}));
```
**Provider 级固定头**(构造时注入):
```rust
let provider = OpenaiResponseProvider::from_parts(...)
.with_extra_headers(vec![
("ark-beta-doubao-app".into(), "true".into()),
]);
```
## 风险评估
### 风险点与缓解措施
| 风险 | 等级 | 缓解措施 |
|------|------|---------|
| 用户通过 `custom_headers` 覆盖 `Authorization` 等认证头 | 中 | 文档说明:自定义头按遍历顺序注入,同 key 后注入覆盖前注入。agcore 作为支持库不做运行时拦截 |
| `serde_json::from_value` 类型错误静默降级为空 HashMap | 低 | 与已有 extra 字段(builtin_tools、text_format)一致的模式,保持行为统一。类型错误时请求正常发出,只是不携带自定义头 |
| HashMap 迭代顺序不确定影响测试确定性 | 低 | HTTP 协议不要求 header 顺序,wiremock 按名匹配。无需特殊处理 |
| AnthropicProvider 前置重构引入回归 | 低 | 提取 `build_request_builder` 是纯重构,现有测试覆盖其请求构造行为。重构后运行现有测试套件即可验证 |
| `build_request_builder` 签名从泛型改为具体类型 | 低 | 已确认两处调用点(chat_blocking / chat_stream_inner)传入的均为具体类型,零影响 |
| AnthropicProvider 的 `default_headers`x-api-key / anthropic-version)与 `extra_headers` 同名头合并行为取决于 reqwest 实现 | 低 | 明确约定 Provider 级固定头不应意图覆盖认证头;`build_request_builder` 的 doc comment 中标注认证头来源 |
### 设计取舍记录
| 决策 | 选择 | 理由 |
|------|------|------|
| Provider 级 vs 请求级 | 双层都支持 | 满足固定头和临时头两种场景 |
| `create_provider` 是否暴露 extra_headers | 不暴露,只传 `Vec::new()` | 保持工厂函数签名简洁,固定头通过 builder 方法注入 |
| 敏感头保护 | 不做运行时拦截,文档说明 | agcore 是支持库,不替调用方做保护 |
| `OpenaiChatRequest.custom_headers` 命名 | 用 `custom_headers` 而非 `extra_headers` | 避免与已有的 `extra_headers: Option<Value>`OpenAI API wire 字段)混淆 |
## 验证标准
### 单元测试(每 provider 4 个)
| 测试 | 验证点 |
|------|--------|
| `*_custom_headers_from_extra` | `convert_request` / `build_request_body` 能从 extra 提取 `custom_headers` |
| `*_custom_headers_skipped_in_json` | `#[serde(skip)]` 确保 custom_headers 不进入序列化 JSON body |
| `*_custom_headers_invalid_type_fallback` | 传入错误类型(如字符串而非对象)时静默降级为空 HashMap |
| `*_extra_headers_from_constructor` | 验证 `from_parts` / `new_with_name_and_headers` 传入的 `extra_headers``build_request_builder` 中被正确注入到 HTTP 请求头 |
### 集成测试(每 provider 4 个,wiremock
| 测试 | 验证点 |
|------|--------|
| `*_custom_headers_are_sent` | mock 匹配器验证 HTTP 请求确实携带自定义头 |
| `*_provider_level_headers_are_sent` | 验证 Provider 级固定头(通过 `with_extra_headers` 注入)确实出现在 HTTP 请求中 |
| `*_custom_headers_override_provider_headers` | 当 Provider 级和请求级设置了相同 key 但不同值时,最终 HTTP 请求携带的是请求级的值 |
| `*_custom_headers_can_override_auth_header` | 注入含 `Authorization` 同 key 的 `custom_headers`,验证最终认证头值被覆盖(使行为可见、可预测,与文档风险说明一致) |
### 回归验证
1. 运行 `cargo test --features full` 确保所有现有测试通过
2. `cargo clippy --features full` 无新警告
3. `cargo fmt --check` 格式一致
## 不涉及的改动
- 不新增 Feature gate
- 不修改 `LlmProvider` trait
- 不修改 `ProviderType` 枚举
- 不新增任何平台相关代码