Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ea01d373e | ||
|
|
5e475e1303 | ||
|
|
76bbeed596 | ||
|
|
77321db8f6 | ||
|
|
939dcf0f9a | ||
|
|
b895616dd0 |
@@ -20,6 +20,7 @@ jobs:
|
|||||||
- "chat,provider-openai,tools-mcp"
|
- "chat,provider-openai,tools-mcp"
|
||||||
- "multi,provider-openai"
|
- "multi,provider-openai"
|
||||||
- "multi,provider-openai,tools-mcp"
|
- "multi,provider-openai,tools-mcp"
|
||||||
|
- "chat,provider-openai,provider-openai-response"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
|||||||
+10
-4
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agcore"
|
name = "agcore"
|
||||||
version = "0.3.2"
|
version = "0.3.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
@@ -20,9 +20,11 @@ agent = ["llm", "tools", "memory", "futures-util"]
|
|||||||
engine = ["agent"]
|
engine = ["agent"]
|
||||||
|
|
||||||
# === Provider features ===
|
# === Provider features ===
|
||||||
# Provider features — openai/anthropic 额外依赖 bytes(流式解析)和 futures-util(Stream 组合)
|
# Provider features — openai/anthropic/openai-response 额外依赖 bytes(流式解析)和 futures-util(Stream 组合)
|
||||||
provider-openai = ["llm", "reqwest", "bytes", "futures-util"]
|
provider-openai = ["llm", "reqwest", "bytes", "futures-util"]
|
||||||
provider-anthropic = ["llm", "reqwest", "bytes", "futures-util"]
|
provider-anthropic = ["llm", "reqwest", "bytes", "futures-util"]
|
||||||
|
# OpenAI Response API(POST /responses)—— 与 Chat Completions 协议独立,独立 feature
|
||||||
|
provider-openai-response = ["llm", "reqwest", "bytes", "futures-util"]
|
||||||
# deepseek/qwen 使用 openai_compat 适配层,不需要 bytes 和 futures-util
|
# deepseek/qwen 使用 openai_compat 适配层,不需要 bytes 和 futures-util
|
||||||
provider-deepseek = ["llm", "reqwest"]
|
provider-deepseek = ["llm", "reqwest"]
|
||||||
provider-qwen = ["llm", "reqwest"]
|
provider-qwen = ["llm", "reqwest"]
|
||||||
@@ -37,8 +39,8 @@ full = [
|
|||||||
"tools", "tools-mcp",
|
"tools", "tools-mcp",
|
||||||
"memory", "memory-sqlite",
|
"memory", "memory-sqlite",
|
||||||
"agent", "engine",
|
"agent", "engine",
|
||||||
"provider-openai", "provider-anthropic", "provider-deepseek",
|
"provider-openai", "provider-anthropic", "provider-openai-response",
|
||||||
"provider-qwen", "provider-ollama",
|
"provider-deepseek", "provider-qwen", "provider-ollama",
|
||||||
"tracing-init",
|
"tracing-init",
|
||||||
]
|
]
|
||||||
light = ["llm", "provider-openai", "tools", "tools-mcp", "memory", "agent", "engine", "prompt", "document"]
|
light = ["llm", "provider-openai", "tools", "tools-mcp", "memory", "agent", "engine", "prompt", "document"]
|
||||||
@@ -148,3 +150,7 @@ required-features = ["memory", "tracing-init"]
|
|||||||
[[example]]
|
[[example]]
|
||||||
name = "end_to_end"
|
name = "end_to_end"
|
||||||
required-features = ["agent", "memory-sqlite", "provider-openai"]
|
required-features = ["agent", "memory-sqlite", "provider-openai"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "response_api_demo"
|
||||||
|
required-features = ["llm", "provider-openai-response"]
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ let provider = create_provider(
|
|||||||
).expect("创建 Provider 失败");
|
).expect("创建 Provider 失败");
|
||||||
```
|
```
|
||||||
|
|
||||||
更多端到端示例见 [`examples/`](./examples/) 目录(共 18 个,全部可 `cargo run --example <name>`):
|
更多端到端示例见 [`examples/`](./examples/) 目录(全部可 `cargo run --example <name>`):
|
||||||
|
|
||||||
| 示例 | 说明 |
|
| 示例 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -132,6 +132,7 @@ let provider = create_provider(
|
|||||||
| `engine_demo` | Agent 执行引擎:SessionManager 会话树 + Checkpointer 快照恢复 |
|
| `engine_demo` | Agent 执行引擎:SessionManager 会话树 + Checkpointer 快照恢复 |
|
||||||
| `bridge_keys_demo` | 桥接键:Agent 间上下文键值透传 |
|
| `bridge_keys_demo` | 桥接键:Agent 间上下文键值透传 |
|
||||||
| `agent_switch_demo` | Agent 热切换:会话中动态切换 Agent 角色 |
|
| `agent_switch_demo` | Agent 热切换:会话中动态切换 Agent 角色 |
|
||||||
|
| `response_api_demo` | OpenAI Response API(`POST /responses`)真实调用 |
|
||||||
|
|
||||||
## Feature 组合
|
## Feature 组合
|
||||||
|
|
||||||
@@ -184,6 +185,7 @@ agcore = { version = "0.3", default-features = false, features = ["multi", "prov
|
|||||||
| `engine` | SessionManager + Checkpointer + SubAgent + Switch | `agent` |
|
| `engine` | SessionManager + Checkpointer + SubAgent + Switch | `agent` |
|
||||||
| `provider-openai` | OpenAI Provider 实现 | `llm` |
|
| `provider-openai` | OpenAI Provider 实现 | `llm` |
|
||||||
| `provider-anthropic` | Anthropic Provider 实现 | `llm` |
|
| `provider-anthropic` | Anthropic Provider 实现 | `llm` |
|
||||||
|
| `provider-openai-response` | OpenAI Response API(`POST /responses`)Provider 实现 | `llm` |
|
||||||
| `provider-deepseek` | DeepSeek Provider 实现 | `llm` |
|
| `provider-deepseek` | DeepSeek Provider 实现 | `llm` |
|
||||||
| `provider-qwen` | Qwen Provider 实现 | `llm` |
|
| `provider-qwen` | Qwen Provider 实现 | `llm` |
|
||||||
| `provider-ollama` | Ollama Provider 实现 | `llm` |
|
| `provider-ollama` | Ollama Provider 实现 | `llm` |
|
||||||
|
|||||||
@@ -0,0 +1,790 @@
|
|||||||
|
# Phase 28-30 — OpenAI Response API Provider 实施方案
|
||||||
|
|
||||||
|
> **版本**:v1 | **作者**:Writer Agent | **日期**:2026-07-20
|
||||||
|
>
|
||||||
|
> **阅读前提**:本文档假设读者已熟悉现有的 Provider 实现模式(`AnthropicProvider` 独立实现方式)、IR 类型系统(`MessageRequest` / `MessageResponse` / `ContentBlock` / `StreamEvent` / `LlmProvider trait`)以及 Cargo features 门控机制。
|
||||||
|
>
|
||||||
|
> **前置条件**:v0.3.2(Phase 20-27)已发布,Cargo features 拆分完成,CI 矩阵 6 种组合全部通过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
### 1.1 背景
|
||||||
|
|
||||||
|
OpenAI 于 2025 年下半年发布了 **Response API**(`POST /responses`),作为 Chat Completions API(`POST /chat/completions`)的下一代接口。Response API 不仅提供了更简洁的请求/响应结构,还将 `web_search`、`file_search`、`computer_use` 等内置工具提升为一等公民,并引入了 `previous_response_id` 多轮续写等新机制。
|
||||||
|
|
||||||
|
agcore 当前通过 `GenericOpenaiProvider` 实现了 OpenAI Chat Completions 协议。`ProviderType::OpenaiResponse` 枚举项已在 `src/llm/provider.rs` 中定义,但工厂函数返回 `Err("Phase 1 暂不实现;请使用 OpenaiChat")`。
|
||||||
|
|
||||||
|
### 1.2 目标
|
||||||
|
|
||||||
|
- 实现独立的 `OpenaiResponseProvider`(不套用 `GenericOpenaiProvider`,参考 `AnthropicProvider` 模式)
|
||||||
|
- 覆盖 Response API 的核心能力:文本对话、流式输出、Vision 输入、工具调用(function calling)
|
||||||
|
- 新增独立 feature `provider-openai-response`,加入 `full` 快捷组合
|
||||||
|
- 内置工具(`web_search` / `file_search` / `computer_use`)通过 `MessageRequest.extra` 逃生舱传递
|
||||||
|
- 多轮接续第一版走全量消息历史模式
|
||||||
|
|
||||||
|
### 1.3 范围
|
||||||
|
|
||||||
|
| 维度 | 包含 | 不包含 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 协议端点 | `POST /responses` | `/responses/{id}/input_items` 等管理端点 |
|
||||||
|
| 输入模式 | 全量消息历史 + `previous_response_id` | 增量续写优化 |
|
||||||
|
| 内置工具 | 通过 `extra` 逃生舱透传 | 原生 ToolDef 结构改动 |
|
||||||
|
| 流式 | SSE 语义事件 → `StreamEvent` | — |
|
||||||
|
| 结构化输出 | `text.format` | 暂不专项封装 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 需求分析
|
||||||
|
|
||||||
|
### 2.1 功能需求
|
||||||
|
|
||||||
|
| # | 需求 | 优先级 | 说明 |
|
||||||
|
|---|------|--------|------|
|
||||||
|
| F1 | 文本对话(非流式 + 流式) | P0 | 最基础的对话能力 |
|
||||||
|
| F2 | Vision 图片输入 | P0 | `UserImage` → `input_image` |
|
||||||
|
| F3 | Function Calling 工具调用 | P0 | `ToolDef` → `{type: "function", ...}` |
|
||||||
|
| F4 | 多轮接续 | P1 | 全量消息历史模式 |
|
||||||
|
| F5 | System 消息处理 | P0 | 多个 System 消息拼接到 `instructions` |
|
||||||
|
| F6 | 流式 SSE 事件映射 | P0 | 按 Response API SSE 事件序列映射 |
|
||||||
|
| F7 | 内置工具逃生舱 | P2 | `extra` 字段透传 `web_search` / `file_search` |
|
||||||
|
| F8 | 结构化输出逃生舱 | P2 | `extra` 字段透传 `text.format` |
|
||||||
|
|
||||||
|
### 2.2 非功能需求
|
||||||
|
|
||||||
|
| # | 需求 | 指标 |
|
||||||
|
|---|------|------|
|
||||||
|
| N1 | 编译隔离 | 新增 feature 不增加 `light` / `chat` 组合的依赖 |
|
||||||
|
| N2 | 测试覆盖 | wiremock 覆盖非流式 + 流式 + 错误路径 |
|
||||||
|
| N3 | 错误映射 | 复用 `GenericOpenaiProvider` 的错误映射逻辑 |
|
||||||
|
| N4 | Clippy 合规 | `cargo clippy --all-features --lib -- -D warnings` 通过 |
|
||||||
|
|
||||||
|
### 2.3 与 Chat Completions 的差异回顾
|
||||||
|
|
||||||
|
| 维度 | Chat Completions | Response API |
|
||||||
|
|------|-----------------|--------------|
|
||||||
|
| 端点 | `POST /chat/completions` | `POST /responses` |
|
||||||
|
| 输入 | `messages: [{role, content}]` | `input: string \| items[]` + 顶层 `instructions` |
|
||||||
|
| 输出 | `choices[n].message` | `output: []` 异构 items 数组 |
|
||||||
|
| 内置工具 | 无(仅 function calling) | `web_search` / `file_search` / `computer_use` 一等公民 |
|
||||||
|
| 多轮接续 | 调用方拼接 messages | `previous_response_id` 参数 或 全量回传 |
|
||||||
|
| 流式 | SSE chunk `choices[n].delta` | SSE 语义事件:`response.text.delta` / `response.output_item.added` 等 |
|
||||||
|
| 结构化输出 | `response_format` | `text.format` |
|
||||||
|
| 认证 | `Authorization: Bearer` | 相同 |
|
||||||
|
| 错误结构 | 相同(401/429/500) | 相同 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 方案设计
|
||||||
|
|
||||||
|
### 3.1 设计决策
|
||||||
|
|
||||||
|
| # | 决策 | 选项 | 选择 | 理由 |
|
||||||
|
|---|------|------|------|------|
|
||||||
|
| D1 | 实现方式 | 独立 Provider vs 套用 GenericOpenaiProvider | **独立 Provider** | Response API 请求/响应结构与 Chat Completions 差异过大,序列化/反序列化无共用价值 |
|
||||||
|
| D2 | Feature 粒度 | 合并到 `provider-openai` vs 独立 | **独立 feature** | 与 `AnthropicProvider` 对齐,避免 `full` 组合膨胀 |
|
||||||
|
| D3 | 加入快捷组合 | 加入 `full` 但不加入 `light` | **`full` 包含** | Response API 属于高级能力,`light` 保持轻量 |
|
||||||
|
| D4 | 多轮方案 | 全量历史 vs 增量 | **全量历史(模式 A)** | 功能正确,无需改动 `LlmCycle` |
|
||||||
|
| D5 | 内置工具支持 | 改 ToolDef vs extra 逃生舱 | **extra 逃生舱** | 不改已有 IR 类型,最小侵入 |
|
||||||
|
|
||||||
|
### 3.2 Feature 定义
|
||||||
|
|
||||||
|
```toml
|
||||||
|
provider-openai-response = ["llm", "reqwest", "bytes", "futures-util"]
|
||||||
|
```
|
||||||
|
|
||||||
|
与 `provider-openai` / `provider-anthropic` 的依赖集合一致——`llm` 已包含 `tokio` / `async-stream` / `futures-core` / `futures-util` / `tokio-stream`,此处补充 `reqwest`(HTTP 客户端)和 `bytes`(流式 buffer 操作)。
|
||||||
|
|
||||||
|
`full` 快捷组合追加 `"provider-openai-response"`。
|
||||||
|
|
||||||
|
### 3.3 新增文件
|
||||||
|
|
||||||
|
所有实现集中在单一文件:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/llm/provider/openai_response.rs ← 全部实现(Wire 类型 + Provider 结构体 + 请求转换 + 响应转换 + 流式处理 + 测试)
|
||||||
|
```
|
||||||
|
|
||||||
|
不在 `provider/` 下创建子目录。模块声明在 `src/llm.rs`,在现有 Provider features cfg 条件中追加 `feature = "provider-openai-response"`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(any(
|
||||||
|
feature = "provider-openai",
|
||||||
|
feature = "provider-anthropic",
|
||||||
|
feature = "provider-deepseek",
|
||||||
|
feature = "provider-qwen",
|
||||||
|
feature = "provider-ollama",
|
||||||
|
feature = "provider-openai-response",
|
||||||
|
))]
|
||||||
|
pub mod provider;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 架构概览
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ OpenaiResponseProvider │
|
||||||
|
│ ┌──────────────────────────────────────────┐ │
|
||||||
|
│ │ convert_request() │ │
|
||||||
|
│ │ MessageRequest → OpenaiResponseRequest │ │
|
||||||
|
│ └──────────────────┬───────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────────────▼───────────────────────┐ │
|
||||||
|
│ │ HTTP POST /responses │ │
|
||||||
|
│ │ (reqwest Client) │ │
|
||||||
|
│ └──────────────────┬───────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────────────▼───────────────────────┐ │
|
||||||
|
│ │ convert_response() │ │
|
||||||
|
│ │ OpenaiResponseBody → MessageResponse │ │
|
||||||
|
│ └──────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────────────────────────────────────┐ │
|
||||||
|
│ │ ResponseSseEventStream │ │
|
||||||
|
│ │ SSE bytes → StreamEvent 流 │ │
|
||||||
|
│ └──────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 Wire 类型设计
|
||||||
|
|
||||||
|
#### 请求体类型
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct OpenaiResponseRequest {
|
||||||
|
pub model: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub instructions: Option<String>,
|
||||||
|
pub input: Vec<ResponseInputItem>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tools: Option<Vec<ResponseTool>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_choice: Option<Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_output_tokens: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub temperature: Option<f32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub top_p: Option<f32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub stop: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub stream: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub previous_response_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub store: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub truncation: Option<Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub metadata: Option<Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reasoning: Option<Value>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Input Item 枚举
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub(crate) enum ResponseInputItem {
|
||||||
|
Message {
|
||||||
|
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||||
|
item_type: Option<String>, // 可选,固定为 "message"(assistant 回传时使用)
|
||||||
|
role: String,
|
||||||
|
content: Vec<ResponseInputContent>,
|
||||||
|
},
|
||||||
|
FunctionCall {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
item_type: String, // 固定为 "function_call"
|
||||||
|
call_id: String,
|
||||||
|
name: String,
|
||||||
|
arguments: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
status: Option<String>,
|
||||||
|
},
|
||||||
|
FunctionCallOutput {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
item_type: String, // 固定为 "function_call_output"
|
||||||
|
call_id: String,
|
||||||
|
output: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
> **关于 `ResponseInputItem` 与 `ResponseOutputItem` 的职责划分**:
|
||||||
|
>
|
||||||
|
> - **`ResponseInputItem`**(`#[serde(untagged)]`):仅用于**请求序列化**(`convert_request`),由代码控制枚举变体的生成,永远不会遇到未知的 `item_type`。因此 untagged 模式是安全的,无需 fallback。
|
||||||
|
> - **`ResponseOutputItem`**(非 untagged,`item_type: String` 为必填字段):用于**响应反序列化**(`convert_response`),来自 API 响应。未知的 `item_type` 已通过 §3.7 的 `ContentBlock::Extension` fallback 处理,不会因新增 item 类型而触发 serde 反序列化失败。
|
||||||
|
|
||||||
|
/// 消息内容块(嵌套在 Message 变体的 content 数组中)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub(crate) enum ResponseInputContent {
|
||||||
|
InputText {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
InputImage {
|
||||||
|
image_url: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
detail: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **补充说明**:Response API 的 `input` 字段还支持简化格式——`input: "Hello"`(单字符串)或 `input: ["Hello", "Hi"]`(字符串数组),但这些格式只能表达纯文本消息。为支持多模态内容(文本 + 图片)和工具调用,本实现使用完整的消息对象数组格式。
|
||||||
|
|
||||||
|
#### Tool 类型
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub(crate) enum ResponseTool {
|
||||||
|
Function {
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
parameters: Value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 响应体类型
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct OpenaiResponseBody {
|
||||||
|
pub id: String,
|
||||||
|
pub model: String,
|
||||||
|
pub output: Vec<ResponseOutputItem>,
|
||||||
|
pub usage: Usage,
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct ResponseOutputItem {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub item_type: String,
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub role: Option<String>,
|
||||||
|
pub content: Option<Vec<ResponseContentPart>>,
|
||||||
|
pub call_id: Option<String>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub arguments: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct ResponseContentPart {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub part_type: String,
|
||||||
|
pub text: Option<String>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SSE 事件类型
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub(crate) enum ResponseSseEvent {
|
||||||
|
#[serde(rename = "response.created")]
|
||||||
|
ResponseCreated { response: ResponseSseMeta },
|
||||||
|
#[serde(rename = "response.completed")]
|
||||||
|
ResponseCompleted { response: ResponseSseMeta },
|
||||||
|
#[serde(rename = "response.failed")]
|
||||||
|
ResponseFailed { error: Option<serde_json::Value> },
|
||||||
|
#[serde(rename = "response.output_item.added")]
|
||||||
|
ResponseOutputItemAdded { item: ResponseOutputItem },
|
||||||
|
#[serde(rename = "response.output_item.done")]
|
||||||
|
ResponseOutputItemDone { item: ResponseOutputItem },
|
||||||
|
#[serde(rename = "response.output_text.delta")]
|
||||||
|
ResponseOutputTextDelta { delta: String, item_id: String },
|
||||||
|
#[serde(rename = "response.output_text.done")]
|
||||||
|
ResponseOutputTextDone { text: String, item_id: String },
|
||||||
|
#[serde(rename = "response.refusal.delta")]
|
||||||
|
ResponseRefusalDelta { delta: String, item_id: String },
|
||||||
|
#[serde(rename = "response.refusal.done")]
|
||||||
|
ResponseRefusalDone { refusal: String, item_id: String },
|
||||||
|
#[serde(rename = "response.function_call_arguments.delta")]
|
||||||
|
ResponseFunctionCallArgumentsDelta { delta: String, item_id: String },
|
||||||
|
#[serde(rename = "response.function_call_arguments.done")]
|
||||||
|
ResponseFunctionCallArgumentsDone { arguments: String, item_id: String },
|
||||||
|
#[serde(rename = "error")]
|
||||||
|
Error { code: String, message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct ResponseSseMeta {
|
||||||
|
pub id: String,
|
||||||
|
pub model: String,
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 请求转换(convert_request)
|
||||||
|
|
||||||
|
#### 消息类型映射
|
||||||
|
|
||||||
|
| 输入场景 | Message 类型 | → Response API input item |
|
||||||
|
|---------|-------------|--------------------------|
|
||||||
|
| 文本 User | `Message::User { content: [Text] }` | `{role: "user", content: [{type: "input_text", text}]}` |
|
||||||
|
| Vision | `Message::UserImage { data, mime_type, detail }` | `{role: "user", content: [{type: "input_image", image_url: "data:{mime};base64,{data}", detail}]}` |
|
||||||
|
| User 多模态 | `Message::User { content: [Text, Image, ...] }` | `{role: "user", content: [{type: "input_text", text}, {type: "input_image", image_url, detail}]}` |
|
||||||
|
| Assistant 文本 | `Message::Assistant { content: [Text] }` | User 侧:`{role: "assistant", content: [{type: "output_text", text}]}`(无 `type` 字段);回传时: `{type: "message", role: "assistant", content: [{type: "output_text", text}]}`(有 `type: "message"`) |
|
||||||
|
| Assistant 工具调用 | `Message::Assistant { content: [ToolUse] }` | `FunctionCall { call_id, name, arguments }` |
|
||||||
|
| Assistant 文本+工具 | `Message::Assistant { content: [Text, ToolUse, ...] }` | 一个 `Message(assistant)` + 一个或多个 `FunctionCall` 项 |
|
||||||
|
| 工具结果 | `Message::ToolResult { tool_call_id, content, is_error }` | `FunctionCallOutput { call_id, output: content }` |
|
||||||
|
| System | `Message::System { content }` | 拼接到顶层 `instructions` 字段(非 input) |
|
||||||
|
|
||||||
|
#### 字段映射
|
||||||
|
|
||||||
|
| MessageRequest 字段 | → Response API 字段 |
|
||||||
|
|---------------------|---------------------|
|
||||||
|
| `model` | `model` |
|
||||||
|
| `max_tokens` | `max_output_tokens` |
|
||||||
|
| `temperature` | `temperature` |
|
||||||
|
| `top_p` | `top_p` |
|
||||||
|
| `stop_sequences` | `stop` |
|
||||||
|
| `stream` | `stream` |
|
||||||
|
| `tools` (ToolDef) | `tools` = `[{type: "function", name, description, parameters}]` |
|
||||||
|
| `tool_choice` | `tool_choice` |
|
||||||
|
|
||||||
|
#### extra 字段映射
|
||||||
|
|
||||||
|
| `MessageRequest.extra` key | → Response API 字段 |
|
||||||
|
|---------------------------|---------------------|
|
||||||
|
| `previous_response_id` | `previous_response_id` |
|
||||||
|
| `store` | `store` |
|
||||||
|
| `metadata` | `metadata` |
|
||||||
|
| `truncation` | `truncation` |
|
||||||
|
| `reasoning.effort` | `reasoning: {effort: ...}` |
|
||||||
|
| 内置工具(`web_search` / `file_search` 等) | 追加到 `tools` 数组 |
|
||||||
|
|
||||||
|
> **备注**:当前仅支持 `reasoning.effort` 子字段(值为 `low`/`medium`/`high`),其他子字段(如 `reasoning.summary`)将在后续版本支持。
|
||||||
|
|
||||||
|
### 3.7 响应转换(convert_response)
|
||||||
|
|
||||||
|
| Response API output item | → MessageResponse 中的表示 |
|
||||||
|
|-------------------------|------------------------------|
|
||||||
|
| `{type: "message", role: "assistant", content: [{type: "output_text", text}]}` | `Message::Assistant { content: [ContentBlock::Text { text }] }` |
|
||||||
|
| `{type: "function_call", name, arguments, call_id}` | `ContentBlock::ToolUse { id: call_id, name, input: arguments }` |
|
||||||
|
| `{type: "web_search_call", ...}` | `ContentBlock::Extension { kind: "web_search_call", data: ... }` |
|
||||||
|
| `{type: "reasoning", ...}` | `ContentBlock::Extension { kind: "reasoning", data: ... }` |
|
||||||
|
| `{type: "file_search_call", ...}` | `ContentBlock::Extension { kind: "file_search_call", data: ... }` |
|
||||||
|
|
||||||
|
**status → StopReason 映射**:
|
||||||
|
- `completed` → `StopReason::Stop`
|
||||||
|
- `incomplete` → `StopReason::Length`
|
||||||
|
- `failed` → `StopReason::Other`
|
||||||
|
|
||||||
|
当 `response.output` 为空数组时,返回 `LlmError::Request { status: 200, body: "empty output" }`,表示响应格式异常。
|
||||||
|
|
||||||
|
对于未知的 `item_type`(非 `message`/`function_call`/`web_search_call`/`file_search_call`/`reasoning`),转换为 `ContentBlock::Extension { kind: item_type, data: serde_json::to_value(item)? }` 以保持前向兼容。
|
||||||
|
|
||||||
|
### 3.8 流式 SSE 事件映射
|
||||||
|
|
||||||
|
| Response API SSE event | → StreamEvent |
|
||||||
|
|------------------------|---------------|
|
||||||
|
| `response.created` | `MessageStart { id, model }` |
|
||||||
|
| `response.output_item.added` (type: message) | `ContentBlockStart { index, block_type: Text }` |
|
||||||
|
| `response.output_text.delta` | `TextDelta { text }` |
|
||||||
|
| `response.output_text.done` | `ContentBlockEnd { index }` |
|
||||||
|
| `response.refusal.delta` | `RefusalDelta { text }` |
|
||||||
|
| `response.refusal.done` | `ContentBlockEnd { index }` |
|
||||||
|
| `response.function_call_arguments.delta` | `ToolCallArgumentsDelta { index, arguments }` |
|
||||||
|
| `response.function_call_arguments.done` | `ToolCallEnd { index }` |
|
||||||
|
| `response.completed` | `MessageComplete { full_response }` |
|
||||||
|
| `response.failed` | `Error { message }` |
|
||||||
|
|
||||||
|
### 3.9 流式 SSE 状态机
|
||||||
|
|
||||||
|
`ResponseSseEventStream` 维护以下状态:
|
||||||
|
|
||||||
|
```
|
||||||
|
字段:
|
||||||
|
- byte_stream: reqwest 的 bytes_stream
|
||||||
|
- buffer: Vec<u8>(SSE 行缓冲)
|
||||||
|
- partial: PartialMessageResponse(累积响应状态)
|
||||||
|
- block_index: u32(输出 block 序号计数器)
|
||||||
|
- saw_terminal: bool(是否已见到 response.completed / response.failed)
|
||||||
|
|
||||||
|
流程:
|
||||||
|
line 级解析 → event: + data: 配对
|
||||||
|
→ 反序列化 ResponseSseEvent
|
||||||
|
→ try_into_stream_event() 映射为 StreamEvent
|
||||||
|
→ StreamEvent::apply_to(&mut partial)
|
||||||
|
→ yield StreamEvent
|
||||||
|
response.completed → partial.finalize() → yield MessageComplete
|
||||||
|
response.failed → yield Error
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.10 错误映射
|
||||||
|
|
||||||
|
复用 `GenericOpenaiProvider` 的 `handle_error_response()` 逻辑:
|
||||||
|
|
||||||
|
| HTTP 状态码 | → LlmError |
|
||||||
|
|------------|------------|
|
||||||
|
| 401 | `LlmError::Authentication(body)` |
|
||||||
|
| 429 | `LlmError::RateLimit { retry_after }` |
|
||||||
|
| 5xx | `LlmError::Request { status, body }` |
|
||||||
|
| 400 + `context_length_exceeded` | `LlmError::ContextLength` |
|
||||||
|
|
||||||
|
### 3.11 Provider 结构体
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub(crate) struct OpenaiResponseProvider {
|
||||||
|
http_client: Client,
|
||||||
|
base_url: String,
|
||||||
|
api_key: String,
|
||||||
|
model: String,
|
||||||
|
timeout_secs: u64,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 工厂方法
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl OpenaiResponseProvider {
|
||||||
|
pub(crate) fn from_parts(
|
||||||
|
base_url: String,
|
||||||
|
api_key: String,
|
||||||
|
model: String,
|
||||||
|
http_client: Client,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Self {
|
||||||
|
Self { http_client, base_url, api_key, model, timeout_secs }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.12 LlmProvider trait 实现
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[async_trait]
|
||||||
|
impl LlmProvider for OpenaiResponseProvider {
|
||||||
|
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError> {
|
||||||
|
self.chat_blocking(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_stream(
|
||||||
|
&self,
|
||||||
|
request: MessageRequest,
|
||||||
|
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
|
||||||
|
self.chat_stream_inner(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capabilities(&self) -> ProviderCapabilities { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.13 Capabilities
|
||||||
|
|
||||||
|
```rust
|
||||||
|
ProviderCapabilities {
|
||||||
|
provider_name: "openai-response",
|
||||||
|
supported_models: Some(vec![model]),
|
||||||
|
features: ProviderFeatures {
|
||||||
|
streaming: true,
|
||||||
|
thinking: true, // o-series reasoning
|
||||||
|
vision: true, // image input
|
||||||
|
audio_input: false,
|
||||||
|
tool_use: true,
|
||||||
|
parallel_tool_calls: true,
|
||||||
|
system_prompt_in_messages: false,
|
||||||
|
max_context_window: 200_000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.14 工厂函数注册
|
||||||
|
|
||||||
|
```rust
|
||||||
|
ProviderType::OpenaiResponse => {
|
||||||
|
let client = build_client_with_timeout(config.timeout_secs)?;
|
||||||
|
Ok(Box::new(openai_response::OpenaiResponseProvider::from_parts(
|
||||||
|
config.base_url,
|
||||||
|
config.api_key,
|
||||||
|
config.model,
|
||||||
|
client,
|
||||||
|
config.timeout_secs,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.15 多轮接续方案
|
||||||
|
|
||||||
|
第一版走**全量消息历史模式(模式 A)**:
|
||||||
|
|
||||||
|
1. `convert_request()` 把 `MessageRequest.messages` 全部转换为 `input` items
|
||||||
|
2. System 消息拼接到 `instructions`
|
||||||
|
3. User / Assistant / ToolResult 消息转换为对应的 input items
|
||||||
|
4. 如果 `extra` 中有 `previous_response_id`,也传入请求体
|
||||||
|
|
||||||
|
此模式与 `LlmCycle::submit_with_tools()` 完全兼容——`LlmCycle` 在每次提交时都会填充完整的历史 messages,`OpenaiResponseProvider` 只是把这些 messages 全部序列化为 Response API 格式。无需改动 `LlmCycle`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 实施计划
|
||||||
|
|
||||||
|
实施拆分为 3 个 Phase,9 个 Step。
|
||||||
|
|
||||||
|
### Phase 28:Feature gate + Wire 类型 + Provider 骨架(~140 行)
|
||||||
|
|
||||||
|
#### Step 28.1:Cargo.toml feature 定义
|
||||||
|
**文件操作**:修改 `Cargo.toml`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# 在 [features] 的 Provider features 区域追加
|
||||||
|
provider-openai-response = ["llm", "reqwest", "bytes", "futures-util"]
|
||||||
|
|
||||||
|
# 在 full 快捷组合中追加
|
||||||
|
full = [
|
||||||
|
"...",
|
||||||
|
"provider-openai-response",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**验证**:`cargo build --features "provider-openai-response"` 编译通过
|
||||||
|
|
||||||
|
#### Step 28.2:Wire 类型定义
|
||||||
|
**文件操作**:新建 `src/llm/provider/openai_response.rs`
|
||||||
|
|
||||||
|
定义 §3.5 中的所有 Wire 类型:
|
||||||
|
- `OpenaiResponseRequest`
|
||||||
|
- `ResponseInputItem`(untagged 枚举:Message / FunctionCall / FunctionCallOutput)
|
||||||
|
- `ResponseInputContent`(tagged 枚举:InputText / InputImage)
|
||||||
|
- `ResponseTool`
|
||||||
|
- `OpenaiResponseBody`
|
||||||
|
- `ResponseOutputItem`
|
||||||
|
- `ResponseContentPart`
|
||||||
|
- `ResponseSseEvent`(完整时序事件枚举)
|
||||||
|
- `ResponseSseMeta`
|
||||||
|
|
||||||
|
**无逻辑代码**,只有 `#[derive(Debug, Clone, Serialize, Deserialize)]` 的结构体和枚举。
|
||||||
|
|
||||||
|
**验证**:`cargo build --features "provider-openai-response"` 编译通过
|
||||||
|
|
||||||
|
#### Step 28.3:Provider 结构体 + from_parts
|
||||||
|
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
|
||||||
|
|
||||||
|
- `OpenaiResponseProvider` 结构体
|
||||||
|
- `from_parts()` 工厂方法
|
||||||
|
- 基础 HTTP 工具函数(`build_request_builder`、`handle_error_response`、`map_reqwest_error`)
|
||||||
|
|
||||||
|
**验证**:`cargo build --features "provider-openai-response"` 编译通过
|
||||||
|
|
||||||
|
#### Step 28.4:Factory 注册 + 模块门控
|
||||||
|
**文件操作**:
|
||||||
|
1. 修改 `src/llm.rs` — 在 cfg 条件中追加 `feature = "provider-openai-response"`
|
||||||
|
2. 修改 `src/llm/provider.rs` — 注册 factory
|
||||||
|
|
||||||
|
在 `src/llm.rs` 中修改现有 Provider features cfg 条件:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(any(
|
||||||
|
feature = "provider-openai",
|
||||||
|
feature = "provider-anthropic",
|
||||||
|
feature = "provider-deepseek",
|
||||||
|
feature = "provider-qwen",
|
||||||
|
feature = "provider-ollama",
|
||||||
|
feature = "provider-openai-response",
|
||||||
|
))]
|
||||||
|
pub mod provider;
|
||||||
|
```
|
||||||
|
|
||||||
|
以及在 `src/llm/provider.rs` 的 `create_provider()` match 中替换当前 `Err` 为真实构造。
|
||||||
|
|
||||||
|
**验证**:
|
||||||
|
- `cargo build --features "provider-openai-response"` 编译通过
|
||||||
|
- `cargo build --features "full"` 编译通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 29:核心 Provider 实现(~680 行)
|
||||||
|
|
||||||
|
#### Step 29.1:convert_request(~200 行)
|
||||||
|
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
|
||||||
|
|
||||||
|
实现 `OpenaiResponseProvider::convert_request(&self, request: MessageRequest) -> Result<OpenaiResponseRequest, LlmError>`。
|
||||||
|
|
||||||
|
处理逻辑:
|
||||||
|
1. 遍历 `request.messages`,按 §3.6 消息类型映射表转换
|
||||||
|
2. Assistant 消息回传时设置 `item_type: Some("message".to_string())`,使序列化结果为 `{type: "message", role: "assistant", content: [...]}`;User 消息保持 `item_type: None`,序列化为 `{role: "user", content: [...]}`(无 `type` 字段)
|
||||||
|
3. `request.tools` → `tools` 数组(`ToolDef` → `ResponseTool::Function`)
|
||||||
|
4. `request.extra` → 解析 `previous_response_id` / `store` / `metadata` / `truncation` / `reasoning` 等
|
||||||
|
5. 标准字段映射(model / max_tokens / temperature / top_p / stop / stream)
|
||||||
|
|
||||||
|
#### Step 29.2:convert_response(~100 行)
|
||||||
|
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
|
||||||
|
|
||||||
|
实现 `OpenaiResponseProvider::convert_response(&self, response: OpenaiResponseBody) -> Result<MessageResponse, LlmError>`。
|
||||||
|
|
||||||
|
处理逻辑:
|
||||||
|
1. 遍历 `response.output`,找到第一个 `type: "message"` 的 item,提取 text
|
||||||
|
2. 其他 items(`function_call` → `ContentBlock::ToolUse`,内置工具 → `ContentBlock::Extension`)
|
||||||
|
3. `response.status` → `StopReason`
|
||||||
|
4. `response.usage` → `Usage`
|
||||||
|
|
||||||
|
#### Step 29.3:非流式 chat()(~80 行)
|
||||||
|
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
|
||||||
|
|
||||||
|
实现 `OpenaiResponseProvider::chat_blocking()`:
|
||||||
|
- `convert_request()` → serde 序列化 → HTTP POST `{base_url}/responses`
|
||||||
|
- Auth header: `Authorization: Bearer {api_key}`
|
||||||
|
- 错误处理映射
|
||||||
|
- 解析响应体 → `convert_response()`
|
||||||
|
|
||||||
|
#### Step 29.4:SSE 事件类型 + 状态机(~230 行)
|
||||||
|
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
|
||||||
|
|
||||||
|
实现 `ResponseSseEventStream` 结构体及其 `Stream` trait:
|
||||||
|
- 字段:`byte_stream`, `buffer`, `partial: PartialMessageResponse`, `block_index: u32`, `saw_terminal: bool`
|
||||||
|
- 行级 SSE 解析:`event:` + `data:` 配对
|
||||||
|
- 事件 → `StreamEvent` 映射
|
||||||
|
- `PartialMessageResponse::apply_to()` 累积
|
||||||
|
- 流结束时 `finalize()` → `MessageComplete`
|
||||||
|
|
||||||
|
#### Step 29.5:流式 chat_stream()(~50 行)
|
||||||
|
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
|
||||||
|
|
||||||
|
实现 `OpenaiResponseProvider::chat_stream_inner()`:
|
||||||
|
- `convert_request()` 设置 `stream: true`
|
||||||
|
- HTTP POST → bytes_stream → 包装为 `ResponseSseEventStream`
|
||||||
|
|
||||||
|
#### Step 29.6:LlmProvider impl(~50 行)
|
||||||
|
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
|
||||||
|
|
||||||
|
实现 `LlmProvider for OpenaiResponseProvider`:
|
||||||
|
- `chat()` → `chat_blocking()`
|
||||||
|
- `chat_stream()` → `chat_stream_inner()`
|
||||||
|
- `capabilities()` → 返回 `ProviderCapabilities`
|
||||||
|
|
||||||
|
#### Step 29.7:单元测试(~70 行)
|
||||||
|
**文件操作**:追加到 `src/llm/provider/openai_response.rs` 的 `#[cfg(test)] mod tests {}`
|
||||||
|
|
||||||
|
| 测试 | 场景 |
|
||||||
|
|------|------|
|
||||||
|
| `convert_request_text_only` | 纯文本输入转换 |
|
||||||
|
| `convert_request_vision` | Vision 输入转换 |
|
||||||
|
| `convert_request_tool_call` | 工具调用输入转换 |
|
||||||
|
| `convert_response_message` | 响应 message item 转换 |
|
||||||
|
| `convert_response_tool_use` | 响应 function_call item 转换 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 30:测试 + CI + 文档(~520 行)
|
||||||
|
|
||||||
|
#### Step 30.1:wiremock 非流式测试(~200 行)
|
||||||
|
**文件操作**:追加到 `src/llm/provider/openai_response.rs` 内联测试
|
||||||
|
|
||||||
|
| 测试 | 场景 | 验证 |
|
||||||
|
|------|------|------|
|
||||||
|
| `response_api_basic_text` | 纯文本响应 | `response.text()` 正确 |
|
||||||
|
| `response_api_tool_call` | 工具调用 | `stop_reason == ToolUse` |
|
||||||
|
| `response_api_multi_turn` | 两轮对话 | 第二轮携带历史 |
|
||||||
|
| `response_api_vision` | 图片输入 | 正确构造 `input_image` |
|
||||||
|
| `response_api_unauthorized` | 401 错误 | `LlmError::Authentication` |
|
||||||
|
| `response_api_rate_limit` | 429 错误 | `LlmError::RateLimit` |
|
||||||
|
| `response_api_server_error` | 500 错误 | `LlmError::Request` |
|
||||||
|
|
||||||
|
#### Step 30.2:wiremock 流式测试(~200 行)
|
||||||
|
**文件操作**:追加到 `src/llm/provider/openai_response.rs` 内联测试
|
||||||
|
|
||||||
|
| 测试 | 场景 | 验证 |
|
||||||
|
|------|------|------|
|
||||||
|
| `response_api_stream_text` | 流式文本 | 完整 SSE 事件序列 |
|
||||||
|
| `response_api_stream_tool` | 流式工具调用 | `FunctionCallArgumentsDelta` 序列 |
|
||||||
|
| `response_api_stream_error` | 流中途失败 | `StreamEvent::Error` |
|
||||||
|
| `response_api_stream_multi_turn` | 流式多轮接续 | 第二轮携带历史消息时的完整 SSE 事件序列 |
|
||||||
|
|
||||||
|
#### Step 30.3:CI 矩阵(~10 行)
|
||||||
|
**文件操作**:修改 `.github/workflows/ci.yml`
|
||||||
|
|
||||||
|
新增测试组合:
|
||||||
|
```yaml
|
||||||
|
- "chat,provider-openai,provider-openai-response"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 30.4:文档更新(~50 行)
|
||||||
|
**文件操作**:修改 `README.md` + `docs/roadmap.md`
|
||||||
|
|
||||||
|
- README feature 表新增 `provider-openai-response`
|
||||||
|
- `docs/roadmap.md` 或 `docs/roadmap-unsorted.md` 新增 v0.3.3 或下版本条目
|
||||||
|
|
||||||
|
#### Step 30.5:Example(~60 行)
|
||||||
|
**文件操作**:新建 `examples/response_api_demo.rs`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[example]]
|
||||||
|
name = "response_api_demo"
|
||||||
|
required-features = ["llm", "provider-openai-response"]
|
||||||
|
```
|
||||||
|
|
||||||
|
基础对话示例,展示 Response API 的基本用法:
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo run --example response_api_demo --features "full"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 实施汇总
|
||||||
|
|
||||||
|
| Phase | 内容 | 代码行数估算 | 验证入口 |
|
||||||
|
|-------|------|------------|---------|
|
||||||
|
| 28 | Feature gate + Wire 类型 + Provider 骨架 | ~140 | `cargo build --features "provider-openai-response"` |
|
||||||
|
| 29 | 核心 Provider 实现(转换/HTTP/流式) | ~680 | 5 个单元测试 |
|
||||||
|
| 30 | 测试 + CI + 文档 | ~520 | 10 个 wiremock 测试 + CI 新组合 |
|
||||||
|
| **合计** | | **~1,340** | 全量 `cargo test --features "full"` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 风险评估
|
||||||
|
|
||||||
|
| ID | 风险 | 影响 | 概率 | 缓解措施 |
|
||||||
|
|----|------|------|------|---------|
|
||||||
|
| R1 | Response API 协议快速迭代 | Wire 类型可能需更新 | 中 | Wire 类型集中在单个文件内,更新成本低 |
|
||||||
|
| R2 | `ContentBlock::Extension` 承载内置工具结果 | 下游消费方需适配 | 低 | 这是既有的逃生舱机制,已有消费模式 |
|
||||||
|
| R3 | 全量历史模式 token 开销 | 多轮时 input tokens 增长 | 低 | 功能正确,后续版本可优化为 `previous_response_id` 增量模式 |
|
||||||
|
| R4 | `instructions` 拼接多个 system 消息 | 语义可能与单 system 消息不同 | 低 | 已确认按 OpenAI 推荐方式全量拼接(`\n` 分隔),行为等价 |
|
||||||
|
| R5 | 与 `GenericOpenaiProvider` 的错误映射逻辑重复 | 维护两份相似逻辑 | 低 | 提取复用函数时需注意不影响现有 provider |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 验证标准
|
||||||
|
|
||||||
|
### 6.1 编译验证
|
||||||
|
|
||||||
|
| # | 检查项 | 命令 |
|
||||||
|
|---|--------|------|
|
||||||
|
| C1 | 独立 feature 编译 | `cargo build --features "provider-openai-response"` |
|
||||||
|
| C2 | full 组合编译 | `cargo build --features "full"` |
|
||||||
|
| C3 | light 组合不受影响 | `cargo build --features "light"`(不包含新 feature) |
|
||||||
|
| C4 | Clippy 合规 | `cargo clippy --all-features --lib -- -D warnings` |
|
||||||
|
|
||||||
|
### 6.2 测试验证
|
||||||
|
|
||||||
|
| # | 检查项 | 通过条件 |
|
||||||
|
|---|--------|---------|
|
||||||
|
| T1 | 单元测试 | `cargo test --features "full"` 全部通过(+15 新增测试) |
|
||||||
|
| T2 | 非流式 wiremock | 7 个测试覆盖文本/工具/多轮/Vision/401/429/500 |
|
||||||
|
| T3 | 流式 wiremock | 3 个测试覆盖文本流/工具流/错误流 |
|
||||||
|
| T4 | 现有测试无回归 | 使用 `--features "full"` 时已有 427 测试全部通过 |
|
||||||
|
|
||||||
|
### 6.3 CI 验证
|
||||||
|
|
||||||
|
| # | 检查项 | 通过条件 |
|
||||||
|
|---|--------|---------|
|
||||||
|
| I1 | 新增 CI 组合 | 包含新 feature 的组合编译通过 |
|
||||||
|
| I2 | clippy + format | `cargo clippy` + `cargo fmt --check` 通过 |
|
||||||
|
|
||||||
|
### 6.4 Example 验证
|
||||||
|
|
||||||
|
| # | 检查项 | 通过条件 |
|
||||||
|
|---|--------|---------|
|
||||||
|
| E1 | Example 编译 | `cargo build --example response_api_demo --features "full"` 通过 |
|
||||||
|
| E2 | Example 运行 | `cargo run --example response_api_demo --features "full"` 可执行(需 API key) |
|
||||||
@@ -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
|
||||||
|
↑ 后者覆盖前者
|
||||||
|
```
|
||||||
|
|
||||||
|
### 改动一: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` 枚举
|
||||||
|
- 不新增任何平台相关代码
|
||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
# AG Core Roadmap
|
# AG Core Roadmap
|
||||||
|
|
||||||
> 拆分式 roadmap:按版本归档 + 未归类内容
|
> 拆分式 roadmap:按版本归档 + 未归类内容
|
||||||
> 最后更新:2026-07-19(v0.3.2 Step 3 完成 — Phase 26-27 CI 固化 + 文档更新交付,427 测试通过)
|
> 最后更新:2026-07-20(Phase 28-30 OpenAI Response API Provider 交付 — 新增独立 feature `provider-openai-response`,450 测试通过)
|
||||||
|
|
||||||
## 文件索引
|
## 文件索引
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
| [`roadmap-v0.2.0.md`](./roadmap-v0.2.0.md) | v0.2.0 计划与交付 — Phase 5–12 + v0.2.0-rc.1 | 🟡 Phase 5-11 已完成;Phase 12 P2 锦上添花可选 |
|
| [`roadmap-v0.2.0.md`](./roadmap-v0.2.0.md) | v0.2.0 计划与交付 — Phase 5–12 + v0.2.0-rc.1 | 🟡 Phase 5-11 已完成;Phase 12 P2 锦上添花可选 |
|
||||||
| [`roadmap-v0.3.0.md`](./roadmap-v0.3.0.md) | v0.3.0 计划与交付 - Phase 13–19 | ✅ Phase 13-19 全部完成,v0.3.0 交付完毕 |
|
| [`roadmap-v0.3.0.md`](./roadmap-v0.3.0.md) | v0.3.0 计划与交付 - Phase 13–19 | ✅ Phase 13-19 全部完成,v0.3.0 交付完毕 |
|
||||||
| [`roadmap-v0.3.2.md`](./roadmap-v0.3.2.md) | v0.3.2 计划与交付 — Phase 20–27(Cargo features 拆分) | ✅ Phase 20-27 全部完成,v0.3.2 交付完毕 |
|
| [`roadmap-v0.3.2.md`](./roadmap-v0.3.2.md) | v0.3.2 计划与交付 — Phase 20–27(Cargo features 拆分) | ✅ Phase 20-27 全部完成,v0.3.2 交付完毕 |
|
||||||
|
| [`28-phase28-openai-response-api-provider.md`](./28-phase28-openai-response-api-provider.md) | Phase 28-30 OpenAI Response API Provider 实施方案(独立 feature `provider-openai-response`) | ✅ Phase 28-30 已交付 |
|
||||||
| [`roadmap-unsorted.md`](./roadmap-unsorted.md) | 未归到任何版本的内容 — 全局愿景、当前状态、模块完整性、v0.4+ 展望、风险与建议、下一步行动、阶段总回顾 | — |
|
| [`roadmap-unsorted.md`](./roadmap-unsorted.md) | 未归到任何版本的内容 — 全局愿景、当前状态、模块完整性、v0.4+ 展望、风险与建议、下一步行动、阶段总回顾 | — |
|
||||||
|
|
||||||
## 阅读建议
|
## 阅读建议
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
//! Required features: cargo run --example response_api_demo --features "full"
|
||||||
|
//!
|
||||||
|
//! 演示 OpenAI Response API(`POST /responses`)的基本用法。
|
||||||
|
//!
|
||||||
|
//! 环境变量:
|
||||||
|
//! - `OPENAI_BASE_URL` — 默认 `https://api.openai.com/v1`
|
||||||
|
//! - `OPENAI_API_KEY` — 必填
|
||||||
|
//! - `OPENAI_MODEL` — 默认 `gpt-4o-mini`
|
||||||
|
//!
|
||||||
|
//! 本示例展示:
|
||||||
|
//! - 单轮对话 + 多轮接续(全量消息历史)
|
||||||
|
//! - 流式响应事件消费
|
||||||
|
//! - 工具调用(Function Calling)单轮演示
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
use agcore::llm::provider::{ProviderConfig, ProviderType, create_provider};
|
||||||
|
use agcore::llm::types::message::{ContentBlock, Message};
|
||||||
|
use agcore::llm::types::request_v2::MessageRequest;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let api_key = env::var("OPENAI_API_KEY").expect("未设置 OPENAI_API_KEY 环境变量");
|
||||||
|
let base_url =
|
||||||
|
env::var("OPENAI_BASE_URL").unwrap_or_else(|_| "https://api.openai.com/v1".to_string());
|
||||||
|
let model = env::var("OPENAI_MODEL").unwrap_or_else(|_| "gpt-4o-mini".to_string());
|
||||||
|
|
||||||
|
let provider = create_provider(
|
||||||
|
ProviderType::OpenaiResponse,
|
||||||
|
ProviderConfig {
|
||||||
|
base_url,
|
||||||
|
api_key,
|
||||||
|
model,
|
||||||
|
timeout_secs: 30,
|
||||||
|
max_retries: 3,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("创建 OpenAI Response Provider 失败");
|
||||||
|
|
||||||
|
// ===== 单轮对话 =====
|
||||||
|
let request = MessageRequest {
|
||||||
|
model: String::new(),
|
||||||
|
messages: vec![
|
||||||
|
Message::system("你是一个简洁的助手,对任何问题都用一句话回答。"),
|
||||||
|
Message::user_text("Rust 的所有权机制是什么?"),
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
match provider.chat(request).await {
|
||||||
|
Ok(resp) => {
|
||||||
|
println!("[单轮] {}", resp.text());
|
||||||
|
println!(
|
||||||
|
"用量: {} 输入 / {} 输出\n",
|
||||||
|
resp.usage.prompt_tokens, resp.usage.completion_tokens
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[单轮] 请求失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 多轮接续(全量历史) =====
|
||||||
|
let request = MessageRequest {
|
||||||
|
model: String::new(),
|
||||||
|
messages: vec![
|
||||||
|
Message::user_text("knock knock."),
|
||||||
|
Message::Assistant {
|
||||||
|
content: vec![ContentBlock::Text {
|
||||||
|
text: "Who's there?".into(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
Message::user_text("Orange."),
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
match provider.chat(request).await {
|
||||||
|
Ok(resp) => println!("[多轮] {}", resp.text()),
|
||||||
|
Err(e) => eprintln!("[多轮] 请求失败: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -22,7 +22,8 @@ pub mod types;
|
|||||||
feature = "provider-anthropic",
|
feature = "provider-anthropic",
|
||||||
feature = "provider-deepseek",
|
feature = "provider-deepseek",
|
||||||
feature = "provider-qwen",
|
feature = "provider-qwen",
|
||||||
feature = "provider-ollama"
|
feature = "provider-ollama",
|
||||||
|
feature = "provider-openai-response"
|
||||||
))]
|
))]
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
/// Provider 抽象接口(trait + 能力元数据),仅依赖 `llm` feature,不引入 reqwest。
|
/// Provider 抽象接口(trait + 能力元数据),仅依赖 `llm` feature,不引入 reqwest。
|
||||||
|
|||||||
+19
-1
@@ -2,6 +2,8 @@ pub mod anthropic;
|
|||||||
pub mod ollama;
|
pub mod ollama;
|
||||||
pub mod openai;
|
pub mod openai;
|
||||||
pub mod openai_compat;
|
pub mod openai_compat;
|
||||||
|
#[cfg(feature = "provider-openai-response")]
|
||||||
|
pub mod openai_response;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -191,8 +193,23 @@ pub fn create_provider(
|
|||||||
),
|
),
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
#[cfg(feature = "provider-openai-response")]
|
||||||
|
ProviderType::OpenaiResponse => {
|
||||||
|
let client = build_client_with_timeout(config.timeout_secs)?;
|
||||||
|
Ok(Box::new(
|
||||||
|
openai_response::OpenaiResponseProvider::from_parts(
|
||||||
|
config.base_url,
|
||||||
|
config.api_key,
|
||||||
|
config.model,
|
||||||
|
client,
|
||||||
|
config.timeout_secs,
|
||||||
|
Vec::new(),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "provider-openai-response"))]
|
||||||
ProviderType::OpenaiResponse => Err(LlmError::Other(
|
ProviderType::OpenaiResponse => Err(LlmError::Other(
|
||||||
"OpenaiResponse Provider 在 Phase 1 暂不实现;请使用 OpenaiChat".into(),
|
"OpenaiResponse Provider 未编译:启用 `provider-openai-response` feature".into(),
|
||||||
)),
|
)),
|
||||||
ProviderType::Anthropic => {
|
ProviderType::Anthropic => {
|
||||||
let client = build_anthropic_client(&config.api_key, config.timeout_secs)?;
|
let client = build_anthropic_client(&config.api_key, config.timeout_secs)?;
|
||||||
@@ -202,6 +219,7 @@ pub fn create_provider(
|
|||||||
config.model,
|
config.model,
|
||||||
client,
|
client,
|
||||||
config.timeout_secs,
|
config.timeout_secs,
|
||||||
|
Vec::new(),
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
ProviderType::DeepSeek => {
|
ProviderType::DeepSeek => {
|
||||||
|
|||||||
+328
-10
@@ -4,6 +4,7 @@
|
|||||||
//! → `content_block_stop` → `message_delta` → `message_stop`。与 OpenAI 不同,
|
//! → `content_block_stop` → `message_delta` → `message_stop`。与 OpenAI 不同,
|
||||||
//! Anthropic 提供显式 block 边界事件,状态机相对简单。
|
//! Anthropic 提供显式 block 边界事件,状态机相对简单。
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::task::{Context, Poll};
|
use std::task::{Context, Poll};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -13,7 +14,7 @@ use bytes::Bytes;
|
|||||||
use futures_core::Stream;
|
use futures_core::Stream;
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use reqwest::header::{HeaderMap, HeaderValue};
|
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
@@ -43,6 +44,8 @@ pub struct AnthropicProvider {
|
|||||||
/// 在 `LlmError::Timeout { duration }` 中回显。`reqwest::Client` 不暴露 timeout getter,
|
/// 在 `LlmError::Timeout { duration }` 中回显。`reqwest::Client` 不暴露 timeout getter,
|
||||||
/// 因此单独存储以便错误消息与配置保持一致。
|
/// 因此单独存储以便错误消息与配置保持一致。
|
||||||
timeout_secs: u64,
|
timeout_secs: u64,
|
||||||
|
/// Provider 级别固定请求头(如平台标识头),所有请求自动携带。
|
||||||
|
extra_headers: Vec<(String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AnthropicProvider {
|
impl AnthropicProvider {
|
||||||
@@ -72,6 +75,7 @@ impl AnthropicProvider {
|
|||||||
api_key,
|
api_key,
|
||||||
model,
|
model,
|
||||||
timeout_secs,
|
timeout_secs,
|
||||||
|
extra_headers: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +135,7 @@ impl AnthropicProvider {
|
|||||||
model: String,
|
model: String,
|
||||||
http_client: Client,
|
http_client: Client,
|
||||||
timeout_secs: u64,
|
timeout_secs: u64,
|
||||||
|
extra_headers: Vec<(String, String)>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
http_client,
|
http_client,
|
||||||
@@ -142,9 +147,17 @@ impl AnthropicProvider {
|
|||||||
api_key,
|
api_key,
|
||||||
model,
|
model,
|
||||||
timeout_secs,
|
timeout_secs,
|
||||||
|
extra_headers,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 设置 Provider 级别固定头,替换已有的 extra_headers(如有)。
|
||||||
|
/// 返回 self 以支持链式调用。如需追加语义,在外部自行 `extend`。
|
||||||
|
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
|
||||||
|
self.extra_headers = headers;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_max_tokens(&self, request: &MessageRequest) -> u32 {
|
fn resolve_max_tokens(&self, request: &MessageRequest) -> u32 {
|
||||||
request.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS)
|
request.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS)
|
||||||
}
|
}
|
||||||
@@ -214,6 +227,10 @@ impl AnthropicProvider {
|
|||||||
|
|
||||||
let max_tokens = self.resolve_max_tokens(&request);
|
let max_tokens = self.resolve_max_tokens(&request);
|
||||||
|
|
||||||
|
// ponytail: 提前抽取 custom_headers,避免后续 into_iter 消耗 request.tools 后借用失败。
|
||||||
|
let custom_headers: HashMap<String, String> =
|
||||||
|
request.get_extra_opt("custom_headers").unwrap_or_default();
|
||||||
|
|
||||||
let tools = if request.tools.is_empty() {
|
let tools = if request.tools.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -251,19 +268,54 @@ impl AnthropicProvider {
|
|||||||
tools,
|
tools,
|
||||||
thinking,
|
thinking,
|
||||||
stream: if request.stream { Some(true) } else { None },
|
stream: if request.stream { Some(true) } else { None },
|
||||||
|
custom_headers,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 构造 HTTP POST 请求 builder(含认证头 + 自定义头)。
|
||||||
|
/// 认证头(x-api-key / anthropic-version)已由 Client 的 default_headers 提供。
|
||||||
|
///
|
||||||
|
/// 头融合顺序:认证头(default_headers)→ Provider 级 extra_headers → 请求级 custom_headers
|
||||||
|
/// 后者覆盖前者。非法 header 名/值(如控制字符)静默跳过 + warn,避免 reqwest panic。
|
||||||
|
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 {
|
||||||
|
if let (Ok(name), Ok(value)) = (
|
||||||
|
HeaderName::from_bytes(k.as_bytes()),
|
||||||
|
HeaderValue::from_str(v),
|
||||||
|
) {
|
||||||
|
builder = builder.header(name, value);
|
||||||
|
} else {
|
||||||
|
warn!(header = %k, "skipping invalid extra_header (key or value contains illegal characters)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (key, value) in &body.custom_headers {
|
||||||
|
if let (Ok(name), Ok(value)) = (
|
||||||
|
HeaderName::from_bytes(key.as_bytes()),
|
||||||
|
HeaderValue::from_str(value),
|
||||||
|
) {
|
||||||
|
builder = builder.header(name, value);
|
||||||
|
} else {
|
||||||
|
warn!(header = %key, "skipping invalid custom_header (key or value contains illegal characters)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(builder)
|
||||||
|
}
|
||||||
|
|
||||||
async fn chat_blocking(&self, request: MessageRequest) -> Result<MessageResponse, LlmError> {
|
async fn chat_blocking(&self, request: MessageRequest) -> Result<MessageResponse, LlmError> {
|
||||||
let body = self.build_request_body(request)?;
|
let body = self.build_request_body(request)?;
|
||||||
let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
|
|
||||||
|
|
||||||
info!(model = %body.model, "Anthropic: 发送非流式请求");
|
info!(model = %body.model, "Anthropic: 发送非流式请求");
|
||||||
|
|
||||||
let response = self
|
let response = self
|
||||||
.http_client
|
.build_request_builder(&body)?
|
||||||
.post(&url)
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| self.map_reqwest_error(e))?;
|
.map_err(|e| self.map_reqwest_error(e))?;
|
||||||
@@ -291,14 +343,10 @@ impl AnthropicProvider {
|
|||||||
let mut body = self.build_request_body(request)?;
|
let mut body = self.build_request_body(request)?;
|
||||||
body.stream = Some(true);
|
body.stream = Some(true);
|
||||||
|
|
||||||
let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
|
|
||||||
|
|
||||||
info!(model = %body.model, "Anthropic: 发送流式请求");
|
info!(model = %body.model, "Anthropic: 发送流式请求");
|
||||||
|
|
||||||
let response = self
|
let response = self
|
||||||
.http_client
|
.build_request_builder(&body)?
|
||||||
.post(&url)
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| self.map_reqwest_error(e))?;
|
.map_err(|e| self.map_reqwest_error(e))?;
|
||||||
@@ -448,6 +496,9 @@ struct AnthropicRequestBody {
|
|||||||
thinking: Option<AnthropicThinking>,
|
thinking: Option<AnthropicThinking>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
stream: Option<bool>,
|
stream: Option<bool>,
|
||||||
|
/// 请求级别自定义 HTTP 头。运行时注入,不进入 JSON 请求体。
|
||||||
|
#[serde(skip)]
|
||||||
|
custom_headers: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -1207,4 +1258,271 @@ event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
|
|||||||
other => panic!("expected RateLimit, got {other:?}"),
|
other => panic!("expected RateLimit, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== custom_headers (Phase 8 Step 8.7) =====
|
||||||
|
|
||||||
|
fn mock_messages_body() -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"id": "msg_test",
|
||||||
|
"type": "message",
|
||||||
|
"model": "claude-sonnet-4-20250514",
|
||||||
|
"content": [{"type": "text", "text": "OK"}],
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"usage": {"input_tokens": 1, "output_tokens": 1}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_provider_with_extra_headers(
|
||||||
|
base_url: String,
|
||||||
|
extra_headers: Vec<(String, String)>,
|
||||||
|
) -> AnthropicProvider {
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.expect("create http client");
|
||||||
|
AnthropicProvider::from_parts(
|
||||||
|
base_url,
|
||||||
|
"sk-ant-test".into(),
|
||||||
|
"claude-sonnet-4-20250514".into(),
|
||||||
|
client,
|
||||||
|
30,
|
||||||
|
extra_headers,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_custom_headers_from_extra() {
|
||||||
|
let provider = make_provider_with_extra_headers("http://x".into(), Vec::new());
|
||||||
|
let mut req = MessageRequest {
|
||||||
|
model: "claude-sonnet-4-20250514".into(),
|
||||||
|
messages: vec![Message::user_text("hi")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
req.set_extra("custom_headers", json!({"X-Custom": "v1", "X-Other": "v2"}));
|
||||||
|
let body = provider.build_request_body(req).unwrap();
|
||||||
|
assert_eq!(body.custom_headers.get("X-Custom").unwrap(), "v1");
|
||||||
|
assert_eq!(body.custom_headers.get("X-Other").unwrap(), "v2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_custom_headers_skipped_in_json_body() {
|
||||||
|
let provider = make_provider_with_extra_headers("http://x".into(), Vec::new());
|
||||||
|
let mut req = MessageRequest {
|
||||||
|
model: "claude-sonnet-4-20250514".into(),
|
||||||
|
messages: vec![Message::user_text("hi")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
req.set_extra("custom_headers", json!({"X-Custom": "v1"}));
|
||||||
|
let body = provider.build_request_body(req).unwrap();
|
||||||
|
let value = serde_json::to_value(&body).unwrap();
|
||||||
|
assert!(
|
||||||
|
value.get("custom_headers").is_none(),
|
||||||
|
"custom_headers 不应进入 JSON body"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_custom_headers_invalid_type_fallback() {
|
||||||
|
let provider = make_provider_with_extra_headers("http://x".into(), Vec::new());
|
||||||
|
let mut req = MessageRequest {
|
||||||
|
model: "claude-sonnet-4-20250514".into(),
|
||||||
|
messages: vec![Message::user_text("hi")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
req.set_extra("custom_headers", json!("not_an_object"));
|
||||||
|
let body = provider.build_request_body(req).unwrap();
|
||||||
|
assert!(body.custom_headers.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn anthropic_custom_headers_are_sent() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/v1/messages"))
|
||||||
|
.and(header("X-Custom", "v1"))
|
||||||
|
.and(header("anthropic-version", "2023-06-01"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(mock_messages_body()))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let provider = make_provider(server.uri());
|
||||||
|
let mut req = MessageRequest {
|
||||||
|
model: "claude-sonnet-4-20250514".into(),
|
||||||
|
messages: vec![Message::user_text("hi")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
req.set_extra("custom_headers", json!({"X-Custom": "v1"}));
|
||||||
|
|
||||||
|
let resp = provider.chat_blocking(req).await.unwrap();
|
||||||
|
assert_eq!(resp.text(), "OK");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn anthropic_provider_level_headers_are_sent() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/v1/messages"))
|
||||||
|
.and(header("X-Platform", "anthropic-test"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(mock_messages_body()))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let provider = make_provider_with_extra_headers(
|
||||||
|
server.uri(),
|
||||||
|
vec![("X-Platform".into(), "anthropic-test".into())],
|
||||||
|
);
|
||||||
|
let req = MessageRequest {
|
||||||
|
model: "claude-sonnet-4-20250514".into(),
|
||||||
|
messages: vec![Message::user_text("hi")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = provider.chat_blocking(req).await.unwrap();
|
||||||
|
assert_eq!(resp.text(), "OK");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn anthropic_custom_headers_override_provider_headers() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/v1/messages"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(mock_messages_body()))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let provider = make_provider_with_extra_headers(
|
||||||
|
server.uri(),
|
||||||
|
vec![("X-Platform".into(), "provider-level".into())],
|
||||||
|
);
|
||||||
|
let mut req = MessageRequest {
|
||||||
|
model: "claude-sonnet-4-20250514".into(),
|
||||||
|
messages: vec![Message::user_text("hi")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
req.set_extra("custom_headers", json!({"X-Platform": "request-wins"}));
|
||||||
|
|
||||||
|
let resp = provider.chat_blocking(req).await.unwrap();
|
||||||
|
assert_eq!(resp.text(), "OK");
|
||||||
|
|
||||||
|
let received = server.received_requests().await.unwrap();
|
||||||
|
assert_eq!(received.len(), 1);
|
||||||
|
let platforms: Vec<&str> = received[0]
|
||||||
|
.headers
|
||||||
|
.get_all("X-Platform")
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.to_str().ok())
|
||||||
|
.collect();
|
||||||
|
assert!(platforms.contains(&"request-wins"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn anthropic_custom_headers_can_override_auth_header() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/v1/messages"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(mock_messages_body()))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let provider = make_provider_with_extra_headers(server.uri(), Vec::new());
|
||||||
|
let mut req = MessageRequest {
|
||||||
|
model: "claude-sonnet-4-20250514".into(),
|
||||||
|
messages: vec![Message::user_text("hi")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
req.set_extra(
|
||||||
|
"custom_headers",
|
||||||
|
json!({"x-api-key": "from-custom-headers"}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = provider.chat_blocking(req).await.unwrap();
|
||||||
|
assert_eq!(resp.text(), "OK");
|
||||||
|
|
||||||
|
let received = server.received_requests().await.unwrap();
|
||||||
|
assert_eq!(received.len(), 1);
|
||||||
|
let keys: Vec<&str> = received[0]
|
||||||
|
.headers
|
||||||
|
.get_all("x-api-key")
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.to_str().ok())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
keys.contains(&"from-custom-headers"),
|
||||||
|
"custom_headers 应能覆盖 x-api-key 头,实际收到: {keys:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_extra_headers_from_constructor_unit() {
|
||||||
|
// ponytail: 用 RequestBuilder::build() 直检 headers,无需 wiremock。
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.expect("create http client");
|
||||||
|
let provider = AnthropicProvider::from_parts(
|
||||||
|
"http://x".into(),
|
||||||
|
"sk-ant-test".into(),
|
||||||
|
"claude-sonnet-4-20250514".into(),
|
||||||
|
client,
|
||||||
|
30,
|
||||||
|
vec![("X-Platform".into(), "anthropic-test".into())],
|
||||||
|
);
|
||||||
|
let body = AnthropicRequestBody {
|
||||||
|
model: "claude-sonnet-4-20250514".into(),
|
||||||
|
max_tokens: 4096,
|
||||||
|
system: None,
|
||||||
|
messages: Vec::new(),
|
||||||
|
tools: None,
|
||||||
|
thinking: None,
|
||||||
|
stream: None,
|
||||||
|
custom_headers: HashMap::new(),
|
||||||
|
};
|
||||||
|
let req = provider
|
||||||
|
.build_request_builder(&body)
|
||||||
|
.unwrap()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(req.headers().get("X-Platform").unwrap(), "anthropic-test");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_invalid_header_name_is_skipped() {
|
||||||
|
// ponytail: extra_headers 含非法 key 应被跳过,不应让 reqwest panic。
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.expect("create http client");
|
||||||
|
let provider = AnthropicProvider::from_parts(
|
||||||
|
"http://x".into(),
|
||||||
|
"sk-ant-test".into(),
|
||||||
|
"claude-sonnet-4-20250514".into(),
|
||||||
|
client,
|
||||||
|
30,
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.with_extra_headers(vec![
|
||||||
|
("X-Valid".into(), "v1".into()),
|
||||||
|
("bad\nname".into(), "v2".into()),
|
||||||
|
]);
|
||||||
|
let body = AnthropicRequestBody {
|
||||||
|
model: "claude-sonnet-4-20250514".into(),
|
||||||
|
max_tokens: 4096,
|
||||||
|
system: None,
|
||||||
|
messages: Vec::new(),
|
||||||
|
tools: None,
|
||||||
|
thinking: None,
|
||||||
|
stream: None,
|
||||||
|
custom_headers: HashMap::new(),
|
||||||
|
};
|
||||||
|
let req = provider
|
||||||
|
.build_request_builder(&body)
|
||||||
|
.unwrap()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(req.headers().get("X-Valid").unwrap(), "v1");
|
||||||
|
assert!(
|
||||||
|
req.headers().get("bad\nname").is_none(),
|
||||||
|
"非法 header 名应被静默跳过"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+276
-3
@@ -8,6 +8,7 @@
|
|||||||
//! - `MessageComplete { full_response }` 由 `PartialMessageResponse::finalize()` 产出。
|
//! - `MessageComplete { full_response }` 由 `PartialMessageResponse::finalize()` 产出。
|
||||||
//! - `capabilities()` 报告 OpenAI Chat 协议的能力。
|
//! - `capabilities()` 报告 OpenAI Chat 协议的能力。
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::task::{Context, Poll};
|
use std::task::{Context, Poll};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -17,9 +18,10 @@ use bytes::Bytes;
|
|||||||
use futures_core::Stream;
|
use futures_core::Stream;
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
use reqwest::header::{HeaderName, HeaderValue};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tracing::{debug, error, info};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use crate::llm::convert::{from_openai, to_openai};
|
use crate::llm::convert::{from_openai, to_openai};
|
||||||
use crate::llm::error::LlmError;
|
use crate::llm::error::LlmError;
|
||||||
@@ -145,6 +147,11 @@ pub(crate) struct OpenaiChatRequest {
|
|||||||
pub extra_headers: Option<Value>,
|
pub extra_headers: Option<Value>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub extra_body: Option<Value>,
|
pub extra_body: Option<Value>,
|
||||||
|
/// 请求级别自定义 HTTP 头。运行时注入,不进入 JSON 请求体。
|
||||||
|
/// ⚠️ 与 struct 已有的 `extra_headers: Option<Value>`(OpenAI API 自身的 wire 格式字段)
|
||||||
|
/// 不同——后者是 OpenAI API 参数,本字段是 reqwest 层的 HTTP 头注入。
|
||||||
|
#[serde(skip)]
|
||||||
|
pub custom_headers: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -437,6 +444,13 @@ impl GenericOpenaiProvider {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 设置 Provider 级别固定头,替换已有的 extra_headers(如有)。
|
||||||
|
/// 返回 self 以支持链式调用。如需追加语义,在外部自行 `extend`。
|
||||||
|
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
|
||||||
|
self.extra_headers = headers;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_client(mut self, client: Client) -> Self {
|
pub fn with_client(mut self, client: Client) -> Self {
|
||||||
self.http_client = client;
|
self.http_client = client;
|
||||||
self
|
self
|
||||||
@@ -451,17 +465,37 @@ impl GenericOpenaiProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 构造 HTTP POST 请求 builder(含认证头与额外请求头)。
|
/// 构造 HTTP POST 请求 builder(含认证头与额外请求头)。
|
||||||
|
///
|
||||||
|
/// 头融合顺序:Authorization → Provider 级 extra_headers → 请求级 custom_headers
|
||||||
|
/// 后者覆盖前者。非法 header 名/值(如控制字符)静默跳过 + warn,避免 reqwest panic。
|
||||||
fn build_request_builder(
|
fn build_request_builder(
|
||||||
&self,
|
&self,
|
||||||
url: &str,
|
url: &str,
|
||||||
body: &impl Serialize,
|
body: &OpenaiChatRequest,
|
||||||
) -> Result<reqwest::RequestBuilder, LlmError> {
|
) -> Result<reqwest::RequestBuilder, LlmError> {
|
||||||
let mut builder = self
|
let mut builder = self
|
||||||
.http_client
|
.http_client
|
||||||
.post(url)
|
.post(url)
|
||||||
.header("Authorization", format!("Bearer {}", self.api_key));
|
.header("Authorization", format!("Bearer {}", self.api_key));
|
||||||
for (k, v) in &self.extra_headers {
|
for (k, v) in &self.extra_headers {
|
||||||
builder = builder.header(k.as_str(), v.as_str());
|
if let (Ok(name), Ok(value)) = (
|
||||||
|
HeaderName::from_bytes(k.as_bytes()),
|
||||||
|
HeaderValue::from_str(v),
|
||||||
|
) {
|
||||||
|
builder = builder.header(name, value);
|
||||||
|
} else {
|
||||||
|
warn!(header = %k, "skipping invalid extra_header (key or value contains illegal characters)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (key, value) in &body.custom_headers {
|
||||||
|
if let (Ok(name), Ok(value)) = (
|
||||||
|
HeaderName::from_bytes(key.as_bytes()),
|
||||||
|
HeaderValue::from_str(value),
|
||||||
|
) {
|
||||||
|
builder = builder.header(name, value);
|
||||||
|
} else {
|
||||||
|
warn!(header = %key, "skipping invalid custom_header (key or value contains illegal characters)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(builder.json(body))
|
Ok(builder.json(body))
|
||||||
}
|
}
|
||||||
@@ -557,6 +591,8 @@ impl GenericOpenaiProvider {
|
|||||||
let seed = request.get_extra_opt("seed");
|
let seed = request.get_extra_opt("seed");
|
||||||
let response_format = request.get_extra_opt("response_format");
|
let response_format = request.get_extra_opt("response_format");
|
||||||
let parallel_tool_calls = request.get_extra_opt("parallel_tool_calls");
|
let parallel_tool_calls = request.get_extra_opt("parallel_tool_calls");
|
||||||
|
let custom_headers: HashMap<String, String> =
|
||||||
|
request.get_extra_opt("custom_headers").unwrap_or_default();
|
||||||
|
|
||||||
Ok(OpenaiChatRequest {
|
Ok(OpenaiChatRequest {
|
||||||
model,
|
model,
|
||||||
@@ -573,6 +609,7 @@ impl GenericOpenaiProvider {
|
|||||||
seed,
|
seed,
|
||||||
response_format,
|
response_format,
|
||||||
parallel_tool_calls,
|
parallel_tool_calls,
|
||||||
|
custom_headers,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1695,4 +1732,240 @@ data: [DONE]\n\n";
|
|||||||
"expected an Error event for malformed SSE chunk"
|
"expected an Error event for malformed SSE chunk"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== custom_headers (Phase 8 Step 8.7) =====
|
||||||
|
|
||||||
|
fn make_provider_for_header_tests(base_url: String) -> GenericOpenaiProvider {
|
||||||
|
GenericOpenaiProvider::new_with_name(
|
||||||
|
base_url,
|
||||||
|
"sk-test".into(),
|
||||||
|
"gpt-4o".into(),
|
||||||
|
"openai",
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mock_chat_completions_body() -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"id": "chatcmpl-test",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1,
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"choices": [{"index": 0, "message": {"role": "assistant", "content": "OK"}, "finish_reason": "stop"}],
|
||||||
|
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openai_chat_custom_headers_from_extra() {
|
||||||
|
let provider = make_provider_for_header_tests("http://x".into());
|
||||||
|
let mut req = MessageRequest {
|
||||||
|
model: "gpt-4o".into(),
|
||||||
|
messages: vec![Message::user_text("hi")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
req.set_extra("custom_headers", json!({"X-Custom": "v1", "X-Other": "v2"}));
|
||||||
|
|
||||||
|
let body = provider.convert_request(req).unwrap();
|
||||||
|
assert_eq!(body.custom_headers.get("X-Custom").unwrap(), "v1");
|
||||||
|
assert_eq!(body.custom_headers.get("X-Other").unwrap(), "v2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openai_chat_custom_headers_skipped_in_json_body() {
|
||||||
|
let provider = make_provider_for_header_tests("http://x".into());
|
||||||
|
let mut req = MessageRequest {
|
||||||
|
model: "gpt-4o".into(),
|
||||||
|
messages: vec![Message::user_text("hi")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
req.set_extra("custom_headers", json!({"X-Custom": "v1"}));
|
||||||
|
let body = provider.convert_request(req).unwrap();
|
||||||
|
|
||||||
|
let value = serde_json::to_value(&body).unwrap();
|
||||||
|
assert!(
|
||||||
|
value.get("custom_headers").is_none(),
|
||||||
|
"custom_headers 不应进入 JSON body"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openai_chat_custom_headers_invalid_type_fallback() {
|
||||||
|
let provider = make_provider_for_header_tests("http://x".into());
|
||||||
|
let mut req = MessageRequest {
|
||||||
|
model: "gpt-4o".into(),
|
||||||
|
messages: vec![Message::user_text("hi")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
req.set_extra("custom_headers", json!("not_an_object"));
|
||||||
|
let body = provider.convert_request(req).unwrap();
|
||||||
|
assert!(body.custom_headers.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn openai_chat_custom_headers_are_sent() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/chat/completions"))
|
||||||
|
.and(header("authorization", "Bearer sk-test"))
|
||||||
|
.and(header("X-Custom", "v1"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(mock_chat_completions_body()))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let provider = make_provider_for_header_tests(server.uri());
|
||||||
|
let mut req = MessageRequest {
|
||||||
|
model: "gpt-4o".into(),
|
||||||
|
messages: vec![Message::user_text("hi")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
req.set_extra("custom_headers", json!({"X-Custom": "v1"}));
|
||||||
|
|
||||||
|
let resp = provider.chat_blocking(req).await.unwrap();
|
||||||
|
assert_eq!(resp.text(), "OK");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn openai_chat_provider_level_headers_are_sent() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/chat/completions"))
|
||||||
|
.and(header("X-Platform", "doubao"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(mock_chat_completions_body()))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let provider = make_provider_for_header_tests(server.uri())
|
||||||
|
.with_extra_headers(vec![("X-Platform".into(), "doubao".into())]);
|
||||||
|
let req = MessageRequest {
|
||||||
|
model: "gpt-4o".into(),
|
||||||
|
messages: vec![Message::user_text("hi")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = provider.chat_blocking(req).await.unwrap();
|
||||||
|
assert_eq!(resp.text(), "OK");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn openai_chat_custom_headers_override_provider_headers() {
|
||||||
|
// ponytail: wiremock 的 `header()` 是精确匹配(顺序敏感),同 key 多值无法匹配。
|
||||||
|
// 因此 override 测试用通用 mock + server.received_requests() 事后验证实际请求头。
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/chat/completions"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(mock_chat_completions_body()))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let provider = make_provider_for_header_tests(server.uri())
|
||||||
|
.with_extra_headers(vec![("X-Platform".into(), "provider-level".into())]);
|
||||||
|
let mut req = MessageRequest {
|
||||||
|
model: "gpt-4o".into(),
|
||||||
|
messages: vec![Message::user_text("hi")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
req.set_extra("custom_headers", json!({"X-Platform": "request-wins"}));
|
||||||
|
|
||||||
|
let resp = provider.chat_blocking(req).await.unwrap();
|
||||||
|
assert_eq!(resp.text(), "OK");
|
||||||
|
|
||||||
|
let received = server.received_requests().await.unwrap();
|
||||||
|
assert_eq!(received.len(), 1);
|
||||||
|
let platforms: Vec<&str> = received[0]
|
||||||
|
.headers
|
||||||
|
.get_all("X-Platform")
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.to_str().ok())
|
||||||
|
.collect();
|
||||||
|
assert!(platforms.contains(&"request-wins"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn openai_chat_custom_headers_can_override_auth_header() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/chat/completions"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(mock_chat_completions_body()))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let provider = make_provider_for_header_tests(server.uri());
|
||||||
|
let mut req = MessageRequest {
|
||||||
|
model: "gpt-4o".into(),
|
||||||
|
messages: vec![Message::user_text("hi")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
req.set_extra(
|
||||||
|
"custom_headers",
|
||||||
|
json!({"authorization": "from-custom-headers"}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = provider.chat_blocking(req).await.unwrap();
|
||||||
|
assert_eq!(resp.text(), "OK");
|
||||||
|
|
||||||
|
let received = server.received_requests().await.unwrap();
|
||||||
|
assert_eq!(received.len(), 1);
|
||||||
|
let auth_values: Vec<&str> = received[0]
|
||||||
|
.headers
|
||||||
|
.get_all("authorization")
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.to_str().ok())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
auth_values.contains(&"from-custom-headers"),
|
||||||
|
"custom_headers 应能覆盖 Authorization 头,实际收到: {auth_values:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openai_chat_extra_headers_from_constructor_unit() {
|
||||||
|
// 验证 `from_parts` 传入的 extra_headers 在 build_request_builder 中被注入。
|
||||||
|
// ponytail: 用 RequestBuilder::build() 直检 headers,无需 wiremock。
|
||||||
|
let provider = GenericOpenaiProvider::from_parts(
|
||||||
|
"http://x".into(),
|
||||||
|
"sk-test".into(),
|
||||||
|
"gpt-4o".into(),
|
||||||
|
"openai",
|
||||||
|
Client::builder()
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.expect("create http client"),
|
||||||
|
vec![("X-Platform".into(), "doubao".into())],
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
let body = OpenaiChatRequest {
|
||||||
|
model: "gpt-4o".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let req = provider
|
||||||
|
.build_request_builder("http://x/chat/completions", &body)
|
||||||
|
.unwrap()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(req.headers().get("X-Platform").unwrap(), "doubao");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openai_chat_invalid_header_name_is_skipped() {
|
||||||
|
// ponytail: extra_headers 含非法 key(如换行符)应被跳过,不应让 reqwest panic。
|
||||||
|
let provider = make_provider_for_header_tests("http://x".into()).with_extra_headers(vec![
|
||||||
|
("X-Valid".into(), "v1".into()),
|
||||||
|
("bad\nname".into(), "v2".into()),
|
||||||
|
]);
|
||||||
|
let body = OpenaiChatRequest {
|
||||||
|
model: "gpt-4o".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let req = provider
|
||||||
|
.build_request_builder("http://x/chat/completions", &body)
|
||||||
|
.unwrap()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(req.headers().get("X-Valid").unwrap(), "v1");
|
||||||
|
assert!(
|
||||||
|
req.headers().get("bad\nname").is_none(),
|
||||||
|
"非法 header 名应被静默跳过"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user