28ca43ccb2
将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
423 lines
15 KiB
Markdown
423 lines
15 KiB
Markdown
# 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
|
||
↑ 后者覆盖前者
|
||
```
|
||
|
||
### 改动一:GenericOpenaiProvider(openai.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
|
||
}
|
||
```
|
||
|
||
### 改动二:OpenaiResponseProvider(openai_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`,零影响。
|
||
|
||
### 改动三:AnthropicProvider(anthropic.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` 枚举
|
||
- 不新增任何平台相关代码
|