将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
15 KiB
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),但文档中应说明风险
- 不修改
LlmProvidertrait、ProviderType枚举 create_provider()工厂函数只传Vec::new()作为 extra_headers 默认值,不暴露配置能力;调用方如需 Provider 级固定头,直接构造 provider 后链式调用.with_extra_headers()
用户故事
- 作为集成者,我想对任意 provider 的请求注入自定义 HTTP 头,以启用平台特有功能(请求级)
- 作为集成者,我想在 provider 构造时注入固定头,让所有请求自动携带,避免每次重复指定(Provider 级)
- 作为维护者,我想三种基础协议使用统一的 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 行)之后:
/// 请求级别自定义 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 行)之后:
let custom_headers: HashMap<String, String> = request
.get_extra_opt("custom_headers")
.unwrap_or_default();
③ build_request_builder() 签名改具体类型 + 注入逻辑
第 454 行,签名从 &impl Serialize 改为 &OpenaiChatRequest(两处调用点传入的均为该类型,安全):
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 方法
/// 注入 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 增加:
pub struct OpenaiResponseProvider {
// ... 已有字段 ...
extra_headers: Vec<(String, String)>,
}
② from_parts() 新增参数
第 299 行:
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 方法
/// 注入 Provider 级别固定头。返回 self 以支持链式调用。
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
self.extra_headers = headers;
self
}
④ OpenaiResponseRequest 新增字段
第 73 行,reasoning 之后:
/// 请求级别自定义 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 之后:
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(两处调用点传入的均为该类型,安全):
/// 构造 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 行:
pub struct AnthropicProvider {
// ... 已有字段 ...
extra_headers: Vec<(String, String)>,
}
② from_parts() 新增参数
第 128 行:
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 方法
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
self.extra_headers = headers;
self
}
④ AnthropicRequestBody 新增字段
第 450 行,stream 之后:
struct AnthropicRequestBody {
model: String,
max_tokens: u32,
// ... 已有字段 ...
/// 请求级别自定义 HTTP 头。运行时注入,不进入 JSON 请求体。
#[serde(skip)]
custom_headers: HashMap<String, String>,
}
#[serde(skip)] 确保该字段不会出现在序列化后的 JSON body 中。
⑤ build_request_body() 从 extra 提取
let custom_headers: HashMap<String, String> = request
.get_extra_opt("custom_headers")
.unwrap_or_default();
⑥ 提取 build_request_builder() 统一方法(前置重构)
/// 构造 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 行):
let response = self
.http_client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| self.map_reqwest_error(e))?;
改造后:
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():
// 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 通用):
request.set_extra("custom_headers", serde_json::json!({
"ark-beta-doubao-app": "true"
}));
Provider 级固定头(构造时注入):
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,验证最终认证头值被覆盖(使行为可见、可预测,与文档风险说明一致) |
回归验证
- 运行
cargo test --features full确保所有现有测试通过 cargo clippy --features full无新警告cargo fmt --check格式一致
不涉及的改动
- 不新增 Feature gate
- 不修改
LlmProvidertrait - 不修改
ProviderType枚举 - 不新增任何平台相关代码