Compare commits
14
Commits
f6cf583cd7
...
v0.3.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25238fc357 | ||
|
|
b7d0a7335f | ||
|
|
c5afa4b31e | ||
|
|
28ca43ccb2 | ||
|
|
528a17f5fa | ||
|
|
d48286a942 | ||
|
|
eeae943727 | ||
|
|
40e4b3d8fe | ||
|
|
8ea01d373e | ||
|
|
5e475e1303 | ||
|
|
76bbeed596 | ||
|
|
77321db8f6 | ||
|
|
939dcf0f9a | ||
|
|
b895616dd0 |
@@ -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
|
||||
|
||||
@@ -186,29 +186,64 @@ pub use vector_store::VectorStore;
|
||||
|
||||
## 文档规范
|
||||
|
||||
### 方案规范 (docs/)
|
||||
### 文档编号规范(design/pdd/ + design/prd/)
|
||||
|
||||
**编号规则**:创建新方案前必须先通过 shell 命令确认当前实际最大编号(Unix: `ls docs/` / Windows: `dir docs\`),禁止使用上下文中缓存的编号,如遇冲突自动递增
|
||||
`design/pdd/`(方案文档)和 `design/prd/`(需求文档)使用相同的命名格式,但**各自独立编号**:
|
||||
|
||||
**方案文档结构**(6 项):
|
||||
1. **背景与目标** - 问题描述、预期目标
|
||||
2. **需求分析** - 功能需求、非功能需求
|
||||
3. **方案设计** - 架构设计、模块划分、接口定义
|
||||
4. **实现计划** - 任务拆解、优先级、时间估算
|
||||
5. **风险评估** - 潜在风险、缓解措施
|
||||
6. **验收标准** - 可验证的完成条件
|
||||
```
|
||||
<序号>-<简短描述>.md
|
||||
```
|
||||
|
||||
### 进度同步规范 (docs/roadmap.md)
|
||||
规则:
|
||||
- 序号使用数字,从 1 开始递增。**创建前必须通过 shell 命令确认目标目录当前实际最大编号再加 1:**
|
||||
```bash
|
||||
# 查 design/pdd/ 的最大编号
|
||||
ls design/pdd/ 2>/dev/null | grep -E '^\d+-' | sort -t- -k1 -n | tail -1 | cut -d- -f1
|
||||
# 查 design/prd/ 的最大编号
|
||||
ls design/prd/ 2>/dev/null | grep -E '^\d+-' | sort -t- -k1 -n | tail -1 | cut -d- -f1
|
||||
# 无输出则从 1 开始
|
||||
```
|
||||
禁止使用上下文中缓存的编号。
|
||||
- 描述:中文,简短概括主题
|
||||
- 两个目录各自独立编号——`design/pdd/` 已有 `3-` 时,`design/prd/` 的新文件仍从当前最大号 +1 开始,互不影响
|
||||
|
||||
完成一项实施后,必须检查 `docs/roadmap.md` 是否存在对应内容;若存在,必须同步标记为完成:
|
||||
方案文档(`design/pdd/`)应包含:
|
||||
- 背景与目标
|
||||
- 需求推演概要(需求拆解、边界识别、关键假设的简要推演)
|
||||
- 当前问题分析
|
||||
- 架构决策记录(重大技术选型、架构变更的决策过程与理由)
|
||||
- 设计方案(含架构图/流程图)
|
||||
- 实施步骤
|
||||
- 验证标准
|
||||
- 回滚方案(如适用)
|
||||
|
||||
- **Step / Phase 状态行**:对应 Step 加 ✅ 标记;Phase 章节末尾「状态」行从 ⏳ 改为 ✅ Phase X 全部交付物已完成
|
||||
- **里程碑表**:更新对应里程碑状态从 ⏳ 改为 ✅ + 完成日期
|
||||
- **依赖关系图(Mermaid)**:节点 `class` 从 `pending` / `core` 改为 `done`,必要时更新节点摘要
|
||||
- **文末「已完成 / 进行中阶段」列表**:追加一行 `- ✅ Phase X — 一句话要点`
|
||||
- **顶部「当前状态」**:补充新完成 Phase,更新「下一步」指向
|
||||
示例:
|
||||
- `design/pdd/1-ui-components重构方案.md`
|
||||
- `design/prd/1-用户认证需求.md`
|
||||
- `design/pdd/2-数据库迁移方案.md`
|
||||
|
||||
参考案例:2026-07-05 完成 Phase 7 SqliteStore 时同步更新 6 处(顶部状态 / Phase 章节 / 依赖图 / M3 / 下一步行动 / 已完成列表)。
|
||||
---
|
||||
|
||||
### 设计目录(design/)
|
||||
|
||||
项目根下的 `design/` 目录集中管理所有设计相关的文件,供人类和 agent 共同读写。
|
||||
|
||||
| 子目录 | 内容 | 谁写 | 谁读 |
|
||||
|--------|------|------|------|
|
||||
| `design/pdd/` | 方案设计文档(PDD)→ 架构方案、设计决策、转换方案 | proposal→writer pipeline | Think 参考、Build 实现、Vet 审查 |
|
||||
| `design/prd/` | 需求文档(PRD)→ 功能需求、用户故事、验收标准 | 人写 | Think 分析、Proposal 写方案时参考 |
|
||||
| `design/prototype/` | **OD 导出的原型 HTML** → 视觉稿、交互原型、页面 layout | OD 桌面版导出 | Think 分析结构、Build 对照实现 |
|
||||
| `design/notes/` | 笔记记录 → 零散想法、会议纪要、调研速记 | 人写 | 各 agent 参考 |
|
||||
| `design/roadmap/` | 路线图 → 里程碑规划、版本计划、优先级列表 | 人写 | Proposal 排期参考 |
|
||||
| `design/DESIGN.md` | 设计系统(品牌规范)→ 色板、字体、间距、语气 | OD 导出 / 人维护 | Think 提取 token、Build 同步到 `src/` |
|
||||
| `design/tokens.css` | 设计 Token CSS → 从 DESIGN.md 提取的 CSS 变量 | 人同步 / agent 同步 | 所有 Svelte 组件引用 |
|
||||
|
||||
**访问规则:**
|
||||
- 读:所有 agent 默认可读(`read_file` 不需要额外权限)
|
||||
- 写:writer agent 可通过 `"design/**": allow` 写入 `design/` 下任意子目录
|
||||
- 注意:`prototype/` 由 OD 桌面版导出,agent 只读不写;`DESIGN.md` 和 `tokens.css` 建议手动维护或 agent 写入时确认后再改
|
||||
|
||||
**兜底规则:** 文档类型不在上表时(如教程、接口文档、临时记录),或目标目录不存在时 → **向用户提问确认路径**。不允许自行推断存放位置。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+10
-4
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agcore"
|
||||
version = "0.3.2"
|
||||
version = "0.3.6"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
@@ -20,9 +20,11 @@ agent = ["llm", "tools", "memory", "futures-util"]
|
||||
engine = ["agent"]
|
||||
|
||||
# === 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-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
|
||||
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"]
|
||||
|
||||
@@ -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.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` 枚举
|
||||
- 不新增任何平台相关代码
|
||||
@@ -0,0 +1,268 @@
|
||||
# Builtin Tools 注入修复方案
|
||||
|
||||
## 背景与目标
|
||||
|
||||
### 问题描述
|
||||
|
||||
agcore 的 OpenaiResponseProvider 在通过 extra 逃生舱注入内置工具(`web_search` / `file_search`)时存在两层缺陷,导致 builtin_tools 完全不生效:
|
||||
|
||||
1. **分支逻辑错位**(`convert_request`,第 615-640 行):builtin_tools 注入代码被嵌套在 `tools_defs` 非空的 `else` 分支内。当调用方只提供 builtin_tools 而不提供 tools_defs 时,分支走 `if tools_defs.is_empty() { None }`,注入代码完全不执行。
|
||||
|
||||
2. **枚举不完整**(`ResponseTool`,第 136-146 行):枚举只有 `Function` 一种变体,非 `function` 类型的 builtin 工具(如 `type: "web_search"`)反序列化失败,退化为 `name: ""` 的空函数定义,API 层面被拒绝。
|
||||
|
||||
### 目标
|
||||
|
||||
- 修复 builtin_tools 注入逻辑,使纯内置工具、混用场景均正常工作
|
||||
- 不破坏现有 tools_defs 功能
|
||||
- 添加回归测试
|
||||
|
||||
## 需求分析
|
||||
|
||||
### 功能需求
|
||||
|
||||
| # | 需求 | 优先级 |
|
||||
|---|------|--------|
|
||||
| F1 | `tools_defs` 为空、`builtin_tools` 非空时,正确注入内置工具 | P0 |
|
||||
| F2 | `tools_defs` 和 `builtin_tools` 同时非空时,合并注入 | P0 |
|
||||
| F3 | 两端均为空时,tools 字段为 None(回归保底) | P0 |
|
||||
| F4 | 无效的 builtin_tools 值不导致崩溃,跳过并告警 | P1 |
|
||||
|
||||
### 非功能需求
|
||||
|
||||
- 不做底层架构改造(extra 逃生舱机制不变)
|
||||
- 不改 `openai.rs`(Chat Completions API 不支持内置工具)
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 总体架构
|
||||
|
||||
修复分三步,对应三层独立但不相互依赖的改动:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ convert_request() │
|
||||
│ │
|
||||
│ [改动二] 重构分支逻辑 │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ tools_defs ──→ 生成 Vec<ResponseTool> │ │
|
||||
│ │ builtin_tools ──→ 追加到同一 Vec │ │
|
||||
│ │ 两者都空 ──→ None; 否则 ──→ Some(items) │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [改动三] 错误处理 │
|
||||
│ unwrap_or_else ──→ match + warn! │
|
||||
└─────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ ResponseTool 枚举 │
|
||||
│ │
|
||||
│ [改动一] 添加 Builtin(Value) 变体 │
|
||||
│ 自定义 Serialize/Deserialize 避免信息丢失 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 改动一:扩展 `ResponseTool` 枚举
|
||||
|
||||
**位置**:第 136-146 行
|
||||
|
||||
**现状**:
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum ResponseTool {
|
||||
#[serde(rename = "function")]
|
||||
Function {
|
||||
name: String,
|
||||
description: String,
|
||||
parameters: Value,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**改后**(需要自定义 Serialize/Deserialize):
|
||||
```rust
|
||||
/// NOTE: 仅在请求序列化路径使用(convert_request → build_request_builder → HTTP body)。
|
||||
/// 响应反序列化走 ResponseOutputItem,不经过此类型。
|
||||
/// 自定义 Deserialize 服务于 convert_request 内 extra 字段反序列化。
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum ResponseTool {
|
||||
Function {
|
||||
name: String,
|
||||
description: String,
|
||||
parameters: Value,
|
||||
},
|
||||
/// 非 function 类型的工具(如 web_search / file_search / code_interpreter)。
|
||||
/// 直接透传原始 JSON Value,不做结构化解析,避免信息丢失。
|
||||
Builtin(Value),
|
||||
}
|
||||
|
||||
impl Serialize for ResponseTool {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
match self {
|
||||
ResponseTool::Function { name, description, parameters } => {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert("type".into(), Value::String("function".into()));
|
||||
map.insert("name".into(), Value::String(name.clone()));
|
||||
map.insert("description".into(), Value::String(description.clone()));
|
||||
map.insert("parameters".into(), parameters.clone());
|
||||
map.serialize(serializer)
|
||||
}
|
||||
ResponseTool::Builtin(value) => value.serialize(serializer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ResponseTool {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let value = Value::deserialize(deserializer)?;
|
||||
match value.get("type").and_then(|t| t.as_str()) {
|
||||
Some("function") => {
|
||||
let name = value.get("name").and_then(|n| n.as_str()).unwrap_or_default().to_string();
|
||||
let description = value.get("description").and_then(|d| d.as_str()).unwrap_or_default().to_string();
|
||||
let parameters = value.get("parameters").cloned().unwrap_or(Value::Null);
|
||||
Ok(ResponseTool::Function { name, description, parameters })
|
||||
}
|
||||
_ => Ok(ResponseTool::Builtin(value)),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
关键点:
|
||||
- 自定义 `Serialize`:`Builtin` 变体直接输出原始 Value(不包裹额外标记)
|
||||
- 自定义 `Deserialize`:非 `"function"` 类型自动走 `Builtin(Value)` 分支
|
||||
- 保留原始 JSON 结构,避免信息丢失(如 `search_context_size`、`user_location` 等字段)
|
||||
|
||||
### 改动二:修复 `convert_request` 分支逻辑
|
||||
|
||||
**位置**:第 615-640 行
|
||||
|
||||
**现状**(伪代码):
|
||||
```
|
||||
if tools_defs.is_empty() {
|
||||
None // ← builtin_tools 被完全跳过
|
||||
} else {
|
||||
从 tools_defs 生成 Vec<ResponseTool>
|
||||
if let Some(builtin_tools) {
|
||||
for v in extra {
|
||||
items.push(from_value(v)) // ← 只有进了 else 才执行
|
||||
}
|
||||
}
|
||||
Some(items)
|
||||
}
|
||||
```
|
||||
|
||||
**改后**(伪代码):
|
||||
```
|
||||
let mut items: Vec<ResponseTool> = Vec::new();
|
||||
|
||||
// 1. 始终处理 tools_defs
|
||||
items.extend(tools_defs.into_iter().map(|t| ResponseTool::Function { ... }));
|
||||
|
||||
// 2. 始终处理 builtin_tools(与 tools_defs 解耦)
|
||||
if let Some(builtin_tools) = builtin_tools {
|
||||
for v in extra {
|
||||
// 见改动三
|
||||
items.push(serde_json::from_value(v).unwrap_or_else(|_| { ... }));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 两者都空 → None;否则 → Some
|
||||
if items.is_empty() { None } else { Some(items) }
|
||||
```
|
||||
|
||||
### 改动三:改进错误处理
|
||||
|
||||
**位置**:第 630-636 行(`unwrap_or_else` 部分)
|
||||
|
||||
**现状**:
|
||||
```rust
|
||||
items.push(serde_json::from_value(v).unwrap_or_else(|_| {
|
||||
ResponseTool::Function {
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
parameters: Value::Null,
|
||||
}
|
||||
}));
|
||||
```
|
||||
|
||||
**改后**:
|
||||
```rust
|
||||
match serde_json::from_value(v.clone()) {
|
||||
Ok(tool) => items.push(tool),
|
||||
Err(e) => {
|
||||
let raw = serde_json::to_string(&v).unwrap_or_default();
|
||||
warn!(tool = %raw, error = %e, "skipped invalid builtin_tool");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`warn!` 输出被反序列化的 Value 摘要,便于生产排障时定位问题(无需复现调用方输入)。
|
||||
|
||||
`unwrap_or_else` 的错误值(空函数定义)在 OpenAI API 层会被拒绝,无实际价值。替换为 `match` + `warn!` 可明确跳过并记录原因。
|
||||
|
||||
### 改动四:添加测试
|
||||
|
||||
在文件末尾 `#[cfg(test)]` 区域或 `tests/` 目录新增 6 个测试用例:
|
||||
|
||||
| # | 用例名 | 场景 | 验证点 |
|
||||
|---|--------|------|--------|
|
||||
| T1 | `test_builtin_only` | 仅提供 builtin_tools | tools 为 Some,含正确 type |
|
||||
| T2 | `test_mixed_tools` | 同时提供 tools_defs + builtin_tools | 合并后 items 顺序/数量正确 |
|
||||
| T3 | `test_no_tools` | 两端均为空 | tools 为 None |
|
||||
| T4 | `test_invalid_builtin` | builtin_tools 含无效 JSON | 不崩溃,有效项保留 |
|
||||
| T5 | `test_function_wire_format` | ResponseTool::Function 序列化 | JSON 结构与改动前一致(AC7) |
|
||||
| T6 | `test_builtin_roundtrip` | ResponseTool::Builtin 反序列化+序列化 | 原始 JSON 结构保留 |
|
||||
|
||||
## 实现计划
|
||||
|
||||
### 步骤
|
||||
|
||||
| 步骤 | 改动 | 文件 | 估算 |
|
||||
|------|------|------|------|
|
||||
| 1 | 扩展 `ResponseTool` 枚举,添加 `Builtin(Value)` + 自定义 Serialize/Deserialize | `openai_response.rs:136-146` | 40 行 |
|
||||
| 2 | 重构 `convert_request` 分支逻辑,解耦 tools_defs 与 builtin_tools | `openai_response.rs:615-640` | 15 行 |
|
||||
| 3 | 替换 `unwrap_or_else` 为 `match` + `warn!` | `openai_response.rs:630-636` | 5 行 |
|
||||
| 4 | 添加 6 个测试用例 | `openai_response.rs` 末尾 | 70 行 |
|
||||
| 5 | `cargo test` 验证全部通过 | - | - |
|
||||
|
||||
### 优先级
|
||||
|
||||
**P0(核心修复)**:步骤 1 + 2,修复分支逻辑和枚举不完整问题。
|
||||
**P1(健壮性)**:步骤 3,改进错误处理。
|
||||
**P1(质量保障)**:步骤 4 + 5,测试覆盖。
|
||||
|
||||
### 依赖关系
|
||||
|
||||
无外部依赖。全部改动限定在 `openai_response.rs` 一个文件内。
|
||||
|
||||
## 风险评估
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||
|------|------|------|----------|
|
||||
| 自定义 Serialize/Deserialize 实现遗漏边界情况 | 低 | 中 | 测试覆盖所有分支:Function / Builtin / 无效值 |
|
||||
| 现有 `Function` 序列化格式变化 | 低 | 高 | 自定义 Serialize 保持与原 derive 行为一致,测试覆盖 wire 格式 |
|
||||
| `warn!` 日志在生产环境未配置 logger 导致 panic | 低 | 中 | 使用 `tracing::warn!`(已导入),项目已初始化 tracing logger |
|
||||
|
||||
### 回滚方案
|
||||
|
||||
单文件改动,回滚只需 `git checkout -- src/llm/provider/openai_response.rs`。
|
||||
|
||||
## 验收标准
|
||||
|
||||
| # | 验收条件 | 验证方式 |
|
||||
|---|----------|----------|
|
||||
| AC1 | `tools_defs` 空 + `builtin_tools` 含 `{"type":"web_search"}` → 请求体 `tools` 包含 `{"type":"web_search"}` | 单测 T1 |
|
||||
| AC2 | 混用场景 → tools 数组同时包含 function 和非 function 工具 | 单测 T2 |
|
||||
| AC3 | 两端空 → tools 字段为 null/None | 单测 T3 |
|
||||
| AC4 | 无效 builtin_tools → 不 panic,有效项不受影响 | 单测 T4 |
|
||||
| AC5 | 全部现有测试通过 | `cargo test` |
|
||||
| AC6 | `cargo clippy` 无新增警告 | `cargo clippy` |
|
||||
| AC7 | `ResponseTool::Function` 序列化后的 JSON 结构与改动前一致 | 单测:验证字段顺序和值 |
|
||||
|
||||
---
|
||||
|
||||
**编写人**:Writer Agent
|
||||
**编写日期**:2026-07-20
|
||||
**基于**:agcore builtin_tools 注入问题分析结论
|
||||
@@ -0,0 +1,249 @@
|
||||
# PDD:Usage 字段反序列化容错方案
|
||||
|
||||
**状态**:Draft
|
||||
**作者**:Think Agent
|
||||
**日期**:2026-07-27
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
为 `agcore::Usage` 结构体的必填 token 字段添加 `#[serde(default)]` 反序列化容错,使 OpenAI Response API 和 Chat Completions API 的 blocking 调用在火山平台返回不完整的 `usage` 字段时不再崩溃。
|
||||
|
||||
### 触发场景
|
||||
|
||||
dc-management 项目调用火山引擎 Responses API 时,非流式路径报错:
|
||||
|
||||
```
|
||||
OpenAI Response 响应解析失败 error=missing field 'prompt_tokens' at line 1 column 1581
|
||||
```
|
||||
|
||||
根因是 `Usage` 的三个 token 字段(`prompt_tokens`、`completion_tokens`、`total_tokens`)定义为必填 `u32`,而火山平台的 Responses API 返回的 `usage` 对象中这些子字段偶发缺失。
|
||||
|
||||
### 影响面
|
||||
|
||||
4/5 的 LLM 命令被阻断:
|
||||
|
||||
| 功能 | 命令 | 状态 |
|
||||
|------|------|------|
|
||||
| AI 探索(产品采集) | `llm_collect` → `workflow.rs` `.chat()` | ❌ 不可用 |
|
||||
| 普通聊天 | `llm_chat` → `llm.rs` `.chat()` | ❌ 不可用 |
|
||||
| 要素值正则化 | `llm_normalize_values` → `normalizer.rs` `.chat()` | ❌ 不可用 |
|
||||
| 数据验证 | `validator.rs` `.chat()` | ❌ 不可用 |
|
||||
| 连接验证 | `verify_llm_connection` → `chat_stream()` | ✅ 幸免(流式) |
|
||||
|
||||
## 2. 需求推演概要
|
||||
|
||||
### 需求拆解
|
||||
|
||||
- **核心需求**:blocking 路径下 `Usage` 反序列化不因缺失子字段而崩溃
|
||||
- **范围边界**:只改 serde 反序列化行为,不改字段类型,不改流式路径
|
||||
- **质量属性**:最小变更(±3 行)、零副作用、测试可验证
|
||||
|
||||
### 关键假设
|
||||
|
||||
| 假设 | 依据 | 验证方式 |
|
||||
|------|------|---------|
|
||||
| `Usage` 已 derive `Default` | 代码确认 | 编译通过 |
|
||||
| 缺失字段默认 0 对所有消费者安全 | `CostTracker::add` 使用 `saturating_add`;流式路径已有 `unwrap_or(0)` 行为 | 代码审查 |
|
||||
| `#[serde(default)]` 不影响序列化 | serde 明确语义 | 代码审查(serde 明确语义) |
|
||||
| `usage: null` 不会出现 | 当前未观察到,留 `ponytail:` 注释 | 生产观察 |
|
||||
| Chat Completions blocking 路径存在同源缺失风险 | 两路径共用火山平台底层 API 基础设施 | 在 §5.4 添加 `#[serde(default)]` 预防性加固(经 PRD 作者推演阶段确认) |
|
||||
|
||||
## 3. 当前问题分析
|
||||
|
||||
### 根因
|
||||
|
||||
- `src/llm/types/usage.rs:4-12`:`Usage` 结构体的 `prompt_tokens`、`completion_tokens`、`total_tokens` 定义为 **必填 `u32`**,非 `Option<u32>`
|
||||
- `src/llm/provider/openai_response.rs:207`:`OpenaiResponseBody` 内嵌 `usage: Usage`,也是必填
|
||||
- `src/llm/provider/openai.rs:245`:`OpenaiChatResponse` 内嵌 `usage: Usage`,同样必填
|
||||
- 火山平台 Responses API 返回的 `usage` 对象中,子字段缺失 → serde 反序列化 `missing field` 错误
|
||||
- 流式路径不受影响:其 `PartialUsage` 所有字段均为 `Option<u32>`,缺失时通过 `unwrap_or(0)` 兜底
|
||||
|
||||
### 已知线索
|
||||
|
||||
- dc-management 的 `verify_llm_connection` 已预见到此问题,注释特意说明使用流式规避
|
||||
- 这是已知的设计约束——流式已容错但 blocking 路径未同步加固
|
||||
|
||||
## 4. 架构决策记录
|
||||
|
||||
| 决策 | 选项 | 选择 | 理由 |
|
||||
|------|------|------|------|
|
||||
| 容错机制 | `#[serde(default)]` vs 改为 `Option<u32>` vs 自定义 `Deserialize` | `#[serde(default)]` | 最小变更,不改类型语义 |
|
||||
| 覆盖范围 | 仅 Responses API vs 同时覆盖 Chat Completions | 同时覆盖 | 增量成本≈0,防患于未然(经 PRD 作者推演阶段确认) |
|
||||
| 测试范围 | 2 个场景 vs 4 个场景 | 4 个场景 | 必要的边界覆盖(缺失 key / 缺失字段 / 空对象 / 完整回归) |
|
||||
|
||||
## 5. 设计方案
|
||||
|
||||
### 5.1 变更概览
|
||||
|
||||
**3 行 serde attribute + 4 个测试用例,零逻辑变更。**
|
||||
|
||||
### 5.2 变更一:`Usage` 结构体加 struct-level `#[serde(default)]`
|
||||
|
||||
**文件**:`src/llm/types/usage.rs:3`
|
||||
|
||||
```diff
|
||||
- #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
+ #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
+ #[serde(default)]
|
||||
pub struct Usage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub completion_tokens_details: Option<CompletionTokensDetails>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_tokens_details: Option<PromptTokensDetails>,
|
||||
}
|
||||
```
|
||||
|
||||
**作用**:JSON 中缺失 `Usage` 的 **任何** 子字段时,自动使用 `Default::default()` 取值:
|
||||
- 三个 token 字段(`u32`):缺失时默认 `0`
|
||||
- 两个 `Option` 详情字段:缺失时默认 `None`
|
||||
|
||||
**为什么 struct-level 够用**:struct-level 对子字段统一生效,不需要为每个字段单独标注。
|
||||
|
||||
### 5.3 变更二:`OpenaiResponseBody.usage` 加 `#[serde(default)]`
|
||||
|
||||
**文件**:`src/llm/provider/openai_response.rs:207`
|
||||
|
||||
```diff
|
||||
pub(crate) struct OpenaiResponseBody {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub output: Vec<ResponseOutputItem>,
|
||||
+ #[serde(default)]
|
||||
pub usage: Usage,
|
||||
pub status: String,
|
||||
}
|
||||
```
|
||||
|
||||
**作用**:整个 `usage` 键在 JSON 中完全缺失时,自动默认 `Usage::default()`。
|
||||
|
||||
### 5.4 变更三:`OpenaiChatResponse.usage` 加 `#[serde(default)]`
|
||||
|
||||
**文件**:`src/llm/provider/openai.rs:245`
|
||||
|
||||
```diff
|
||||
pub(crate) struct OpenaiChatResponse {
|
||||
pub id: String,
|
||||
pub object: String,
|
||||
pub created: u64,
|
||||
pub model: String,
|
||||
pub choices: Vec<Choice>,
|
||||
+ #[serde(default)]
|
||||
pub usage: crate::llm::types::usage::Usage,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system_fingerprint: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub service_tier: Option<ServiceTier>,
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 自动受益路径(无需变更)
|
||||
|
||||
| 路径 | 字段 | 为何已安全 |
|
||||
|------|------|-----------|
|
||||
| `OpenaiChatChunk.usage` | `Option<Usage>` | `Option` 天然兜底 `None`;`Some({不全})` 被 `Usage` struct-level `#[serde(default)]` 兜住。注意:此路径仅用于流式反序列化,API 流式 last chunk 的 usage 通常完整,受益场景概率极低 |
|
||||
| `MessageResponse.usage` | `Usage`(IR 层) | IR 层不直接反序列化 JSON,只从 provider 传递已解析的值 |
|
||||
|
||||
### 5.6 未覆盖的已知边界
|
||||
|
||||
```rust
|
||||
// ponytail: #[serde(default)] handles missing key; null usage not observed from API,
|
||||
// but if it appears, add deserialize_with to map null → Usage::default()
|
||||
```
|
||||
|
||||
`"usage": null` 会导致 serde 尝试将 `null` 反序列化为 `Usage` 结构体,当前方案无法兜底。当前未观察到该行为,暂不处理。
|
||||
|
||||
### `total_tokens` 缺失时的语义不一致
|
||||
|
||||
当 API 返回 `{"prompt_tokens": 10, "completion_tokens": 5}` 但缺失 `total_tokens` 时,struct-level `#[serde(default)]` 使 `total_tokens = 0`,而非 `10 + 5 = 15`。`CostTracker::add` 使用 `saturating_add` 不会崩溃,但聚合统计中 `total_tokens` 可能不等于 `prompt_tokens + completion_tokens` 之和。此行为与流式 `PartialUsage::into_usage()` 的 `unwrap_or(0)` 一致,属于已知的简化取舍。
|
||||
|
||||
### Responses API 流式路径 usage 恒为零(pre-existing)
|
||||
|
||||
`ResponseSseMeta`(`openai_response.rs:335-340`)不包含 `usage` 字段,导致流式 `response.completed` 事件的 token 用量信息未被捕获,`MessageComplete.full_response.usage` 恒为零。这是一个独立于本次变更的 pre-existing 缺陷。阻塞路径加 `#[serde(default)]` 后,两条路径行为一致(皆为零值),不引入新差异。此问题建议作为后续独立跟踪项处理。
|
||||
|
||||
### 5.7 效应链路
|
||||
|
||||
```
|
||||
Usage struct-level #[serde(default)]
|
||||
├─ OpenaiResponseBody.usage: Usage ← 子字段缺失兜住
|
||||
│ └─ field-level #[serde(default)] ← 整个 key 缺失兜住
|
||||
├─ OpenaiChatResponse.usage: Usage ← 子字段缺失兜住(自动受益)
|
||||
│ └─ field-level #[serde(default)] ← 整个 key 缺失兜住(新增)
|
||||
└─ OpenaiChatChunk.usage: Option<Usage> ← 子字段缺失兜住(自动受益,无需改动)
|
||||
└─ Option 已有 skip_serializing_if ← None 时跳过
|
||||
```
|
||||
|
||||
## 6. 实施步骤
|
||||
|
||||
| 步骤 | 文件 | 操作 | 验证 |
|
||||
|------|------|------|------|
|
||||
| 1 | `src/llm/types/usage.rs:3` | 加 `#[serde(default)]` | `cargo build` |
|
||||
| 2 | `src/llm/provider/openai_response.rs:207` | 加 `#[serde(default)]` | `cargo build` |
|
||||
| 3 | `src/llm/provider/openai.rs:245` | 加 `#[serde(default)]` | `cargo build` |
|
||||
| 4 | `openai_response.rs` tests 模块 | 加 4 个测试用例 | `cargo test` |
|
||||
| 5 | 全量检查 | `cargo test && cargo clippy` | 无失败/新增警告 |
|
||||
|
||||
**预计时长**:30 分钟(含测试编写与验证)。
|
||||
|
||||
## 7. 验证标准
|
||||
|
||||
- [ ] `Usage` 结构体三个 token 字段在 JSON 缺失时不报错,默认值为 `0`
|
||||
- [ ] `OpenaiResponseBody.usage` 键在 JSON 中完全缺失时不报错,默认 `Usage::default()`
|
||||
- [ ] `OpenaiChatResponse.usage` 键在 JSON 中完全缺失时不报错,默认 `Usage::default()`
|
||||
- [ ] 序列化行为不受影响(输出 JSON 仍包含全部 token 字段)
|
||||
- [ ] `cargo test` 全部通过
|
||||
- [ ] `cargo clippy` 无新增警告
|
||||
- [ ] dc-management 项目 `llm_collect`(AI 探索)命令正常返回结果(agcore 发布后由 dc-management 侧执行)
|
||||
- [ ] dc-management 项目 `llm_chat`(普通聊天)命令正常响应(agcore 发布后由 dc-management 侧执行)
|
||||
- [ ] 序列化后反序列化 roundtrip 验证字段值不变
|
||||
- [ ] `CostTracker` 和 `session.usage()` 的聚合逻辑不依赖 `total_tokens == prompt_tokens + completion_tokens` 恒等式
|
||||
|
||||
### 测试清单
|
||||
|
||||
| 测试名 | 场景 | 输入 | 断言 |
|
||||
|--------|------|------|------|
|
||||
| `deserialize_missing_usage_fields` | usage 存在但缺子字段 | `"usage": {"prompt_tokens": 5}` | 缺省字段默认 0 |
|
||||
| `deserialize_missing_usage_key` | 整个 usage key 缺失 | 无 `usage` 字段 | `Usage::default()` |
|
||||
| `deserialize_empty_usage_object` | `usage: {}` | 空对象 | 全字段默认 0/None |
|
||||
| `deserialize_full_usage_with_details` | 完整 usage(含 details) | 含所有字段 | 正确解析,类型不变 |
|
||||
| `deserialize_usage_missing_prompt_tokens` | 精确复现报错场景:usage 存在但缺 `prompt_tokens` | `"usage": {"completion_tokens": 5, "total_tokens": 5}` | `prompt_tokens` 默认 0 |
|
||||
| `deserialize_response_body_missing_usage_key` | `OpenaiResponseBody` 上下文中 usage key 完全缺失 | 完整 `OpenaiResponseBody` JSON 无 `usage` | `usage == Usage::default()` |
|
||||
| `deserialize_chat_response_missing_usage_key` | `OpenaiChatResponse` 上下文中 usage key 完全缺失 | 完整 `OpenaiChatResponse` JSON 无 `usage` | `usage == Usage::default()` |
|
||||
| `deserialize_chat_response_missing_usage_fields` | `OpenaiChatResponse` 的 usage 存在但缺子字段 | 完整 `OpenaiChatResponse` JSON,`usage` 仅含 `prompt_tokens` | 缺省字段默认 0 |
|
||||
| `serialize_deserialize_roundtrip` | 序列化后反序列化,验证字段值不变 | 完整 `Usage` 结构体 | roundtrip 后字段值一致 |
|
||||
|
||||
## 8. 回滚方案
|
||||
|
||||
逐个 revert 三个文件中的 `#[serde(default)]` 行,删除对应的测试用例。回滚后功能恢复原状(无数据迁移、无配置变更)。
|
||||
|
||||
## 9. 非目标
|
||||
|
||||
- 不修改 `Usage` 字段类型(保持 `u32`,不改为 `Option<u32>`)
|
||||
- 不修改流式路径(`PartialUsage` / `ResponseSseMeta` 的 usage 捕获问题单独处理)
|
||||
- 不修改其他 provider(`Anthropic` / `Ollama`,它们有自己的反序列化逻辑)
|
||||
- 不处理 `"usage": null` 边界(未观察到,留 `ponytail:` 注释)
|
||||
|
||||
## 10. 风险评估
|
||||
|
||||
| 风险 | 影响 | 可能性 | 应对方向 |
|
||||
|------|------|--------|---------|
|
||||
| `"usage": null` 反序列化失败 | 崩溃 | 低 | 未观察到;留 ponytail 注释 |
|
||||
| 零值 Usage 掩盖 API 异常 | 计费数据不全 | 低 | `CostTracker::add` 可加 `warn!` 日志 |
|
||||
| `prompt + completion ≠ total` | 聚合语义不一致 | 低 | `PartialUsage` 已有同样行为,接受 |
|
||||
| 下游依赖升级后反序列化行为变化 | 无 | 极低 | 类型不变、字段名不变 |
|
||||
|
||||
## 11. 术语表
|
||||
|
||||
| 术语 | 定义 | 说明 |
|
||||
|------|------|------|
|
||||
| blocking 路径 | `LlmProvider::chat()` 非流式调用 | 单次 HTTP 请求,完整 JSON 响应后一次性解析 |
|
||||
| streaming 路径 | `LlmProvider::chat_stream()` 流式调用 | 通过 SSE 逐事件推送,usage 可选 |
|
||||
| `#[serde(default)]` | serde 属性宏 | 反序列化时缺失字段使用类型的 `Default` 实现填充 |
|
||||
|
||||
## 12. 历史版本
|
||||
|
||||
| 版本 | 日期 | 变更说明 |
|
||||
|------|------|---------|
|
||||
| v1 | 2026-07-27 | 初始版本,基于 PRD 1 推演 |
|
||||
@@ -0,0 +1,181 @@
|
||||
# PRD:Usage 字段反序列化容错
|
||||
|
||||
**状态**:Draft
|
||||
**作者**:proposal
|
||||
**日期**:2026-07-27
|
||||
|
||||
## 1. 核心目标
|
||||
|
||||
为 `agcore::Usage` 结构体的必填 token 字段添加 `#[serde(default)]` 反序列化容错,使 OpenAI Response API 的 blocking(非流式)调用在火山平台返回不完整的 `usage` 字段时不再崩溃。
|
||||
|
||||
## 2. 目标用户与场景
|
||||
|
||||
| 用户角色 | 使用场景 | 核心诉求 |
|
||||
|---------|---------|---------|
|
||||
| dc-management 使用者 | AI 探索(产品信息采集) | 点击「AI 探索」后能正常返回结果,不因 usage 缺失而报错 |
|
||||
| dc-management 使用者 | LLM 聊天 | 普通聊天功能正常响应 |
|
||||
| dc-management 使用者 | 要素值正则化(Phase 2) | 数据归一化流程不因 usage 解析失败而中断 |
|
||||
| agcore 下游 crate | 任何使用 `agcore::llm` 且后端可能不返回完整 usage 的项目 | 反序列化鲁棒性提升 |
|
||||
|
||||
## 3. 问题描述
|
||||
|
||||
### 3.1 报错信息
|
||||
|
||||
dc-management 项目调用火山引擎 Responses API 时,非流式路径报错:
|
||||
|
||||
```
|
||||
OpenAI Response 响应解析失败 error=missing field 'prompt_tokens' at line 1 column 1581
|
||||
```
|
||||
|
||||
### 3.2 影响面
|
||||
|
||||
经排查,以下功能被阻断(4/5 的 LLM 命令受影响):
|
||||
|
||||
| 功能 | 命令 | 状态 |
|
||||
|------|------|------|
|
||||
| AI 探索(产品采集) | `llm_collect` → `workflow.rs` `.chat()` | ❌ 不可用 |
|
||||
| 普通聊天 | `llm_chat` → `llm.rs` `.chat()` | ❌ 不可用 |
|
||||
| 要素值正则化 | `llm_normalize_values` → `normalizer.rs` `.chat()` | ❌ 不可用 |
|
||||
| 数据验证 | `validator.rs` `.chat()` | ❌ 不可用 |
|
||||
| 连接验证 | `verify_llm_connection` → `chat_stream()` | ✅ 幸免(流式) |
|
||||
|
||||
### 3.3 根因
|
||||
|
||||
- **`llm/types/usage.rs:4-12`**:`Usage` 结构体的 `prompt_tokens`、`completion_tokens`、`total_tokens` 定义为 **必填 `u32`**,非 `Option<u32>`
|
||||
- **`llm/provider/openai_response.rs:207`**:`OpenaiResponseBody` 内嵌 `usage: Usage`,也是必填
|
||||
- 火山平台的 Responses API 返回的 `usage` 对象中,三个必填 token 子字段**缺失**(`usage` 键存在但子字段不完整),触发了 serde 反序列化的 `missing field` 错误
|
||||
- 流式路径不受影响:其 `PartialUsage` 所有字段均为 `Option<u32>`,缺失时通过 `unwrap_or(0)` 兜底
|
||||
|
||||
### 3.4 已知线索
|
||||
|
||||
- dc-management 的 `verify_llm_connection`(连接验证函数)**已预见到此问题**,注释明确说明特意使用流式来规避(`llm.rs:368-371`)
|
||||
- 这是已知的设计约束——流式已容错但 blocking 路径未同步加固
|
||||
|
||||
## 4. 功能清单
|
||||
|
||||
### v1 必做
|
||||
|
||||
- **`Usage` 结构体加 struct-level `#[serde(default)]`**(`src/llm/types/usage.rs:3`):
|
||||
```diff
|
||||
- #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
+ #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
+ #[serde(default)]
|
||||
pub struct Usage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub completion_tokens_details: Option<CompletionTokensDetails>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_tokens_details: Option<PromptTokensDetails>,
|
||||
}
|
||||
```
|
||||
- 作用:JSON 中缺失 `Usage` 的**任何**子字段时,自动使用 `Default::default()` 取值
|
||||
- 三个 token 字段:缺失时默认 `0`
|
||||
- 两个 `Option` 详情字段(`completion_tokens_details` / `prompt_tokens_details`):缺失时默认 `None`
|
||||
- **类型全部保持不动**,不改为 `Option`,不丢失语义
|
||||
- 等效代码行数:1 行
|
||||
|
||||
- **`OpenaiResponseBody.usage` 加 `#[serde(default)]`**(`src/llm/provider/openai_response.rs:207`):
|
||||
```diff
|
||||
pub(crate) struct OpenaiResponseBody {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub output: Vec<ResponseOutputItem>,
|
||||
+ #[serde(default)]
|
||||
pub usage: Usage,
|
||||
pub status: String,
|
||||
}
|
||||
```
|
||||
- 作用:整个 `usage` 键在 JSON 中完全缺失时,自动默认 `Usage::default()`
|
||||
- 等效代码行数:1 行
|
||||
|
||||
**变更合计:2 行 serde 属性宏,零逻辑变更,零类型变更。**
|
||||
|
||||
### v1 可选
|
||||
|
||||
- 无(上述两项即可完整修复)
|
||||
|
||||
### v2 考虑
|
||||
|
||||
- 无——struct-level `#[serde(default)]` 已覆盖所有子字段,`completion_tokens_details` / `prompt_tokens_details` 的 `#[serde(default)]` 需求已在 v1 中一并解决
|
||||
- 流式路径的 `ResponseSseMeta` 不包含 `usage` 字段(`src/llm/provider/openai_response.rs:335-340`),`response.completed` 事件的 usage 信息未被捕获。这是一个独立的缺陷,与当前问题无关,建议单独处理
|
||||
|
||||
### 非目标
|
||||
|
||||
- 不修改 `Usage` 字段类型(保持 `u32`,不改为 `Option<u32>`)
|
||||
- 不修改流式路径(`PartialUsage` 的 usage 捕获问题单独处理)
|
||||
- 不修改其他 provider(`OpenaiChat` / `Anthropic`),它们有自己的反序列化逻辑
|
||||
|
||||
## 5. 边界与假设
|
||||
|
||||
| 边界 / 假设 | 来源 | 说明 |
|
||||
|-------------|------|------|
|
||||
| `Usage` 已 derive `Default` | 代码确认 | `#[serde(default)]` 直接使用 `Default` 实现(全字段 `0`) |
|
||||
| 缺失字段默认 0 对所有消费者安全 | 代码审查 | 所有消费方(`CostTracker::add`、`MessageResponse` 使用处)都在做 `saturating_add`,`0` 是安全值。流式路径的 `PartialUsage.into_usage()` 已经 `unwrap_or(0)`,行为一致 |
|
||||
| 序列化行为不受影响 | serde 语义 | `#[serde(default)]` 仅在反序列化缺失字段时生效,不影响序列化输出 |
|
||||
| 报错提示的 `missing field` 确认是子字段缺失而非整个 usage 缺失 | 错误消息分析 | 错误消息 `missing field 'prompt_tokens'` 说明 JSON 路径 `usage.prompt_tokens` 不存在即键 `usage` 存在但子字段缺失,因此 `#[serde(default)]` 加在子字段上是必要条件 |
|
||||
|
||||
## 6. 术语表
|
||||
|
||||
| 术语 | 定义 | 说明 |
|
||||
|------|------|------|
|
||||
| blocking 路径 | `LlmProvider::chat()` 非流式调用 | 发送单个 HTTP 请求,等待完整 JSON 响应后一次性解析 |
|
||||
| streaming 路径 | `LlmProvider::chat_stream()` 流式调用 | 通过 SSE 逐事件推送,usage 可选 |
|
||||
| `#[serde(default)]` | serde 属性宏 | 反序列化时如果字段缺失,使用类型的 `Default` 实现填充 |
|
||||
| Responses API | OpenAI 标准 `POST /responses` 协议 | 区别于 Chat Completions(`POST /chat/completions`) |
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
- [ ] `Usage` 结构体三个 token 字段在 JSON 缺失时不报错,默认值为 `0`
|
||||
- [ ] `OpenaiResponseBody.usage` 键在 JSON 中完全缺失时也不报错,默认值为 `Usage::default()`
|
||||
- [ ] dc-management 的 `llm_collect`(AI 探索)命令正常返回结果
|
||||
- [ ] dc-management 的 `llm_chat`(普通聊天)命令正常响应
|
||||
- [ ] 已有序列化行为不受影响(输出 JSON 仍包含全部 token 字段)
|
||||
- [ ] `cargo test` 全部通过
|
||||
- [ ] `cargo clippy` 无新增警告
|
||||
- [ ] 补两条反序列化测试:
|
||||
- `response_api_missing_usage_fields` — mock 响应中 `usage` 对象缺失 `prompt_tokens` / `completion_tokens` / `total_tokens`,验证不报错且 usage 字段全为 0
|
||||
- `response_api_missing_usage_key` — mock 响应中完全没有 `usage` 键,验证不报错且默认 `Usage::default()`
|
||||
|
||||
## 8. 风险评估
|
||||
|
||||
| 风险 | 影响 | 可能性 | 应对方向 |
|
||||
|------|------|--------|---------|
|
||||
| 下游 crate 升级 agcore 后依赖 usage 字段不缺失 | 无——`#[serde(default)]` 不改变已有行为 | 极低 | 类型不变、字段名不变 |
|
||||
| 序列化时 `#[serde(default)]` 影响输出 | 无——`#[serde(default)]` 只影响反序列化 | 极低 | serde 明确语义 |
|
||||
| 测试覆盖不足 | 当前 mock 响应均携带完整 usage,不会触发新路径 | 中 | 建议加一条 usage 缺失的测试用例 |
|
||||
|
||||
## 9. 发布计划(可选)
|
||||
|
||||
| 阶段 | 范围 | 时间 |
|
||||
|------|------|------|
|
||||
| v1 | 两处变更(usage.rs 3 字段 + openai_response.rs 1 字段) | 即日 |
|
||||
| 发布 | 打 tag(如 `v0.3.6` 或 `v0.3.5-usage-fix`),更新 dc-management 引用 | 即日 |
|
||||
|
||||
## 10. 历史版本
|
||||
|
||||
| 版本 | 日期 | 变更说明 |
|
||||
|------|------|---------|
|
||||
| v1 | 2026-07-27 | 人工种子(原始) |
|
||||
|
||||
### 种子内容
|
||||
|
||||
发起方:dc-management 项目 `src/routes/products/ai-explore/` 页面「AI 探索」功能报错
|
||||
|
||||
报错信息:
|
||||
|
||||
```
|
||||
OpenAI Response 响应解析失败 error=missing field 'prompt_tokens' at line 1 column 1581
|
||||
```
|
||||
|
||||
根因:
|
||||
|
||||
- `src/llm/types/usage.rs` 中 `Usage` 结构的 `prompt_tokens`, `completion_tokens`, `total_tokens` 为必填 `u32`
|
||||
- `src/llm/provider/openai_response.rs` 的 `OpenaiResponseBody` 内嵌 `usage: Usage`
|
||||
- 火山平台 Responses API 返回的 usage 子字段缺失 → serde 反序列化失败
|
||||
|
||||
修复方向:
|
||||
|
||||
- `Usage` 三字段加 `#[serde(default)]`(缺失时默认 0)
|
||||
- `OpenaiResponseBody.usage` 加 `#[serde(default)]`(整个缺失时默认 Usage::default())
|
||||
@@ -5,7 +5,8 @@
|
||||
> **已分版本的内容**:请查阅
|
||||
> - [`roadmap-v0.1.0.md`](./roadmap-v0.1.0.md) — Phase 0–4c + v0.1.0 Release
|
||||
> - [`roadmap-v0.2.0.md`](./roadmap-v0.2.0.md) — Phase 5–12 + v0.2.0-rc.1
|
||||
> - [`roadmap-v0.3.0.md`](./roadmap-v0.3.0.md) — Phase 13–19(13-18 已完成,19 待实施)
|
||||
> - [`roadmap-v0.3.0.md`](./roadmap-v0.3.0.md) — Phase 13–19(全部完成)
|
||||
> - [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md) — Phase A-E 多 Agent 编排路线图
|
||||
>
|
||||
> 返回总入口:[`roadmap.md`](./roadmap.md)
|
||||
|
||||
@@ -15,7 +16,7 @@
|
||||
|
||||
AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。
|
||||
|
||||
**当前状态**:v0.2.0-rc.1 已打标签。Phase 0-18 全部完成。v0.3.0 实施中,Phase 19 共 1 个增量 Phase 待交付。目标是从"LLM 调用工具箱"升级为"能构建多 Agent 协作、RAG、长记忆 Agent 产品的基础系统"。
|
||||
**当前状态**:v0.3.5。Phase 0-30 全部完成。v0.4.0 规划已确定,覆盖 5 个增量 Phase(A-E):Swarm 编排抽象、结果聚合、Human-in-the-loop + 用户 Steering、TokenJuice 语义压缩、自动校正。目标是从"多 Agent 基础系统"升级为"多 Agent 多职责编排系统"。
|
||||
|
||||
---
|
||||
|
||||
@@ -35,21 +36,30 @@ AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可
|
||||
|
||||
---
|
||||
|
||||
## v0.4+ 展望
|
||||
## v0.4.0 规划
|
||||
|
||||
### 已规划的功能
|
||||
v0.4.0 的完整规划已移入独立的 [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md),包含 5 个增量 Phase:
|
||||
|
||||
| 功能 | 说明 | 预计版本 |
|
||||
|------|------|---------|
|
||||
| Multi-Agent Swarm 编排 | Supervisor/Subgraph 模式,基于 v0.3 dispatch 构建 | v0.4 |
|
||||
| Human-in-the-loop 审批 | `interrupt()` + `Command(resume=...)` 异步审批回调 | v0.4 |
|
||||
| Agent 自动创生 | LLM 自主决定何时派发子 agent、派发什么角色 | v0.4 |
|
||||
| 分布式 session 共享 | SessionManager Redis 后端支持跨进程 | v0.4 |
|
||||
| 精确 tokenizer 计数 | 引入 `tiktoken-rs`,绑定模型具体 tokenizer,替换字符估算 | v0.4+ |
|
||||
| TokenJuice 语义压缩 | 对工具结果做语义压缩而非字节截断 | v0.4+ |
|
||||
| Markdown 技能按需加载 | 技能注册表 + 按 prompt 上下文动态加载 | v0.4+ |
|
||||
| 增量 checkpoint | 仅存储变化部分,替换当前全量 JSON 模式 | v0.4+ |
|
||||
| RL 轨迹导出 | ShareGPT 格式轨迹、Atropos 集成 | v0.4+ |
|
||||
| Phase | 内容 | 状态 |
|
||||
|-------|------|------|
|
||||
| **Phase A** | Swarm 编排(Star/Sequential/Hierarchical + Subgraph) | 📋 待实施 |
|
||||
| **Phase B** | 结果聚合 + 编排模式完善 | 📋 待实施 |
|
||||
| **Phase C** | Human-in-the-loop + 用户 Steering | 📋 待实施 |
|
||||
| **Phase D** | TokenJuice 语义压缩(工具结果/历史/跨 Agent) | 📋 待实施 |
|
||||
| **Phase E** | 自动校正 / Reflection | 📋 待实施 |
|
||||
|
||||
### 未来版本(v0.5+)
|
||||
|
||||
以下功能已从 v0.4 范围移出:
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| Agent 自动创生 | LLM 自主决定何时派发子 agent — 设计复杂,v0.4 专注显式声明式编排 |
|
||||
| 分布式 session 共享(Redis 后端) | 与编排正交,多数用户单进程即可 |
|
||||
| 精确 tokenizer 计数(tiktoken-rs) | 依赖引入,不在 v0.4 核心范围内 |
|
||||
| 增量 Checkpoint | 存储优化,当前全量 JSON 够用 |
|
||||
| 路线 B(StateGraph 通用图引擎) | 预留为路线 A 的未来升级路径 |
|
||||
| RL 轨迹导出 | 专项需求 |
|
||||
|
||||
### 明确不做(agcore 范围外)
|
||||
|
||||
@@ -74,10 +84,10 @@ AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可
|
||||
|
||||
## 下一步行动
|
||||
|
||||
1. **v0.3.0 Phase 19 启动**:KnowledgeGraph + 双通道检索,落地 `docs/note-knowledge-graph-design.md` 中记录的知识图谱设计
|
||||
2. **Phase 19 收尾**:完成 v0.3.0 最后一个 Phase 后准备 rc.1 标签 + CHANGELOG
|
||||
3. **示例先行**:完成 Phase 19 后立即创建对应的 knowledge_graph_demo 示例,确保 `cargo run --example` 可验证
|
||||
4. **里程碑追踪**:以 M13(Phase 17)+ M14(Phase 18)为已达成里程碑,逐 Phase 推进 M15
|
||||
1. **v0.4.0 启动**:按 [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md) 规划,从 Phase A(Swarm 编排)开始实施
|
||||
2. **Phase A 实施**:engine/supervisor.rs + tools/builtin.rs + Swarm::star/sequential/hierarchical
|
||||
3. **示例先行**:每个 Phase 交付时同步提交对应的示例程序
|
||||
4. **里程碑追踪**:以 M16-M20 为目标里程碑,逐 Phase 推进
|
||||
|
||||
---
|
||||
|
||||
@@ -107,3 +117,84 @@ AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可
|
||||
- ✅ **v0.3.0 Phase 16 完成** — `SummaryConfig` 配置结构体(6 个字段:`trigger_token_ratio=0.75` / `max_context_tokens=32_000` / `summary_prompt` / `debounce_turns=3` / `summary_model=None` / `max_tool_result_chars=500`,默认 `None` 沿用主模型避断裂非 OpenAI 用户)+ `AgentBuilder::summary_config(cfg)` 链式方法 + `AgentConfig.summary_config: Option<SummaryConfig>` 字段;`AgentSession` 新增 `last_summary_turn: Option<u32>` 字段(首次不受防抖约束,`should_summarize` 用 `Option` 哨兵实现)+ `maybe_summarize(current_turn)` 内联检查点(OnTurnEnd 之后 / `turn_index` 之前,对称 `submit_turn` / `finalize_turn` 两个入口,流式路径 `saturating_sub(1)` 修正)+ 关联函数 `generate_summary`(构造独立 `LlmCycle` 调 `submit_messages` 传 `vec![Message::user_text(prompt)]`,`max_tokens=1024`,空消息守卫直接返回空串)+ 公开 API `get_conversation_summary()`;`src/agent/summary.rs`(~240 行,含 8 个 SummaryConfig/`format_messages_as_text` 内联测试——默认值/空输入/系统用户助理/ToolResult(含 `tool_call_id`)/工具调用/Unicode 安全截断/整体 30K 截断保留最新;有效字符数截断多字节安全,droptest 验证保留尾部消息)+ `src/agent/session.rs` 注入 10 个摘要集成测试(默认值不触发 / 超阈值触发 / 防抖阻止重复 / SessionMemory 写入 / Full 模式不注入 / 失败不阻断主流程 / 流式路径触发 / 默认配置零影响 / **Focused `summary_override` 写入正向验证** / **空消息不调用 LLM** / **巨型 `max_context_tokens` 永不触发**);`format_messages_as_text` 简洁版消息格式化(`[Tool: name]` + `Tool Result [id]:` + ToolResult 字符级 `chars().take(max_tool_result_chars)` 截断 + 整段 30K 总长度截断从头部保留最新);所有错误静默(失败用 `tracing::error!`,成功用 `tracing::info!(turn, summary_len)`);`MergeStrategy` 注释中过时 "Summarize 指向"与 `context.rs:78` "v0.3 将支持 Hook 驱动" 过时注释在实施时同步移除/更新;方案文档 `docs/22-phase16-summary-auto-generation.md`(471 行),实施后**两轮审查 PASS**:第一轮 PM/SA 审查 11 项问题修复 + 第二轮实施审查 9 项问题修复(🔴 `generate_summary` 空消息 bug + 🟡 W4 流式路径防抖 + 🟡 W2 模型硬编码 + 🟡 W5 Full 模式无谓 save + 🟡 W3 30K 截断 + 🟡 W6 成功无日志 + 🟡 W1/W7 测试补全 + 💭 注释同步);零新外部依赖;全量 335 → **353**(+18 新测试,含二次审查增补 4 个),clippy 0 警告,doc 0 warning,`quick_start` 示例正常 exit 0;**M12 里程碑达成** + 第二轮审查门禁 PASS
|
||||
- ✅ **v0.3.0 Phase 17 完成** — 新建 `src/engine/` 模块(5 文件:`mod.rs`/`error.rs`/`snapshot.rs`/`checkpointer.rs`/`session_manager.rs`),实现 **SessionManager**(10 个公开方法:`create`/`create_child`/`get`/`recover`/`replace`/`children`/`parent`/`destroy`/`submit_turn`/`submit_turn_stream`/`finalize_turn_stream`,内部 `RwLock<HashMap>` + `Arc<tokio::sync::Mutex<AgentSession>>` + `Checkpointer` 组合)和 **Checkpointer**(5 个公开方法:`checkpoint`/`rollback_load`/`list_checkpoints`/`delete_all`/`latest_snapshot`);`SessionSnapshot` 独立 struct 避开 `Arc<dyn Agent>` 不可序列化,配套 `SessionMemoryEntry` 保留 metadata/created_at;`AgentSession` 扩展三段式快照(`to_snapshot` async 读 MemoryStore + `from_snapshot` 纯同步构造 + `restore_memory` &mut self async 写回持久层);`SessionMemory` 新增 `list_entries()` 和 `set_with_meta()` 方法(恢复时保留完整 entry 数据);存储 key 风格统一为 `session:{id}:meta` / `ckpt:{id}:{ckpt_id}`(与 `slot_data:` 风格一致);`EngineError` 6 个变体(含 `Memory(#[from] MemoryError)` 透传 + `Agent(#[from] AgentError)`);`CkptMeta` 加 `created_at_nanos` 字段确保同秒内精确降序排序;ckpt_id 用纳秒+单调计数器生成(零外部依赖,ponytail);session_id 用纳秒+计数器自动生成(统一策略,UUID v4 备选);自动 checkpoint 失败 `tracing::error!` 不阻断主流程(不提供强持久化保证);流式 checkpoint 仅在 `finalize_turn_stream` 创建(不留半成品污染);孤儿策略:`destroy()` 不递归删除子 session,父被销毁后 `parent()` 返回 `Ok(None)`;3 处 derive 改动(`CostTracker` + `ContextSlot` + `MergeStrategy` 加 serde,`CostTracker` 额外加 `Clone`);`SessionManager::recover` + `replace` 内部自动 `restore_memory` 写回持久层;零新外部依赖;方案文档 `docs/23-phase17-agent-execution-engine.md`(775 行,经两轮 PM+SA 审查 + 实施后第三轮 PM+SA+Code Reviewer 三方联合审查),实施后**两轮审查门禁 PASS**:第一轮修复 6 🔴 + 第二轮修复 2 🔴(to_snapshot 同步→async + Roadmap 同步)+ 实施后修复 8 个 🟡(restore_memory metadata/created_at 完整恢复 + &mut self 签名 + 死代码清理 + 3 个边界测试 + tracing 补全 + 文档语义统一 + 示例 rollback 一致性 assert);15 个 `SessionManager` 内联测试(CRUD/recover/replace/树形/孤儿/auto_checkpoint on-off)+ 6 个 `Checkpointer` 内联测试(roundtrip/不存在的 ckpt/同秒降序/delete_all 幂等/latest/隔离)+ 1 个 `snapshot_deserialize_with_minimal_fields` 序列化兼容测试;全量 353 → **374**(+21 新测试),clippy 0 警告,doc 0 warning,`engine_demo` 示例端到端演示 create→submit_turn→checkpoint→rollback→replace→destroy 全链路并验证 rollback 一致性;**M13 里程碑达成** + 两轮审查门禁 PASS
|
||||
- ✅ **v0.3.0 Phase 18 完成** — 新增 `src/engine/switch.rs`(222 行)实现 `SessionManager::switch_agent()` 热切换(替换 `Arc<dyn Agent>`,slot 历史 / `turn_index` / `session_memory` / `cost_so_far` 全部保留,同步更新 `SessionMeta.agent_name` 到持久层,`created_at` / `parent_id` 保持原始不可变)+ 新增 `src/engine/sub_agent.rs`(1071 行)实现 4 个公开方法(`dispatch` / `dispatch_all` / `dispatch_stream` 与前述 `switch_agent` 共 4 个 Phase 18 核心 API)+ 3 个公开类型(`DispatchConfig` / `SubTaskResult` / `SubTaskStreamEvent`);`DispatchConfig` 4 字段(`max_concurrency=10` / `inherit_session_memory=true` / `bridge_keys=None` / `shared_namespace=None`)+ 三态 `bridge_keys` 语义(`None` = 不继承 / `Some(vec![])` = 全部 / `Some(keys)` = 指定 keys)+ 约定式 `shared_namespace` 子↔子共享(`shared:{prefix}:{key}`)不触发自动注入;`dispatch` 流程:`create_child` → `inherit_session_memory`(快照语义)→ `submit_turn` → 返回 `SubTaskResult`;`dispatch_all` `tokio::sync::Semaphore` 并发控制 + `Vec<Result<...>>` 部分成功语义按输入顺序 indexed 收集;`dispatch_stream` `unbounded_channel` + spawn task 消息重建 + `finalize_turn` 后台落库(明确不参与 `auto_checkpoint` 防重复);`SubTaskStreamEvent` 事件序列:`ChildCreated` → `Stream(StreamEvent) × N` → `Completed(SubTaskResult)` 或 `Error { child_id, error }`;`EngineError` 新增 `DispatchFailed(#[source] String)` 变体 + `CostTracker` 加 `From<Usage>` 转换;`save_session_meta` / `load_session_meta` 改 `pub(crate)` 供 `switch.rs` 调用;4 个端到端示例:`agent_switch_demo`(115 行)+ `sub_agent_dispatch_demo`(141 行)+ `bridge_keys_demo`(197 行)+ `dispatch_stream_demo`(121 行)全部 exit 0;17 个内联测试(4 switch + 5 dispatch + 4 dispatch_all + 4 dispatch_stream);零新外部依赖;方案文档 `docs/24-phase18-agent-switch-and-dispatch.md`(700 行);全量 374 → **391**(+17 新测试,0 失败),clippy 0 警告,doc 0 warning;**M14 里程碑达成**
|
||||
- ✅ **v0.3.0 Phase 19 完成** — 知识图谱 + 双通道检索,详见 `docs/25-phase19-knowledge-graph-and-retrieval.md`;全量 391 → **427 passed / 0 failed**(+36 新测试);**M15 里程碑达成**
|
||||
- ✅ **v0.3.2 Phase 20-27 全部完成** — Cargo features 拆分(16 模块级 + 5 provider + 4 快捷组合),详见 `docs/roadmap-v0.3.2.md`;全量 427 → **427 passed**(不变,门控验证)
|
||||
- ✅ **Phase 28-30 OpenAI Response API Provider 完成** — 独立 feature `provider-openai-response`,全量约 450 passed
|
||||
- 📋 **v0.4.0 规划完成** — 5 个增量 Phase(A-E)覆盖多 Agent 编排、HITL + Steering、TokenJuice、自动校正。详见 [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md)
|
||||
|
||||
---
|
||||
|
||||
## 设计笔记
|
||||
|
||||
### Checkpointer 分层存储模型
|
||||
|
||||
> 来源:v0.4.0 规划讨论中涉及增量 Checkpoint 的技术推演。当前全量 JSON checkpoint 够用,但为未来优化预留设计方案。
|
||||
|
||||
#### 分层叠加模型(OverlayFS 模式)
|
||||
|
||||
受容器分层文件系统启发,增量 Checkpoint 可以借鉴 overlayfs 的"底层只读 + 上层可写叠加"设计:
|
||||
|
||||
**全量基座(只读)**:
|
||||
```rust
|
||||
pub struct SnapshotBase {
|
||||
pub checkpoint_id: String,
|
||||
pub session_id: String,
|
||||
pub snapshot: SessionSnapshot, // 完整 JSON 化状态
|
||||
}
|
||||
```
|
||||
|
||||
**增量层(叠加 diff)**:
|
||||
```rust
|
||||
pub struct SnapshotLayer {
|
||||
pub base_checkpoint_id: String,
|
||||
pub applies_to_id: String, // 在哪个 checkpoint 上叠加
|
||||
pub diff: Vec<DiffOp>, // JSON Patch 操作集合
|
||||
}
|
||||
|
||||
pub enum DiffOp {
|
||||
MessageAppended { message: Message },
|
||||
SlotChanged { slot_id: String, diff: serde_json::Value },
|
||||
TurnIndexIncremented { from: u32, to: u32 },
|
||||
CostUpdated { diff: CostTracker },
|
||||
}
|
||||
```
|
||||
|
||||
**重建路径**:
|
||||
```
|
||||
rollback_load("session_x", 6)
|
||||
→ 读取 "ckpt:{session_x}:base"(全量)
|
||||
→ 读取 "ckpt:{session_x}:layer:1" ~ "ckpt:{session_x}:layer:6"
|
||||
→ 依次应用 layer.1 → layer.2 → ... → layer.6
|
||||
→ 得到 session_6 的状态
|
||||
```
|
||||
|
||||
**层折叠(类似 docker squash)**:
|
||||
```
|
||||
layer.1 → layer.2 → layer.3 → layer.4 → layer.5
|
||||
↓ 合并
|
||||
base.ckpt'(包含 layer.1-3)→ layer.4 → layer.5
|
||||
```
|
||||
|
||||
#### Shadow FS 模型(运行中保护)
|
||||
|
||||
与分层模型互补,shadow 模型适用于运行中的 session 保护而非长期存储:
|
||||
|
||||
```rust
|
||||
// submit_turn 在 shadow session 上执行,commit 时才原子切换
|
||||
let shadow = current_session.fork(); // 复用 ContextSlot::fork
|
||||
let result = shadow.submit_turn(input).await;
|
||||
if result.is_ok() {
|
||||
current_session.commit(shadow); // 原子替换
|
||||
} else {
|
||||
drop(shadow); // 丢弃,当前 session 完好无损
|
||||
}
|
||||
```
|
||||
|
||||
#### 适用场景对比
|
||||
|
||||
| 模型 | 适合场景 | 不适合场景 |
|
||||
|------|---------|-----------|
|
||||
| **分层叠加(OverlayFS)** | Checkpoint 链长期存储、time-travel、多版本回退 | session 较小(< 10KB/轮)时复杂度不值得 |
|
||||
| **Shadow FS(CoW)** | 运行中 session 保护、防止 submit_turn 失败污染 | 不能替代 checkpoint 链、不支持多时间点回退 |
|
||||
|
||||
**触发条件**:当单 session checkpoint 超过 500KB 且频繁保存导致性能瓶颈时,考虑实现分层模型。
|
||||
@@ -0,0 +1,233 @@
|
||||
# AG Core Roadmap — v0.4.0
|
||||
|
||||
> 本文件聚焦 **v0.4.0 版本** 的规划。Phase A-E 计划中,覆盖多 Agent 编排、Human-in-the-loop 与 Steering、语义压缩、自动校正。
|
||||
> 返回总入口:[`roadmap.md`](./roadmap.md)
|
||||
|
||||
## v0.4.0 愿景
|
||||
|
||||
从 v0.3 的"多 Agent 基础系统"升级为"多 Agent 多职责编排系统"。补齐高层编排抽象(Swarm/Supervisor/Subgraph)、生产级人工干预能力(HITL + Steering)、工具与消息的语义压缩(TokenJuice),以及自动质量校正(Reflection)。为即将开发的多 Agent 协作产品提供完整的编排、干预与质量保证层。
|
||||
|
||||
## v0.4.0 总体范围
|
||||
|
||||
**总体规模**:5 个增量 Phase(Phase A-E),总新增代码约 1,950 行,零强制新外部依赖,零破坏性变更。
|
||||
|
||||
### 架构决策
|
||||
|
||||
**路线选择**:采用轻量编排模式(路线 A),不引入通用有向图引擎。通过 `Swarm::star()` / `Swarm::sequential()` / `Swarm::hierarchical()` 等具名模式提供编排能力,底层复用现有 `dispatch` / `create_child` / `SessionManager` 基础设施。预留路线 B(StateGraph 抽象)作为未来版本的升级路径。
|
||||
|
||||
**模块位置**:
|
||||
- 编排逻辑 → `src/engine/supervisor.rs`(新增)
|
||||
- 内建工具 → `src/tools/builtin.rs`(新增)
|
||||
- TokenJuice 压缩 → `src/llm/compress.rs`(新增)
|
||||
- Steering 机制 → `src/engine/steer.rs`(新增,或并入 supervisor.rs)
|
||||
|
||||
### 功能清单
|
||||
|
||||
#### P0 — 必须交付
|
||||
|
||||
| # | 功能 | 模块 | 方案要点 |
|
||||
|---|------|------|---------|
|
||||
| 1 | Swarm 编排(Star/Sequential/Hierarchical + Subgraph) | `engine/supervisor` | `Swarm::star().supervisor(A).worker(B)` 声明式 API;`Swarm::sequential().link(A).link(B)` 串联;`Swarm::hierarchical().supervisor(root).group("sub", ...)` 层次嵌套 |
|
||||
| 2 | 结果聚合 | `engine/supervisor` | `aggregation_prompt` 模板将子 Agent 结果合并到 Supervisor 上下文;`DispatchConfig` 扩展 `result_key` 字段 |
|
||||
| 3 | Human-in-the-loop 审批 | `engine/steer` | `interrupt()` 暂停执行 + `Command(resume=bool)` 恢复;`HookEvent::OnInterrupt` 新变体 |
|
||||
| 4 | 用户 Steering(运行中校正) | `engine/steer` | `Command(resume=Correction{...})` 结构化校正;Steer 消息在工具批处理边界注入 |
|
||||
| 5 | TokenJuice 语义压缩 | `llm/compress` | `Compressor` trait 统一抽象;覆盖工具结果、对话历史、跨 Agent 消息三层;LLM 摘要压缩 + 确定性兜底 |
|
||||
|
||||
#### P1 — 推荐交付
|
||||
|
||||
| # | 功能 | 模块 | 方案要点 |
|
||||
|---|------|------|---------|
|
||||
| 6 | 自动校正 / Reflection | `engine/reflect` | Evaluator-Optimizer 循环;Producer-Critic 角色分离;上限 2-3 轮迭代 |
|
||||
|
||||
### 实施计划 — 5 个增量 Phase
|
||||
|
||||
> **编号说明**:Phase A-E 为 v0.4.0 专属编号,接续已完成的 Phase 30。
|
||||
|
||||
---
|
||||
|
||||
#### Phase A: Swarm 编排抽象(Star / Sequential / Hierarchical + Subgraph)
|
||||
|
||||
**目标**:在现有 `dispatch` 原语基础上,提供声明式多 Agent 编排 API。Supervisor 作为 `Arc<dyn Agent>`,通过内建工具 `dispatch_sub_agent` 驱动子 Agent 执行。
|
||||
|
||||
**交付物**:
|
||||
1. `src/engine/supervisor.rs` 新文件:
|
||||
- `Swarm` 枚举/结构体:`Swarm::star()`(星型,一个 Supervisor + N 个 Worker)、`Swarm::sequential()`(顺序链 A→B→C)、`Swarm::hierarchical()`(层次嵌套,Supervisor 下的 Sub-Supervisor)
|
||||
- 各模式的 `build()` 和 `run(input)` 方法
|
||||
- 底层通过 `SessionManager::dispatch()` / `dispatch_all()` 实现
|
||||
2. `src/tools/builtin.rs` 新文件:
|
||||
- `dispatch_sub_agent(name, task, config)` 内建工具 — 从 Agent 注册表查找 Agent 工厂 → `SessionManager::dispatch()`
|
||||
3. `AgentRegistry`:`HashMap<String, Box<dyn Fn() -> Arc<dyn Agent>>>` 轻量工厂注册表(约 50 行)
|
||||
4. Subgraph 嵌套:`Swarm::hierarchical()` 支持 `group(name, inner_swarm)`,内层 Swarm 作为子节点编译后嵌入
|
||||
|
||||
**设计要点**:
|
||||
- Supervisor 就是 `Arc<dyn Agent>`,不新增 `SupervisorAgent` trait
|
||||
- 路由逻辑写在 Supervisor 的 system prompt 中(LLM 决定的动态路由)
|
||||
- 三种模式覆盖常见编排拓扑,不引入通用图引擎(路线 B 留作未来)
|
||||
- Subgraph 编译为独立的 `SessionManager` 子树(复用 `create_child` 的父子关系)
|
||||
|
||||
**依赖**:Phase 18(SubAgent dispatch / SessionManager)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 500 行
|
||||
**状态**:📋 待实施
|
||||
|
||||
---
|
||||
|
||||
#### Phase B: 结果聚合 + 编排模式完善
|
||||
|
||||
**目标**:让 Supervisor 能智能地合并 Worker 结果。完善三种编排模式的容错性和易用性。
|
||||
|
||||
**交付物**:
|
||||
1. `aggregation_prompt` 模板系统 — 内建 `DEFAULT_AGGREGATION_PROMPT`,用户可自定义聚合逻辑
|
||||
2. `DispatchConfig` 扩展:
|
||||
- `result_key: Option<String>` — 将子结果存入 `session_memory` 的指定 key,供后续阶段使用
|
||||
- `aggregate_strategy: AggregateStrategy` — `Concatenate` / `Summarize` / `Custom(Value)`
|
||||
3. 编排模式增强:
|
||||
- `Swarm::sequential()` 支持失败时停止 / 跳过 / 重试策略
|
||||
- `Swarm::star()` 支持 Worker 超时
|
||||
4. 端到端示例 3 个:
|
||||
- `swarm_star_demo.rs` — 星型编排 + 并发派发 + 结果聚合
|
||||
- `swarm_sequential_demo.rs` — 串联流水线
|
||||
- `swarm_hierarchical_demo.rs` — 层次嵌套(Supervisor → Sub-Supervisor → Worker)
|
||||
|
||||
**依赖**:Phase A
|
||||
**优先级**:P0
|
||||
**预估规模**:约 200 行
|
||||
**状态**:📋 待实施
|
||||
|
||||
---
|
||||
|
||||
#### Phase C: Human-in-the-loop + 用户 Steering
|
||||
|
||||
**目标**:生产级多 Agent 系统的关键门禁。提供执行中暂停-审批-恢复机制,以及用户运行中校正方向的能力。
|
||||
|
||||
**交付物**:
|
||||
1. `src/engine/steer.rs` 新文件:
|
||||
- `interrupt(value)` 函数 — 在工具循环中插入暂停点,持久化当前状态后返回控制权
|
||||
- `Command` 枚举:
|
||||
- `Command::Resume(bool)` — 二元审批(批准/拒绝)
|
||||
- `Command::ResumeWith(Correction)` — 结构化校正(修改工具参数 / 调整方向)
|
||||
2. `LlmCycle` 扩展:可中断工具循环模式
|
||||
- `submit_with_tools_interruptible()` — 支持在工具批处理边界检查中断信号
|
||||
- 中断时保存当前 `LlmCycle` 状态到 checkpoint
|
||||
3. `HookEvent::OnInterrupt` / `OnSteer` 新变体 — 监听中断和校正事件
|
||||
4. `SessionManager::resume_turn(session_id, resume_data)` — 从 checkpoint 恢复并注入审批结果
|
||||
5. `tools/builtin.rs` 扩展:
|
||||
- `request_approval(question, context)` — 请求用户审批
|
||||
- `emit_steer(correction)` — 用户校正
|
||||
6. Steering 生命周期:
|
||||
- `interrupt` → 用户收到提示 → 用户决定方向 → `Command::ResumeWith(correction)` → Agent 在新方向上继续
|
||||
|
||||
**设计要点**:
|
||||
- User Steering 不是简单的"批准/拒绝",而是 `Correction { action, reason, amended_params }` 结构化指令
|
||||
- Steering 消息在工具批处理边界(Worker 返回后、Supervisor 决策前)注入,不中断正在执行的工具
|
||||
- 继承 `ContextSlot::fork/merge` 模式,steer 前 fork 快照,允许用户回退到 steer 前状态
|
||||
|
||||
**依赖**:Phase A(Swarm 编排)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 500 行
|
||||
**状态**:📋 待实施
|
||||
|
||||
---
|
||||
|
||||
#### Phase D: TokenJuice 语义压缩
|
||||
|
||||
**目标**:替代当前字节级截断(`microcompact` 的 `[pruned]`),提供语义级别的压缩。在三层管道中接入:工具结果压缩、对话历史压缩、跨 Agent 消息压缩。
|
||||
|
||||
**交付物**:
|
||||
1. `src/llm/compress.rs` 新文件:
|
||||
- `Compressor` trait(`async fn compress(&self, input: &str, ctx: &CompressionContext) -> Result<String>`)
|
||||
- `CompressionContext`:`target_tokens` / `preserve_keys` / `strategy`
|
||||
- `CompressionStrategy` 枚举:`Semantic { model }`(LLM 摘要)、`Extractive { ratio }`(抽取式)、`Hybrid { semantic_first }`(混合)
|
||||
- `SemanticCompressor` 实现(复用已有 provider 做 LLM 摘要压缩)
|
||||
- `ExtractiveCompressor` 实现(确定性关键句提取,零 LLM 调用)
|
||||
2. 三层接入点:
|
||||
- **工具结果压缩**:在 `run_tool_loop` 中,`tool.execute()` 后插入 `compress_result()`,压缩结果再 `push ToolResult`
|
||||
- **对话历史压缩**:在 `load_messages()` 后插入 `compress_history()`,替代/补充 `microcompact`
|
||||
- **跨 Agent 消息压缩**:在 `inherit_session_memory` 的子 memory 写入前压缩(减少子 Agent 的 context 水位)
|
||||
3. `CycleConfig` / `CompactConfig` 扩展:
|
||||
- `token_compression: Option<CompressionConfig>` — 可选语义压缩配置
|
||||
- `fallback_to_microcompact: bool`(默认 `true`)— LLM 压缩失败时退化为字节截断
|
||||
4. TokenJuice 与现有 `microcompact` 的关系:
|
||||
- `microcompact` 保留为最轻量级兜底(零 LLM 调用)
|
||||
- TokenJuice 是可选增强层(默认关闭,用户 opt-in)
|
||||
|
||||
**设计要点**:
|
||||
- 零新外部依赖:LLM 摘要压缩复用已有 provider,抽取式压缩纯 Rust 实现
|
||||
- 与现有 `CompactState` 断路器模式兼容(LLM 压缩失败 3 次后自动降级到 `microcompact`)
|
||||
- `preserve_keys` 确保关键数据(数字、ID、SQL、代码片段)不被压缩掉
|
||||
|
||||
**依赖**:Phase 14(Embedding trait 可选参考)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 400 行
|
||||
**状态**:📋 待实施
|
||||
|
||||
---
|
||||
|
||||
#### Phase E: 自动校正 / Reflection
|
||||
|
||||
**目标**:实现 Agent 输出后的自我质量评估与自动修正循环。基于 `interrupt/resume` 基础设施,构建 Producer-Critic 闭环。
|
||||
|
||||
**交付物**:
|
||||
1. `src/engine/reflect.rs` 新文件:
|
||||
- `ReflectionConfig`:`max_cycles`(默认 2)/ `critic_agent`(可选不同模型)/ `criteria: Vec<String>`(评估标准)
|
||||
- `Reflectable` trait:`fn reflection_criteria(&self) -> Vec<String>` + `fn needs_refinement(&self, critique: &Critique) -> bool`
|
||||
- `ReflectionLoop`:`evaluate(output) → Critique` → `should_refine? → yes: refine(output, critique) → 循环 / no: 返回`
|
||||
2. Swarm 内建 Reflection 模式:
|
||||
- `Swarm::reflect(producer_agent, critic_agent)` — 专用 Reflection Swarm
|
||||
- 可在 Supervisor 流程中嵌入 `reflect_on(worker_result)` — 对 Worker 结果自动过一遍质量检查
|
||||
3. `Critique` 结构体:`issues: Vec<Issue>` / `score: f32` / `should_refine: bool` / `suggestions: Vec<String>`
|
||||
4. `tools/builtin.rs` 扩展:`verify_output(claim, evidence)` 工具 — 让 Agent 自行验证输出真实性
|
||||
|
||||
**设计要点**:
|
||||
- Producer 和 Critic 使用**不同模型**(避免同一模型的自我审查盲区 bias)
|
||||
- 上限 2-3 轮(第一轮修正捕获 70–80% 改善空间,第 4+ 轮收益递减)
|
||||
- 基于已有 `HookEvent::OnTurnEnd` 或扩展 `HookEvent::OnOutputGenerated` 触发反思
|
||||
- 失败静默:Reflection 失败不阻断主流程(`tracing::warn!` 后继续交付原始输出)
|
||||
|
||||
**依赖**:Phase C(interrupt/resume 基础设施)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 350 行
|
||||
**状态**:📋 待实施
|
||||
|
||||
---
|
||||
|
||||
### v0.4.0 Phase 依赖关系图
|
||||
|
||||
```mermaid
|
||||
graph BT
|
||||
PA["<b>Phase A: Swarm 编排</b><br/>Swarm::star/sequential/hierarchical<br/>Subgraph 嵌套<br/>内建 dispatch_sub_agent 工具<br/>~500 行"]:::pending
|
||||
PB["<b>Phase B: 结果聚合</b><br/>aggregation_prompt 模板<br/>DispatchConfig result_key<br/>编排模式完善<br/>3 个端到端示例<br/>~200 行"]:::pending
|
||||
PC["<b>Phase C: HITL + Steering</b><br/>interrupt/resume<br/>Command(ResumeWith Correction)<br/>HookEvent::OnInterrupt<br/>~500 行"]:::pending
|
||||
PD["<b>Phase D: TokenJuice</b><br/>Compressor trait<br/>工具结果/历史/跨 Agent 压缩<br/>Semantic + Extractive 策略<br/>~400 行"]:::pending
|
||||
PE["<b>Phase E: 自动校正</b><br/>ReflectionLoop<br/>Producer-Critic<br/>上限 2-3 轮<br/>~350 行"]:::pending
|
||||
|
||||
PB --> PA
|
||||
PC --> PA
|
||||
PE --> PC
|
||||
|
||||
classDef done fill:#4ade80,stroke:#16a34a,color:#1a1a1a
|
||||
classDef pending fill:#fbbf24,stroke:#d97706,color:#1a1a1a
|
||||
classDef future fill:#94a3b8,stroke:#64748b,color:#1a1a1a
|
||||
```
|
||||
|
||||
### 关键里程碑
|
||||
|
||||
| 里程碑 | Phase 完成条件 | 可验证指标 | 状态 |
|
||||
|--------|---------------|-----------|------|
|
||||
| **M16** | Phase A | `Swarm::star().supervisor(A).worker(B).run(input)` 端到端验证;`dispatch_sub_agent` 内建工具注册并可用;2 个示例 exit 0 | 📋 待启动 |
|
||||
| **M17** | Phase B | `Swarm::sequential()` 串联执行验证;`Swarm::hierarchical()` 层次嵌套验证;结果聚合正确合并;3 个新示例 exit 0 | 📋 待启动 |
|
||||
| **M18** | Phase C | `interrupt()` 暂停 + `Command::Resume(bool)` 恢复全链路验证;`Command::ResumeWith(Correction)` 结构化校正验证;HookEvent 触发验证 | 📋 待启动 |
|
||||
| **M19** | Phase D | 工具结果经语义压缩后保留关键信息(验证压缩比 ≥ 3:1);`microcompact` 降级路径验证;对话历史压缩验证 | 📋 待启动 |
|
||||
| **M20** | Phase E | ReflectionLoop 正确性验证:已知缺陷的输出被修复、无缺陷的输出不被修改(不变性保证);2 轮迭代上限验证;Critic 不同模型配置验证 | 📋 待启动 |
|
||||
|
||||
### 不做(v0.5+)
|
||||
|
||||
| 功能 | 原因 |
|
||||
|------|------|
|
||||
| Agent 自动创生(LLM 驱动动态分派) | 设计复杂且不确定性高,v0.4 专注显式声明式编排 |
|
||||
| 分布式 Session 共享(Redis 后端) | 与编排正交,大多数用户单进程即可 |
|
||||
| 精确 tokenizer 计数(tiktoken-rs) | 依赖引入,v0.4 专注编排与压缩能力本身 |
|
||||
| 增量 Checkpoint | 存储优化,当前全量 JSON 够用 |
|
||||
| 路线 B(StateGraph 通用图引擎) | 当前编排需求在路线 A 范围内,图引擎留给未来版本 |
|
||||
| RL 轨迹导出 | 专项需求,非通用 |
|
||||
| Markdown 技能按需加载 | 独立功能 |
|
||||
@@ -1,7 +1,7 @@
|
||||
# AG Core Roadmap
|
||||
|
||||
> 拆分式 roadmap:按版本归档 + 未归类内容
|
||||
> 最后更新:2026-07-19(v0.3.2 Step 3 完成 — Phase 26-27 CI 固化 + 文档更新交付,427 测试通过)
|
||||
> 最后更新:2026-07-21(v0.4.0 规划完成 — Phase A-E 多 Agent 编排路线图制定)
|
||||
|
||||
## 文件索引
|
||||
|
||||
@@ -11,11 +11,13 @@
|
||||
| [`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.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-v0.4.0.md`](./roadmap-v0.4.0.md) | v0.4.0 计划 — Phase A-E(Swarm 编排、HITL + Steering、TokenJuice 语义压缩、自动校正) | 📋 计划中 |
|
||||
| [`roadmap-unsorted.md`](./roadmap-unsorted.md) | 未归到任何版本的内容 — 全局愿景、当前状态、模块完整性、v0.4+ 展望、风险与建议、下一步行动、阶段总回顾 | — |
|
||||
|
||||
## 阅读建议
|
||||
|
||||
- **按版本顺序追溯历史**:v0.1.0 → v0.2.0 → v0.3.0
|
||||
- **按版本顺序追溯历史**:v0.1.0 → v0.2.0 → v0.3.0 → v0.4.0
|
||||
- **了解产品演进全貌**:从 `roadmap-unsorted.md` 顶部开始读
|
||||
- **查找特定 Phase**:每个版本文件内按 Phase 编号顺序排列
|
||||
- **了解项目当前关注点**:从 `roadmap-unsorted.md` 的「下一步行动」开始
|
||||
@@ -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-deepseek",
|
||||
feature = "provider-qwen",
|
||||
feature = "provider-ollama"
|
||||
feature = "provider-ollama",
|
||||
feature = "provider-openai-response"
|
||||
))]
|
||||
pub mod provider;
|
||||
/// Provider 抽象接口(trait + 能力元数据),仅依赖 `llm` feature,不引入 reqwest。
|
||||
|
||||
+19
-1
@@ -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,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(
|
||||
"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)?;
|
||||
@@ -202,6 +219,7 @@ pub fn create_provider(
|
||||
config.model,
|
||||
client,
|
||||
config.timeout_secs,
|
||||
Vec::new(),
|
||||
)))
|
||||
}
|
||||
ProviderType::DeepSeek => {
|
||||
|
||||
+328
-10
@@ -4,6 +4,7 @@
|
||||
//! → `content_block_stop` → `message_delta` → `message_stop`。与 OpenAI 不同,
|
||||
//! Anthropic 提供显式 block 边界事件,状态机相对简单。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
@@ -13,7 +14,7 @@ use bytes::Bytes;
|
||||
use futures_core::Stream;
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::Client;
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use tracing::{debug, error, info, warn};
|
||||
@@ -43,6 +44,8 @@ pub struct AnthropicProvider {
|
||||
/// 在 `LlmError::Timeout { duration }` 中回显。`reqwest::Client` 不暴露 timeout getter,
|
||||
/// 因此单独存储以便错误消息与配置保持一致。
|
||||
timeout_secs: u64,
|
||||
/// Provider 级别固定请求头(如平台标识头),所有请求自动携带。
|
||||
extra_headers: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl AnthropicProvider {
|
||||
@@ -72,6 +75,7 @@ impl AnthropicProvider {
|
||||
api_key,
|
||||
model,
|
||||
timeout_secs,
|
||||
extra_headers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +135,7 @@ impl AnthropicProvider {
|
||||
model: String,
|
||||
http_client: Client,
|
||||
timeout_secs: u64,
|
||||
extra_headers: Vec<(String, String)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
http_client,
|
||||
@@ -142,9 +147,17 @@ impl AnthropicProvider {
|
||||
api_key,
|
||||
model,
|
||||
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 {
|
||||
request.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS)
|
||||
}
|
||||
@@ -214,6 +227,10 @@ impl AnthropicProvider {
|
||||
|
||||
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() {
|
||||
None
|
||||
} else {
|
||||
@@ -251,19 +268,54 @@ impl AnthropicProvider {
|
||||
tools,
|
||||
thinking,
|
||||
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> {
|
||||
let body = self.build_request_body(request)?;
|
||||
let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
|
||||
|
||||
info!(model = %body.model, "Anthropic: 发送非流式请求");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.build_request_builder(&body)?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| self.map_reqwest_error(e))?;
|
||||
@@ -291,14 +343,10 @@ impl AnthropicProvider {
|
||||
let mut body = self.build_request_body(request)?;
|
||||
body.stream = Some(true);
|
||||
|
||||
let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
|
||||
|
||||
info!(model = %body.model, "Anthropic: 发送流式请求");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.build_request_builder(&body)?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| self.map_reqwest_error(e))?;
|
||||
@@ -448,6 +496,9 @@ struct AnthropicRequestBody {
|
||||
thinking: Option<AnthropicThinking>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stream: Option<bool>,
|
||||
/// 请求级别自定义 HTTP 头。运行时注入,不进入 JSON 请求体。
|
||||
#[serde(skip)]
|
||||
custom_headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -1207,4 +1258,271 @@ event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
|
||||
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 名应被静默跳过"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+322
-3
@@ -8,6 +8,7 @@
|
||||
//! - `MessageComplete { full_response }` 由 `PartialMessageResponse::finalize()` 产出。
|
||||
//! - `capabilities()` 报告 OpenAI Chat 协议的能力。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
@@ -17,9 +18,10 @@ use bytes::Bytes;
|
||||
use futures_core::Stream;
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::Client;
|
||||
use reqwest::header::{HeaderName, HeaderValue};
|
||||
use serde::Serialize;
|
||||
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::error::LlmError;
|
||||
@@ -145,6 +147,11 @@ pub(crate) struct OpenaiChatRequest {
|
||||
pub extra_headers: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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>,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -235,6 +242,7 @@ pub(crate) struct OpenaiChatResponse {
|
||||
pub created: u64,
|
||||
pub model: String,
|
||||
pub choices: Vec<Choice>,
|
||||
#[serde(default)]
|
||||
pub usage: crate::llm::types::usage::Usage,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system_fingerprint: Option<String>,
|
||||
@@ -437,6 +445,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 {
|
||||
self.http_client = client;
|
||||
self
|
||||
@@ -451,17 +466,37 @@ impl GenericOpenaiProvider {
|
||||
}
|
||||
|
||||
/// 构造 HTTP POST 请求 builder(含认证头与额外请求头)。
|
||||
///
|
||||
/// 头融合顺序:Authorization → Provider 级 extra_headers → 请求级 custom_headers
|
||||
/// 后者覆盖前者。非法 header 名/值(如控制字符)静默跳过 + warn,避免 reqwest panic。
|
||||
fn build_request_builder(
|
||||
&self,
|
||||
url: &str,
|
||||
body: &impl Serialize,
|
||||
body: &OpenaiChatRequest,
|
||||
) -> Result<reqwest::RequestBuilder, LlmError> {
|
||||
let mut builder = self
|
||||
.http_client
|
||||
.post(url)
|
||||
.header("Authorization", format!("Bearer {}", self.api_key));
|
||||
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))
|
||||
}
|
||||
@@ -557,6 +592,8 @@ impl GenericOpenaiProvider {
|
||||
let seed = request.get_extra_opt("seed");
|
||||
let response_format = request.get_extra_opt("response_format");
|
||||
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 {
|
||||
model,
|
||||
@@ -573,6 +610,7 @@ impl GenericOpenaiProvider {
|
||||
seed,
|
||||
response_format,
|
||||
parallel_tool_calls,
|
||||
custom_headers,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
@@ -1695,4 +1733,285 @@ data: [DONE]\n\n";
|
||||
"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 名应被静默跳过"
|
||||
);
|
||||
}
|
||||
|
||||
// ===== OpenaiChatResponse Usage 容错(v0.3.6 引入)=====
|
||||
|
||||
/// C1: Chat Completions 响应中 usage 键完全缺失 —— 默认 Usage::default()。
|
||||
#[test]
|
||||
fn deserialize_chat_response_missing_usage_key() {
|
||||
let body: OpenaiChatResponse = serde_json::from_value(json!({
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"created": 1718000000,
|
||||
"model": "gpt-4o",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "hi"},
|
||||
"finish_reason": "stop"
|
||||
}]
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(body.usage.prompt_tokens, 0);
|
||||
assert_eq!(body.usage.completion_tokens, 0);
|
||||
assert_eq!(body.usage.total_tokens, 0);
|
||||
assert!(body.usage.completion_tokens_details.is_none());
|
||||
assert!(body.usage.prompt_tokens_details.is_none());
|
||||
}
|
||||
|
||||
/// C2: Chat Completions 响应中 usage 存在但缺子字段 —— 缺省字段默认 0。
|
||||
#[test]
|
||||
fn deserialize_chat_response_missing_usage_fields() {
|
||||
let body: OpenaiChatResponse = serde_json::from_value(json!({
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"created": 1718000000,
|
||||
"model": "gpt-4o",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "hi"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 8}
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(body.usage.prompt_tokens, 8);
|
||||
assert_eq!(body.usage.completion_tokens, 0);
|
||||
assert_eq!(body.usage.total_tokens, 0);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Usage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
@@ -79,3 +80,33 @@ impl Usage {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn deserialize_usage_missing_prompt_tokens() {
|
||||
let value = json!({
|
||||
"completion_tokens": 5,
|
||||
"total_tokens": 5
|
||||
});
|
||||
let usage: Usage = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(usage.prompt_tokens, 0);
|
||||
assert_eq!(usage.completion_tokens, 5);
|
||||
assert_eq!(usage.total_tokens, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_deserialize_roundtrip() {
|
||||
let original = Usage::from_input_output(10, 20);
|
||||
let serialized = serde_json::to_value(original).unwrap();
|
||||
let deserialized: Usage = serde_json::from_value(serialized).unwrap();
|
||||
assert_eq!(deserialized.prompt_tokens, original.prompt_tokens);
|
||||
assert_eq!(deserialized.completion_tokens, original.completion_tokens);
|
||||
assert_eq!(deserialized.total_tokens, original.total_tokens);
|
||||
assert!(deserialized.completion_tokens_details.is_none());
|
||||
assert!(deserialized.prompt_tokens_details.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user