feat(llm): 实现 OpenAI Response API Provider
CI / test (chat,provider-openai) (push) Has been cancelled
CI / test (chat,provider-openai,provider-openai-response) (push) Has been cancelled
CI / test (chat,provider-openai,tools-mcp) (push) Has been cancelled
CI / test (full) (push) Has been cancelled
CI / test (light) (push) Has been cancelled
CI / test (multi,provider-openai,tools-mcp) (push) Has been cancelled
CI / clippy (push) Has been cancelled
CI / fmt (push) Has been cancelled
CI / examples (push) Has been cancelled
CI / test (multi,provider-openai) (push) Has been cancelled

- 新增独立 OpenaiResponseProvider(POST /responses 协议),独立 feature provider-openai-response
- 覆盖文本对话/流式/Vision/Function Calling/多轮接续/结构化输出/内置工具逃生舱
- 内置工具(web_search/file_search)通过 extra 逃生舱透传
- 工厂注册 ProviderType::OpenaiResponse + src/llm 模块门控追加
- 新增 example response_api_demo + CI 矩阵新增组合 + README/roadmap 同步
- 测试覆盖:13 单元 + 15 wiremock(流式 + 非流式 + 错误路径)
- 文档:docs/28-phase28-openai-response-api-provider.md
This commit is contained in:
徐涛
2026-07-20 09:05:04 +08:00
parent f6cf583cd7
commit b895616dd0
9 changed files with 2884 additions and 7 deletions
+1
View File
@@ -20,6 +20,7 @@ jobs:
- "chat,provider-openai,tools-mcp"
- "multi,provider-openai"
- "multi,provider-openai,tools-mcp"
- "chat,provider-openai,provider-openai-response"
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
+9 -3
View File
@@ -20,9 +20,11 @@ agent = ["llm", "tools", "memory", "futures-util"]
engine = ["agent"]
# === Provider features ===
# Provider features — openai/anthropic 额外依赖 bytes(流式解析)和 futures-utilStream 组合)
# Provider features — openai/anthropic/openai-response 额外依赖 bytes(流式解析)和 futures-utilStream 组合)
provider-openai = ["llm", "reqwest", "bytes", "futures-util"]
provider-anthropic = ["llm", "reqwest", "bytes", "futures-util"]
# OpenAI Response APIPOST /responses)—— 与 Chat Completions 协议独立,独立 feature
provider-openai-response = ["llm", "reqwest", "bytes", "futures-util"]
# deepseek/qwen 使用 openai_compat 适配层,不需要 bytes 和 futures-util
provider-deepseek = ["llm", "reqwest"]
provider-qwen = ["llm", "reqwest"]
@@ -37,8 +39,8 @@ full = [
"tools", "tools-mcp",
"memory", "memory-sqlite",
"agent", "engine",
"provider-openai", "provider-anthropic", "provider-deepseek",
"provider-qwen", "provider-ollama",
"provider-openai", "provider-anthropic", "provider-openai-response",
"provider-deepseek", "provider-qwen", "provider-ollama",
"tracing-init",
]
light = ["llm", "provider-openai", "tools", "tools-mcp", "memory", "agent", "engine", "prompt", "document"]
@@ -148,3 +150,7 @@ required-features = ["memory", "tracing-init"]
[[example]]
name = "end_to_end"
required-features = ["agent", "memory-sqlite", "provider-openai"]
[[example]]
name = "response_api_demo"
required-features = ["llm", "provider-openai-response"]
+3 -1
View File
@@ -110,7 +110,7 @@ let provider = create_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 快照恢复 |
| `bridge_keys_demo` | 桥接键:Agent 间上下文键值透传 |
| `agent_switch_demo` | Agent 热切换:会话中动态切换 Agent 角色 |
| `response_api_demo` | OpenAI Response API`POST /responses`)真实调用 |
## Feature 组合
@@ -184,6 +185,7 @@ agcore = { version = "0.3", default-features = false, features = ["multi", "prov
| `engine` | SessionManager + Checkpointer + SubAgent + Switch | `agent` |
| `provider-openai` | OpenAI Provider 实现 | `llm` |
| `provider-anthropic` | Anthropic Provider 实现 | `llm` |
| `provider-openai-response` | OpenAI Response API`POST /responses`Provider 实现 | `llm` |
| `provider-deepseek` | DeepSeek Provider 实现 | `llm` |
| `provider-qwen` | Qwen 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.2Phase 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 个 Phase9 个 Step。
### Phase 28Feature gate + Wire 类型 + Provider 骨架(~140 行)
#### Step 28.1Cargo.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.2Wire 类型定义
**文件操作**:新建 `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.3Provider 结构体 + 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.4Factory 注册 + 模块门控
**文件操作**
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.1convert_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.2convert_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.4SSE 事件类型 + 状态机(~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.6LlmProvider 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.1wiremock 非流式测试(~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.2wiremock 流式测试(~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.3CI 矩阵(~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.5Example~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 |
+2 -1
View File
@@ -1,7 +1,7 @@
# AG Core Roadmap
> 拆分式 roadmap:按版本归档 + 未归类内容
> 最后更新:2026-07-19v0.3.2 Step 3 完成 — Phase 26-27 CI 固化 + 文档更新交付,427 测试通过)
> 最后更新:2026-07-20Phase 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 512 + 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 1319 | ✅ Phase 13-19 全部完成,v0.3.0 交付完毕 |
| [`roadmap-v0.3.2.md`](./roadmap-v0.3.2.md) | v0.3.2 计划与交付 — Phase 2027Cargo 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+ 展望、风险与建议、下一步行动、阶段总回顾 | — |
## 阅读建议
+82
View File
@@ -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
View File
@@ -22,7 +22,8 @@ pub mod types;
feature = "provider-anthropic",
feature = "provider-deepseek",
feature = "provider-qwen",
feature = "provider-ollama"
feature = "provider-ollama",
feature = "provider-openai-response"
))]
pub mod provider;
/// Provider 抽象接口(trait + 能力元数据),仅依赖 `llm` feature,不引入 reqwest。
+17 -1
View File
@@ -2,6 +2,8 @@ pub mod anthropic;
pub mod ollama;
pub mod openai;
pub mod openai_compat;
#[cfg(feature = "provider-openai-response")]
pub mod openai_response;
pub mod registry;
use std::time::Duration;
@@ -191,8 +193,22 @@ 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,
),
))
}
#[cfg(not(feature = "provider-openai-response"))]
ProviderType::OpenaiResponse => Err(LlmError::Other(
"OpenaiResponse Provider 在 Phase 1 暂不实现;请使用 OpenaiChat".into(),
"OpenaiResponse Provider 未编译:启用 `provider-openai-response` feature".into(),
)),
ProviderType::Anthropic => {
let client = build_anthropic_client(&config.api_key, config.timeout_secs)?;
File diff suppressed because it is too large Load Diff