# 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`,通过 `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`(OpenAI API 自身的 wire 格式字段) /// 不同——后者是 OpenAI API 参数,本字段是 reqwest 层的 HTTP 头注入。 #[serde(skip)] pub custom_headers: HashMap, ``` `#[serde(skip)]` 确保该字段不会出现在序列化后的 JSON body 中。 **② `convert_request()` 从 extra 提取** 在 `parallel_tool_calls`(第 559 行)之后: ```rust let custom_headers: HashMap = 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 { 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, ``` `#[serde(skip)]` 确保该字段不会出现在序列化后的 JSON body 中。 **⑤ `convert_request()` 从 extra 提取** 第 404 行,`reasoning` 之后: ```rust let custom_headers: HashMap = 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`,失败时静默降级为空 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 { 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, } ``` `#[serde(skip)]` 确保该字段不会出现在序列化后的 JSON body 中。 **⑤ `build_request_body()` 从 extra 提取** ```rust let custom_headers: HashMap = 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 { 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`(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` 枚举 - 不新增任何平台相关代码