Files
agcore/design/pdd/29-openai-response-provider-custom-headers.md
T
徐涛 28ca43ccb2 chore(docs): 将设计文档从 docs 移至 design 目录
将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
2026-07-23 05:45:53 +08:00

15 KiB
Raw Blame History

OpenAI Response Provider 自定义请求头支持

背景

OpenAI Responses API 的部分实现(如火山引擎豆包)需要携带特殊的 HTTP 请求头(如 ark-beta-doubao-app: true)来启用平台特定功能。当前 OpenaiResponseProviderbuild_request_builder() 中只设置了 Authorization 头,没有途径注入自定义请求头。

原方案只覆盖 OpenAI Response Provider。经讨论后扩展为三 Provider 统一方案:OpenAI ChatGenericOpenaiProvider)、OpenAI ResponseOpenaiResponseProvider)、AnthropicAnthropicProvider)。

核心动机:

  • 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 行)之后:

/// 请求级别自定义 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
}

改动二:OpenaiResponseProvideropenai_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,零影响。

改动三:AnthropicProvideranthropic.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_headersx-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_headersbuild_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 枚举
  • 不新增任何平台相关代码