16 Commits
Author SHA1 Message Date
徐涛 9e4f50c955 feat(llm): LlmCycle 切换 IR 消息类型,移除 Phase 0 桥接层
核心改动:
- LlmCycle.messages 从 Vec<OpenaiChatMessage> 切换为 Vec<Message>,
  移除 Phase 0 引入的 chat_message_to_message / message_to_chat_message
  转换函数
- 移除 LlmCycle.system_prompt 字段(FIX-D),调用方通过
  Message::system_text() + with_messages() 管理系统提示;
  with_system_prompt() 标记 #[deprecated] 保留为过渡期 shim
- submit_stream 简化(FIX-E):Item = StreamEvent 不再带 Result 包装,
  错误用 StreamEvent::Error 传出;self.messages 不再自动 push_message,
  调用方在收到 MessageComplete 后手动调用 cycle.push_message()
- compact.rs 适配 Message 类型 + ContentBlock 估算;microcompact
  跳过 is_error:true 的 ToolResult(FIX-F),保留 LLM 错误诊断
- ConversationMemory 持久化从 OpenaiChatMessage 切换为 Message
- ToolInvocation 增加 tool_call_id 字段(FIX-A),
  invoke/invoke_all 签名增加 tool_call_id 参数

调用方迁移:
- AgentSession::submit_turn 用 Message::system_text() + with_messages()
- simple_visit example 同步迁移

测试 177 passed / 0 failed(compact 新增 5 个单元测试覆盖 FIX-F + 估计全变体)。
2026-07-02 22:48:00 +08:00
徐涛 a74c24b6fe feat(llm): 重写 Provider 适配层,支持 OpenAI Chat / Anthropic / DeepSeek / Qwen
重写 OpenaiChatProvider:移除 Phase 0 临时桥接,实现真实流式状态机
(ContentBlockStart / *Delta / ContentBlockEnd / ToolCallEnd),
MessageComplete.full_response 由 PartialMessageResponse::finalize 产出。

新增 AnthropicProvider:实现 Messages API + SSE 事件序列
(message_start → content_block_start → content_block_delta →
content_block_stop → message_delta → message_stop),
529(overloaded)映射为 RateLimit;thinking signature 由
partial.set_thinking_signature 内部写入。

新增 DeepSeekProvider / QwenProvider:OpenAI-compatible 协议的
newtype 包装,共享 GenericOpenaiProvider 的 HTTP / SSE / 转换逻辑;
Qwen 通过 extra_headers 注入 X-DashScope-SSE: enable。

新增 convert.rs 公共转换模块:从 Phase 0 cycle.rs / Phase 0
OpenaiProvider 桥接层提取 from_openai / to_openai /
content_to_blocks / blocks_to_content,避免跨 Provider 重复逻辑。

新增 wiremock dev-dependency + 14 个集成测试:
- OpenaiChatProvider:基础文本 / 401 / 500 / 流式 MessageComplete
- AnthropicProvider:基础 / 401 / 529 / 流式 SSE 序列 / max_tokens 默认值
- DeepSeek / Qwen:基础文本响应

171 个测试全部通过(之前 157,新增 14)。
2026-07-02 22:24:30 +08:00
徐涛 7c299f1cfd feat(llm): 新增 IR 类型层 + 切换 LlmProvider trait 签名
引入跨 Provider 统一的新类型系统:
- Message 扁平大枚举(System/User/UserImage/Assistant/ToolResult)
- MessageRequest + MessageResponse + StreamEvent(高精度 IR)
- PartialMessageResponse 含 apply_to/finalize 汇聚算法
- ProviderType enum 扩展 + ProviderCapabilities

切换 LlmProvider trait 签名为 chat(MessageRequest) → MessageResponse,
chat_stream 返回 Stream<Item = Result<StreamEvent, LlmError>>,新增 capabilities()。

StreamEvent 命名冲突处理:旧变体迁移至 old_stream::LegacyStreamEvent,
stream.rs 通过 pub use 重新导出新高精度事件。

OpenaiProvider 添加 Phase 0 临时桥接(MessageRequest ↔ OpenaiChatRequest 转换),
标注 ponytail: Phase 0 临时桥接 标记,Phase 1 重写时移除。

测试 147 passed / 0 failed(新增 31 个新类型测试)。
2026-07-02 21:58:07 +08:00
徐涛 925c8f9729 docs: 更新方案文档,补充 StreamEvent 迁移和 OpenaiProvider 桥接方案 2026-06-30 22:59:05 +08:00
徐涛 832ebf2665 docs(phase0): 新增 Phase 0 实施计划文档
定义类型层落地计划,涵盖新类型定义、Trait 签名切换及上游适配方案
2026-06-30 22:22:12 +08:00
徐涛 000cd2022d feat(llm): 实现 Provider 重构方案并更新文档
根据复核反馈修正以下内容:
- 补充 §2.4 中扁平大枚举对 9e 依赖验证的说明
- 消除 §2.3 MessageComplete 冗余字段
- 明确 Phase 0 add-only 策略和 trait 签名切换时点
- 补充 HTTP mock 策略和兼容性验证标准
2026-06-26 08:12:12 +08:00
徐涛 ac7fca8b40 feat(docs): 新增 LLM Provider 重构方案文档 2026-06-25 23:07:22 +08:00
徐涛 f639229d09 docs: 添加 commit body 规则,禁止逐文件列举变更 2026-06-24 17:00:43 +08:00
徐涛 4423024974 docs: 更新 compact 主题状态为已推演 2026-06-22 08:43:48 +08:00
徐涛 3babc5567f enhance(docs): 补全 compact 在 IR 上的改法推演结论 2026-06-22 06:52:04 +08:00
徐涛 3ddd0b6d80 enhance(docs): 更新 system prompt 双重表达冲突推演结论为方案 D 2026-06-22 06:34:11 +08:00
徐涛 80d44ee687 docs(AGENTS): 添加 Commit 提交流程说明 2026-06-19 08:23:00 +08:00
徐涛 ee7fcab71c docs(9): 完善 Anthropic 流式状态机设计推演结论 2026-06-18 06:51:13 +08:00
徐涛 8c9324350b docs(9d): 补全 OpenAI 流式转换推演结论 2026-06-18 06:34:20 +08:00
徐涛 c0eae92b10 docs: 更新方案9推演结论并调整StreamEvent设计 2026-06-17 22:55:47 +08:00
徐涛 8686a2e1d6 docs(llm): 新增 LLM Provider 统一接口设计文档 2026-06-15 23:07:33 +08:00
39 changed files with 11206 additions and 672 deletions
+13
View File
@@ -97,6 +97,7 @@
- `test` - 测试相关
- `chore` - 构建/工具/配置
- 描述使用祈使句、现在时态、句尾无句号
- **Body 规则**:不列举 commit 中每一个文件的变更,body 只列出变更概要列表即可
- **Scope 推导规则**
- 从被提交的文件路径中推导出一个最相关的 scope
- 一个 commit 只写一个主要 scope,不要罗列多个
@@ -109,6 +110,18 @@
- `docs: 更新 API 文档`(全局文档更新)
- `chore(deps): 升级 tokio 到 1.40`(依赖更新)
### Commit 提交流程
在生成 commit message 后,**必须先展示给用户确认**,不可直接执行 `git commit`
流程:
1. Agent 根据 diff 生成符合规范的 commit message
2. 展示生成的 message,等待用户明确回应
3. 用户有三种选择:
- **确认** → 执行 `git commit`
- **重新生成** → 根据用户反馈修改 message 后重新展示
- **取消** → 放弃本次 commit 操作
---
### Rust
+1
View File
@@ -23,3 +23,4 @@ time = { version = "0.3", features = ["serde"] }
[dev-dependencies]
dotenvy = "0.15.7"
wiremock = "0.6"
+515
View File
@@ -0,0 +1,515 @@
# LLM Provider 重构改进方案(最终确认)
> 本文档记录 2026-06-25 设计评审后确认的方案决策,是对 9 系文档(`9-llm-provider-unified-interface.md` 及 `9a`-`9g` 子文档)中已有设计的**精炼与修订**。
>
> **阅读前提**:本文档假设读者已熟悉现有 9 系文档中的背景、架构总览和类型体系概念。
>
> **与 9 系的关系**
> - 9 系文档中的 `ContentBlock`、`MessageRequest`、`MessageResponse`、`StopReason`、`ThinkingConfig`、`ToolDefinition`、`PartialUsage` 等核心类型定义**继续有效**,本文档不再重复
> - `ProviderCapabilities`、`LlmProvider trait` 签名、`PartialMessageResponse` 汇聚算法等 design intent **继续有效**trait 签名由 9c 定义,切换时点由本文档 §4 Phase 0 执行)
> - 本文档仅记录**本次确认的修订内容和执行计划**
---
## 修订记录
| 日期 | 版本 | 修订摘要 |
|------|------|---------|
| 2026-06-25 | v1 | 初版,记录 5 项设计决策 |
| 2026-06-26 | v2 | 初审修订:修正 §1 表描述(Assistant → UserImage);消除 §2.3 MessageComplete 冗余字段;明确 Phase 0 add-only 策略;补充 9e 依赖审查说明;补充 HTTP mock 策略;修正 §5 兼容性验证标准;增加 §7 开放事项 |
| 2026-06-26 | v3 | 复审修订:Phase 0 改为"add + trait 签名切换"消除结构性缺口;补充 OpenAI Response API 范围和 OpenAI-compatible 复用策略说明;调整 Phase 2 范围(聚焦逻辑简化) |
---
## 1. 修订摘要
| 设计维度 | 9 系文档 | 本次修订 | 修订原因 |
|----------|---------|---------|---------|
| Message 模型 | 结构化层次(`System/User/Assistant/Tool`,每项含 `content: Vec<ContentBlock>` | **扁平大枚举**User 拆出 `UserImage` 独立变体,Assistant 保持整体,ToolUse 仍在 content 中) | 编译器能检查约束,`UserImage` 消费方 match 可直接区分文本和图片输入,无需检查 Vec 内容 |
| StreamEvent 终端事件 | `MessageComplete { stop_reason, thinking_signature }` | **精简为 `MessageComplete { full_response: MessageResponse }`**,移除冗余顶层字段 | 消除冗余和消费方疑惑,唯一信源 |
| Provider 发现 | 未明确 | **Enum-based**`ProviderType` enum + exhaustive match),不做动态注册 | 当前协议数量可控,编译期安全,无运行时查表开销 |
| 项目阶段 | 9 系是"推演中" | **可直接执行**,无历史包袱,一步到位 | 项目尚未 release,没有 breaking change 顾虑 |
---
## 2. 本次修订的 5 项设计决策
### 2.1 Decision-01Message 采用扁平大枚举
#### 定义
```rust
/// 跨 Provider 统一的消息类型(扁平大枚举)。
///
/// 设计原则:每个变体直接承载完整语义,
/// 消费方 match 即可获得所有信息,无需在嵌套的 Vec 中搜索。
#[derive(Debug, Clone)]
pub enum Message {
/// 系统提示(User & Assistant 之外的引导指令)
System {
content: Vec<ContentBlock>,
},
/// 用户输入
User {
content: Vec<ContentBlock>,
},
/// 用户的图片输入(快捷构造,免去构造 ContentBlock 的 boilerplate
UserImage {
data: String,
mime_type: String,
detail: ImageDetail,
},
/// Assistant 回复内容块(可能包含 text、thinking、tool_use 等多种 block 的混合)
///
/// 注意:Assistant 的一次回复可以同时包含文本、思考过程、工具调用。
/// 扁平大枚举并未将 ToolUse 提升为独立变体,而是保留在 content 中,
/// 因为在一次 Assistant turn 中 text 和 tool_use 的**顺序关系**是有意义的。
/// (例如:先输出推理过程,再调用工具)
Assistant {
content: Vec<ContentBlock>,
},
/// 工具调用结果
ToolResult {
tool_call_id: String,
content: Vec<ContentBlock>,
is_error: bool,
},
}
```
#### 与 9 系结构化层次的差异
| 维度 | 9 系(结构化层次) | 本次(扁平大枚举) |
|------|-------------------|-------------------|
| Assistant 消息结构 | `Assistant { content: Vec<ContentBlock> }`ToolUse 在 content 中 | 同上,保持 ToolUse 在 content 中 |
| "独立 Assistant 消息"的含义 | 一次 LLM 响应 = 一个 `Assistant { content: [...] }` | 同上 |
| Thinking / ToolCall 作为独立变体 | ❌ 无独立变体 | **Thinking、ToolCall 不作为独立 Message 变体**,仍在 `Assistant.content` 中 |
| UserImage 独立变体 | `User { content: [Image{...}] }` | `UserImage { data, mime, detail }` |
| 为什么不把 ToolUse 提到 Message 层 | — | 因为 text ↔ tool_use 的**交错顺序**是 Assistant 响应的语义组成部分,拆散后会丢失顺序信息 |
| 实际的参与方差异 | `User` + `UserImage` 合并为同一变体 | `User``UserImage` **拆开**,方便消费方 match(无需检查 Vec 内容来区分文字和图片) |
> **与 9b 文档的关系**9b 的 `Message::System`、`Message::User`、`Message::Assistant`、`Message::Tool` 四个变体分类保留,
> 但 `User` 的图片输入场景通过新增 `UserImage` 变体提供便捷路径,减少 boilerplate。
> `Message::Assistant` 的 `content: Vec<ContentBlock>` 保持不变——ToolUse 仍在 content 中。
#### 便捷构造函数
```rust
impl Message {
pub fn user_text(text: impl Into<String>) -> Self;
pub fn user_image(data: impl Into<String>, mime_type: impl Into<String>, detail: ImageDetail) -> Self;
pub fn assistant(text: impl Into<String>) -> Self;
pub fn system(text: impl Into<String>) -> Self;
pub fn tool_result(tool_call_id: impl Into<String>, text: impl Into<String>, is_error: bool) -> Self;
}
```
### 2.2 Decision-02LlmProvider 感知消息类型
沿用 9c 文档中的 trait 设计,无修订。
```rust
#[async_trait]
pub trait LlmProvider: Send + Sync {
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError>;
async fn chat_stream(
&self,
request: MessageRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>;
fn capabilities(&self) -> ProviderCapabilities;
}
```
每个 Provider 实现内部自行处理 `MessageRequest` ↔ 原生协议格式的映射。无外部转换层。
### 2.3 Decision-03StreamEvent 高精度 + 终端事件携带完整响应
沿用 9c 文档中定义的 `StreamEvent`,但**在终端事件中增加完整响应快照**。
#### 修订后的 MessageComplete 事件
```rust
pub enum StreamEvent {
// ── Meta ──
MessageStart { id: String, model: String },
// ── Content Block 边界 ──
ContentBlockStart { index: u32, block_type: ContentBlockType },
ContentBlockEnd { index: u32 },
// ── 块内增量 ──
TextDelta { text: String },
ThinkingDelta { text: String },
RefusalDelta { text: String },
ToolCallArgumentsDelta { index: u32, arguments: String },
ToolCallEnd { index: u32 },
// ── 汇总 ──
CostUpdate { usage: PartialUsage },
// ═══════════════════════════════════════════════════════
// 修订:MessageComplete 携带完整响应快照(移除冗余的 stop_reason / thinking_signature
// ═══════════════════════════════════════════════════════
/// 消息完成 —— 唯一可靠的完整响应来源。
///
/// `full_response` 携带完整的 MessageResponse(含已拼接完毕的 content / usage / stop_reason),
/// 消费方**无需自行累积 delta**,直接使用此快照继续后续流程。
///
/// 设计说明:
/// - 9c 原有设计在 `MessageComplete` 中同时携带 `stop_reason` 和 `thinking_signature` 顶层字段,
/// 但这些信息已包含在 `full_response` 中,造成冗余和消费方的疑惑(到底读顶层字段还是 full_response)。
/// - 本次修订全部移除顶层冗余字段,`full_response` 是唯一信源。
/// - Anthropic 的 thinking signaturemessage_delta 中下发,晚于 content_block_stop)由 Provider
/// 的流处理循环直接调用 `PartialMessageResponse::set_thinking_signature()` 写入内部状态,
/// 再通过 `finalize()` 回填到 Thinking block 中,最终出现在 `full_response` 的 content 里。
/// 消费方不需要感知 signature 的存在。
MessageComplete {
/// 完整的响应快照。
///
/// 与 `PartialMessageResponse` 内部累积的状态**最终一致**,
/// 提供此快照是为了让消费方(如 LlmCycle)在流结束后可以直接拿到
/// 完整的 MessageResponse,无需自己实现汇聚算法。
full_response: MessageResponse,
},
// ── 错误 ──
Error { message: String },
}
```
#### 设计理由
1. **简化消费方**`LlmCycle::submit_stream()` 目前需要在 `while let` 循环中逐个处理 delta 并维护一个会话状态来判断"响应是否完整"。有了 `full_response``LlmCycle``AgentSession` 只需要监听 `MessageComplete` 事件,拿到快照后直接继续 tool 循环或返回给调用方。
2. **与 PartialMessageResponse 保持一致**`PartialMessageResponse::finalize()` 产生的 `MessageResponse` 就是 `full_response` 的值。Provider 内部的汇聚逻辑不变,只是在发出 `MessageComplete` 时多传一个已完成构建的最终结果。
3. **零额外开销**`MessageResponse` 在 Provider 内部已经构造好了(作为汇聚算法的最终产物),只是多 clone/arc 一次给事件携带。
4. **消除冗余**9c 原有设计同时保留了顶层 `stop_reason``thinking_signature``full_response` 中的相同信息,造成消费方疑惑。本次修订只保留 `full_response` 为唯一信源。
#### 对 PartialMessageResponse 的影响
```rust
// finalize 在原有逻辑末尾增加一步:
// 将 finalize 的结果提前缓存,由 MessageComplete 事件携带
impl PartialMessageResponse {
pub fn finalize(mut self) -> Result<MessageResponse, LlmError> {
// ... 原有代码(按 index 升序遍历 blocks ...
let response = MessageResponse { ... };
// 新增:self. 中缓存 finalize 结果
// (实际由 Provider 的流处理循环在发出 MessageComplete 前调用
// finalize 并填充到事件中)
Ok(response)
}
}
```
Provider 的流处理循环 - `thinking_signature` 不再经过事件层,由 Provider 直接写入 `PartialMessageResponse` 内部状态:
```rust
// 伪代码:Provider 流处理循环(以 Anthropic 为例)
let mut partial = PartialMessageResponse::new();
while let Some(event) = anthropic_stream.next().await {
match event {
// Anthropic 的 message_delta 携带 thinking.signature
// → Provider 直接写入 PartialMessageResponse 内部状态
AnthropicEvent::MessageDelta { delta, usage } => {
if let Some(thinking) = &delta.thinking {
if let Some(sig) = &thinking.signature {
partial.set_thinking_signature(sig.clone());
}
}
yield StreamEvent::CostUpdate { usage: map_usage(usage) };
}
// 其他 Anthropic 事件 → 映射为 StreamEvent 并 apply_to
other => {
let ir_event = map_to_ir_event(other);
ir_event.apply_to(&mut partial);
}
// message_stop → 调用 finalize 并发出完成事件
AnthropicEvent::MessageStop => {
let full = partial.finalize()?;
yield StreamEvent::MessageComplete { full_response: full };
break;
}
}
}
```
> **变更追溯**9c 的原有设计中,`MessageComplete` 事件携带顶层 `stop_reason` 和 `thinking_signature` 字段,
> 供 `apply_to()` 设置 `PartialMessageResponse` 的内部状态。本次修订移除这些冗余字段后,
> `thinking_signature` 改为由 Provider 直接调用 `partial.set_thinking_signature()` 写入内部状态,
> `stop_reason` 则在 `finalize()` 中统一定于 `full_response.stop_reason`。
### 2.4 Decision-04LlmCycle 简化
沿用 9e 文档的改造方向,核心变化是内部消息类型从 `Vec<OpenaiChatMessage>` 改为 `Vec<Message>`
> **⚠️ 依赖验证**:9e 文档写于结构化层次设计阶段(`Message::System` / `User` / `Assistant` / `Tool`),
> 其中的代码片段(如 `build_request()` 中 match System 消息的分支、插入 System prompt 的判断逻辑)
> 基于旧 Message 定义。扁平大枚举后——
> - `User` 拆出 `UserImage` → match 分支需增加 `UserImage` 的处理
> - `Message::Tool` 更名为 `Message::ToolResult` → 所有引用需改名
> - 其余 match 分支(`System`、`User`、`Assistant`)的基本逻辑不变
>
> **实施 Phase 2 时**:从 9e 中摘取实现思路,代码手动编写,不直接复制 9e 中的代码片段。
> 修改 9e 文档中过时的代码片段不在本方案范围内,Phase 2 实施时自然淘汰。
关键变化要点(9e 已有详述):
| 当前 | 改进后 |
|------|--------|
| `messages: Vec<OpenaiChatMessage>` | `messages: Vec<Message>` |
| `build_request()` 中手动拼接 system prompt | system prompt 通过 `Message::System` 在 messages 中表达,Provider 映射层自行处理差异 |
| `submit_stream()` 中自建 delta 聚合逻辑 | 监听 `MessageComplete.full_response`,直接拿到完整响应 |
| tool 循环需自行解析 `ChatResponse` 中的 tool_calls | 从 `MessageResponse.message`Assistant 变体)的 content 中提取 ContentBlock::ToolUse |
### 2.5 Decision-05Provider 发现使用 Enum
不使用动态注册表,保留当前 `ProviderType` enum 模式,但扩展其覆盖范围。
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderType {
OpenaiChat,
OpenaiResponse,
Anthropic,
DeepSeek,
Qwen,
}
```
工厂函数 `create_provider()` 做 exhaustive match
```rust
pub fn create_provider(
provider_type: ProviderType,
config: ProviderConfig,
) -> Result<Box<dyn LlmProvider>, LlmError> {
match provider_type {
ProviderType::OpenaiChat => Ok(Box::new(providers::OpenaiChatProvider::new(...))),
ProviderType::OpenaiResponse => Ok(Box::new(providers::OpenaiResponseProvider::new(...))),
ProviderType::Anthropic => Ok(Box::new(providers::AnthropicProvider::new(...))),
ProviderType::DeepSeek => Ok(Box::new(providers::DeepSeekProvider::new(
config.base_url,
config.api_key,
config.model,
))),
ProviderType::Qwen => Ok(Box::new(providers::QwenProvider::new(...))),
}
}
```
新增 Provider 时,编译器通过 exhaustiveness check 强制要求 `match` 更新。
> **理由**:当前目标协议数量(4-5 种)完全可控,enum 的编译期安全检查优于运行时的 `HashMap::get()`。
> 未来如果扩展到 15+ 种以上,再改为注册表模式。
---
## 3. 对 9 系文档的更新映射
| 9 系文档 | 变更类型 | 操作 |
|---------|---------|------|
| `9b-ir-type-system.md` §3.2 Message | 修订 | `UserImage` 变体新增;其余部分继续有效 |
| `9c-llm-provider-trait.md` §4.1 LlmProvider trait | 切换时点修订 | trait 签名切换由"推迟到 Phase 2"改为 Phase 0 内完成。trait 定义本身不变。 |
| `9c-llm-provider-trait.md` §4.3 StreamEvent | 修订 | `MessageComplete` 增加 `full_response: MessageResponse` 字段 |
| `9c-llm-provider-trait.md` §4.4 PartialMessageResponse | 追加 | `finalize()` 返回结果需在 Provider 发出 `MessageComplete` 前已可用 |
| `9d-provider-implementations.md` | 继续有效 | 实现策略不变 |
| `9e-llm-cycle-and-upstream.md` | 需重新审查 | 方向不变,但其中的 match 分支和 System prompt 插入逻辑基于旧 Message 定义。Phase 2 实施时参考思路而非照搬代码(见 §2.4 ⚠️ 依赖验证) |
| `9f-edge-cases.md` | 继续有效 | 边界情况处理不变 |
| `9g-risk-and-migration.md` | 继续有效 | 风险评估不变 |
| 本文档 `10-...` | **新增** | 记录最终决策和修订 |
---
## 4. 实施步骤
### Phase 0:类型层落地 + trait 签名切换
**目标**:新增新的类型系统 + 切换 `LlmProvider` trait 签名,使全链路使用新类型。Phase 0 结束时 `cargo test` 全部通过。
**原则**
- 新类型定义放入**新文件**`message.rs``request_v2.rs``response_v2.rs`),不堆积到已有类型文件
- 已有的 `request.rs``OpenaiChatRequest`)、`response.rs``OpenaiChatResponse`**保留原样**,后续 Provider 实现可能作为内部转换目标继续引用
- `LlmProvider` trait 签名由 `chat(ChatRequest) → ChatResponse` 切换为 `chat(MessageRequest) → MessageResponse`,**在同一个 Phase 内完成**(见下方任务 6‒8)
- trait 签名变更导致的编译错误(`StubProvider``LlmCycle` 调用点)**在 Phase 0 内全部修复**,不留到 Phase 1
- `AgentSession` 等上游中对 `LlmCycle.submit()` 返回值的引用同步适配
**`StreamEvent` 命名冲突处理**
`StreamEvent`(高精度版)定义在 `src/llm/types/response_v2.rs` 中。
`StreamEvent``src/llm/stream.rs` 中定义)的变体(`AssistantTextDelta``ToolExecutionStarted``TurnComplete` 等)与新类型冲突。
处理方式(任务 9 执行):
1. `response_v2.rs` 中的新 `StreamEvent` 是唯一的 `StreamEvent` 定义
2. `src/llm/stream.rs` 中的旧 `StreamEvent` 枚举**替换为**重新导出语句:`pub use super::types::response_v2::StreamEvent;`
3.`StreamEvent` 的变体(`AssistantTextDelta``ToolExecutionStarted``TurnComplete`)**暂时保留**为一个独立的枚举(命名为 `LegacyStreamEvent`)放在 `src/llm/types/old_stream.rs` 新文件中,供 `stream.rs` 中的 `parse_chunk_stream()` 内部使用
4. 这样 `stream.rs` 的辅助函数继续编译,`LlmCycle``AgentSession` 看到的是新 `StreamEvent`
**涉及文件**
| 类型 | 文件 | 操作 |
|------|------|------|
| 新增 | `src/llm/types/message.rs` | 新文件 |
| 新增 | `src/llm/types/request_v2.rs` | 新文件 |
| 新增 | `src/llm/types/response_v2.rs` | 新文件 |
| 新增 | `src/llm/types/old_stream.rs` | 新文件(从 `stream.rs` 迁移旧 `StreamEvent` 变体) |
| 追加 | `src/llm/types/mod.rs` | 追加 `pub mod` 声明 |
| 修改 | `src/llm/provider.rs` | 改 `LlmProvider` trait 签名 |
| 修改 | `src/agent/builder.rs` | 更新 `StubProvider` 实现 |
| 修改 | `src/llm/stream.rs` | 将旧 `StreamEvent` 枚举替换为对 `response_v2::StreamEvent` 的重新导出 |
| 修改 | `src/llm/provider/openai.rs` | 修改:添加临时桥接实现(`MessageRequest → ChatRequest` 转换 + `ChatResponse → MessageResponse` 转换),Phase 1 重写时移除 |
| 修改 | `src/llm/hooks.rs` | 更新 `HookContext.request` 类型为 `&'a MessageRequest` |
| 修改 | `src/llm/cycle.rs` | 更新调用点(`build_request``submit``submit_stream``submit_messages``submit_request` 的类型引用和返回值) |
| 修改 | `src/llm/cycle/retry.rs` | 如有对新 `LlmError` 类型的引用,同步适配 |
| 修改 | `src/agent/error.rs``src/agent/runtime.rs``src/agent/session.rs` 等 | 如有对 `LlmCycle` 返回值或 `ChatResponse` 的引用,同步适配(具体文件由编译错误定位) |
**具体任务**
1. 新增 `src/llm/types/message.rs`,定义 `Message` 扁平大枚举 + `ContentBlock` + `ContentBlockType`
2. 将 9b 中的 `ContentBlock` 变体(`Text`, `Image`, `Audio`, `File`, `ToolUse`, `ToolResult`, `Thinking`, `Extension`)及其辅助类型(`ImageSource``AudioSource``FileSource`)定义到 `message.rs`
3. 新增 `src/llm/types/request_v2.rs`,定义 `MessageRequest`(从 9b 移植)+ `ExtraError` + extra 访问方法(`get_extra``get_extra_opt``get_extra_as``set_extra`
4. 新增 `src/llm/types/response_v2.rs`,定义 `MessageResponse` + `StreamEvent`(高精度版,`MessageComplete` 只含 `full_response: MessageResponse`+ `PartialUsage` + `PartialMessageResponse` + `apply_to` + `finalize`
5. 新类型侧单元测试:构造、序列化/反序列化(JSON roundtrip)、match 穷举性验证、`PartialMessageResponse.apply_to + finalize` 汇聚一致性测试
6. 修改 `src/llm/provider.rs``LlmProvider` trait 签名改为 `chat(MessageRequest) → Result<MessageResponse, LlmError>``chat_stream(MessageRequest) → Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>`
7. 修改 `src/agent/builder.rs`:更新 `StubProvider` 实现以匹配新 trait 签名
8. 修改 `src/llm/cycle.rs`
- `build_request()`:将已有的 `Vec<OpenaiChatMessage>` 转换为 `Vec<Message>`(通过 `chat_message → message` 映射函数),构造 `MessageRequest`
- `submit()` / `submit_messages()`:返回 `Result<MessageResponse, LlmError>`
- `submit_stream()`:返回 `Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError>`
- 流处理循环:由消费 `OpenaiChatChunk` 改为消费 `StreamEvent`。流结束处的 `full_response` 暂不使用(Phase 2 才启用简化逻辑),先提取 `stop_reason``message` 构建传统返回
9. 新增文件 `src/llm/types/old_stream.rs`(从 `src/llm/stream.rs` 迁移旧 `StreamEvent` 定义),同时将 `src/llm/stream.rs` 中的旧 `StreamEvent` 枚举替换为对 `response_v2.rs` 中新 `StreamEvent` 的重新导出(`pub use super::types::response_v2::StreamEvent;`),确保 `stream.rs``parse_chunk_stream()``ChunkToEventStream` 继续编译通过
10. 修改 `src/llm/provider/openai.rs`:添加 `LlmProvider` trait 临时桥接实现——
- `chat()``MessageRequest → ChatRequest`(利用现有 `OpenaiChatMessage` 转换)→ 调用已有 `chat_inner()``ChatResponse → MessageResponse`(使用 `finalize()` 算法或直接映射)
- `chat_stream()``MessageRequest → ChatRequest` → 调用已有 `chat_stream_inner()` → 将 `OpenaiChatChunk` 流映射为 `StreamEvent` 流(利用已有的 `parse_chunk_stream`
- 桥接实现标记 `// ponytail: Phase 0 临时桥接,Phase 1 重写时移除`
11. 测试适配(编译驱动,涉及文件不限于以下列表,由编译器报错定位):
- `src/llm/cycle.rs` 测试模块(`MockProvider``assistant_text_response()``assistant_tool_call_response()`、各测试用例中的断言类型)
- `src/agent/session.rs` 测试模块(`MockProvider`、响应构造 helper 等)
- `src/agent/builder.rs` 测试模块(`StubProvider` 已单独由任务 7 处理)
- `src/agent/session_memory.rs``src/agent/runtime.rs`
12. 编译驱动适配:对上游(`agent/session.rs``agent/runtime.rs``agent/error.rs` 等)中引用旧类型的地方,逐一按编译错误修复
**验证**`cargo test` 全部通过。`git diff` 确认新增和修改文件范围符合预期。确认 `OpenaiProvider` 的临时桥接代码带有 `// ponytail: Phase 0 临时桥接` 注释,Phase 1 移除时易于定位。
### Phase 1Provider 适配
> **前置条件**Phase 0 已完成,`LlmProvider` trait 签名已切换为 `chat(MessageRequest) → MessageResponse`。本 Phase 直接实现新 Provider,无需再处理 trait 兼容性。
**目标**:重写 `OpenaiProvider`(使用新类型),新增 `AnthropicProvider`。DeepSeek/Qwen 作为 OpenAI-compatible 协议实现一并纳入。
**涉及文件**
- `src/llm/provider.rs` — 修改 `create_provider` 工厂函数,匹配新的 `ProviderType` enum
- `src/llm/provider/registry.rs` — 适配新 `LlmProvider` trait(改动极小,只是类型变化)
- `src/llm/provider/openai.rs` — 重写:内部实现 `MessageRequest ↔ OpenaiChatRequest` 转换
- `src/llm/provider/anthropic.rs` — 新文件:`MessageRequest ↔ Anthropic Messages API` 映射
- `src/llm/provider/deepseek.rs` — 新文件(与 `OpenaiChatProvider` 共享 `/chat/completions` 协议)
- `src/llm/provider/qwen.rs` — 新文件(同上)
**具体任务**
1. `OpenaiProvider` 内部 `chat()``MessageRequest``OpenaiChatRequest`serde 序列化)→ HTTP POST → 解析 `OpenaiChatResponse``MessageResponse`(通过 `finalize()` 算法或直接映射)
2. `OpenaiProvider` 内部 `chat_stream()`:同样的转换路径,但响应解析改为 SSE 流式 → 逐 chunk 输出 `StreamEvent`
3. `AnthropicProvider`:实现 Anthropic Messages API 的请求/响应映射,包括:
- Messages API 请求体构建(`system` 参数 + `messages[]` + `tools` 等)
- SSE 流解析(`message_start`, `content_block_start`, `content_block_delta`, `content_block_stop`, `message_delta`, `message_stop`, `ping`
- 将 Anthropic SSE 事件映射为 IR `StreamEvent`
4. `DeepSeekProvider` / `QwenProvider`OpenAI-compatible):
- 共享 `OpenaiChatProvider``/chat/completions` 协议
- **代码复用策略实施时决定**(推荐:`OpenaiChatProvider` 参数化为 `GenericOpenaiProvider { base_url, api_key, model, provider_name }`DeepSeek/Qwen 共用同一实现,仅配置不同;备选:trait 组合提取 HTTP 请求逻辑为可复用组件)
- 差异化处理:`max_tokens` 字段名(部分兼容端点使用 `max_tokens` 而非 `max_completion_tokens`)、错误格式(非标准 error body 解析)
5. `ProviderRegistry``register_with_config()``create_provider()` 适配新 enum
6. **OpenAI Response API`ProviderType::OpenaiResponse`)实现范围说明**:本 Phase 的 `OpenaiResponseProvider` 只覆盖核心对话能力(models response 创建、流式)、工具调用。内置工具(`web_search``file_search`)、`previous_response_id` 续写、`store` 等 Response API 独有特性通过 `MessageRequest.extra` 传递(参考 9b 的 extra key 约定表),内置工具的完整支持延后。如果资源有限,`OpenaiResponseProvider` 可延迟到 Phase 2 之后开发,不影响其他 Provider。
**验证**
- 每个 Provider 的 `chat()``chat_stream()` 基本路径集成测试(mock HTTP 层)
- 消息类型双向映射测试(`Message → OpenaiChatRequest`, `OpenaiChatResponse → MessageResponse`
- 错误路径测试(HTTP 400/401/429/500 → `LlmError` 映射)
**HTTP mock 策略**
- 推荐使用 [`wiremock`](https://crates.io/crates/wiremock) crate(项目尚无 HTTP mock 依赖)
- 每个 Provider 的测试模块中,用 `MockServer` 启动 mock 服务端,返回预定义请求/流式响应
- `OpenaiProvider` 的 mock 端点为 `/chat/completions`SSE 流或 JSON 响应)
- `AnthropicProvider` 的 mock 端点为 `/v1/messages`SSE 事件序列)
- 测试不依赖真实网络,`base_url` 指向 `mock_server.uri()`
### Phase 2LlmCycle 简化(逻辑重构)
> **说明**Phase 0 已完成 `LlmCycle` 的"类型迁移"trait 签名、`build_request` 转换层、返回值类型)。Phase 2 聚焦**逻辑简化**——去掉 Phase 0 遗留的临时转换层,利用新类型的表达能力重写 LlmCycle 核心逻辑。
**目标**
-`LlmCycle` 内部消息存储从 `Vec<OpenaiChatMessage>` 切换为 `Vec<Message>`**移除 Phase 0 引入的 `OpenaiChatMessage → Message` 转换层**
- 流处理循环重构:利用 `MessageComplete.full_response` 直接拿到完整响应,去掉手动 delta 累积
- 工具循环清洗:从 `MessageResponse.message` 的 content 中直接提取 `ContentBlock::ToolUse`
- `compact.rs` 适配新 `Message` 类型
**涉及文件**
- `src/llm/cycle.rs` — 主要修改
- `src/llm/cycle/usage.rs` — 保持兼容(`Usage` 类型不变)
- `src/llm/cycle/retry.rs` — 保持兼容
- `src/llm/compact.rs` — 适配 `Message` 类型
**具体任务**
1. `self.messages``Vec<OpenaiChatMessage>` 改为 `Vec<Message>`,移除 `build_request()` 中的类型转换步骤
2. `build_request()` 直接构建 `MessageRequest``messages` 直接传入 `self.messages`),不再手动插入 system prompt(从 messages 中取 `Message::System`
3. `submit()` / `submit_messages()`:已返回 `MessageResponse`,无需改签名。检查调用方是否直接解构 `MessageResponse` 是正确的
4. `submit_stream()`:流处理循环中锚定 `MessageComplete.full_response`,拿到完整的 `MessageResponse` 后直接继续 tool 循环或结束。去掉中间状态的维护
5. tool 循环:从 `MessageResponse.message``Assistant { content }` 中提取 `ContentBlock::ToolUse` 变体
6. `compact.rs` 适配:`microcompact()` / `should_compact()` 的操作对象从 `OpenaiChatMessage` 改为 `Message`,按 text block 长度计算 token 数
7. 清理 Phase 0 引入的临时转换函数(`chat_message_to_message``message_to_chat_message` 等),确认不再被引用后删除
**验证**
- `LlmCycle` 集成测试全部通过
- 多轮对话 + 工具调用的端到端流程正常
- `git diff` 确认 Phase 0 引入的临时转换函数已被删除
---
## 5. 验证标准
| 维度 | 验证方法 | 通过条件 |
|------|---------|---------|
| 类型正确性 | `cargo test` | 所有测试通过 |
| JSON 双向映射 | 单元测试 | `Message → JSON → Message` 往返不变 |
| Provider 基本路径 | 集成测试(mock HTTP | 每个 Provider 的 chat + chat_stream 成功 |
| Provider 错误路径 | 集成测试(mock HTTP 4xx/5xx | 错误映射为正确的 `LlmError` 变体 |
| StreamEvent 完整快照 | 集成测试 | `MessageComplete.full_response` 与 PartialMessageResponse 聚合结果一致 |
| LlmCycle 多轮对话 | 集成测试(mock Provider | 多轮对话 + 工具循环正常 |
| compact | 集成测试 | 超过 token 阈值后消息被正确压缩 |
| 向后兼容(已有代码) | 编译检查 | Phase 0 修改 `LlmProvider` trait + `LlmCycle` 调用点 + `StubProvider` 后,`cargo test` 全部通过。`git diff` 只涉及预期变更的文件,无意外修改 |
---
## 6. 回滚方案
由于项目尚无外部消费者,回滚策略比较简单。每个 Phase 结束时打 tag 作为 checkpoint,允许跳跃回退。
| 阶段 | 触发条件 | 操作 |
|------|---------|------|
| Phase 0(类型层) | 新类型设计发现重大缺陷 | 回退 git,保留 9 系文档作为参照,重启设计评审 |
| Phase 0 完成时 | 类型定义通过评审和测试 | 打 tag `types-v2-prototype` |
| Phase 1Provider 适配) | 某个 Provider 实现不合理 | 将该 Provider 回退为 `unimplemented!()`(当前状态),不影响其他 Provider |
| Phase 1 完成时 | Provider 测试全部通过 | 打 tag `providers-v2-prototype` |
| Phase 2LlmCycle 简化) | 循环逻辑或 compact 出现问题 | 保留旧 `LlmCycle` 实现(不改文件名),通过 feature flag 切换 |
| **跨阶段回退** | Phase 2 发现 Phase 0 类型设计有误 | 回退至 Phase 0 checkpoint`types-v2-prototype`),在不动已有文件的前提下直接原地修改新类型文件重新迭代,不需要整个回退到 Phase 0 之前 |
**风险储备**
- 如果 `OpenaiProvider` 的重写复杂度过高,可以保留旧的 `OpenaiProvider` 不变,在旁边新增一个 `OpenaiProviderV2` 并行开发
- `ChatRequest` / `ChatResponse` / `Message` / `ContentBlock` / `ToolDefinition` / `StopReason` 等类型别名和旧类型结构体的弃用路径:
- **Phase 0 完成时**:旧别名**保留**(作为编译桥接),新类型通过不同路径(`request_v2::MessageRequest``response_v2::MessageResponse`)访问,两者同时存在于类型模块中
- **Phase 1 完成时**Provider 实现切换到新类型,旧 `OpenaiProvider` 的临时桥接代码被 Phase 1 的真实实现替换。旧别名仍由 `src/llm/types/mod.rs` 导出,不影响其他模块
- **Phase 2 完成时**`LlmCycle` 内部消息存储从 `Vec<OpenaiChatMessage>` 切换到 `Vec<Message>`,所有 `ChatRequest`/`ChatResponse` 引用被替换。此时对 `ChatRequest``ChatResponse``Message``ContentBlock``ToolDefinition``StopReason` 等旧别名和 `ChatResponse` 结构体加 `#[deprecated]` 标记
- **下一个版本(v0.2.0 或 v1.0.0**:运行 `cargo check` 确认无外部引用后,删除所有 deprecated 别名和 `ChatResponse` 结构体
---
## 7. 开放事项
以下事项已在 9 系文档中充分讨论,本次无修订,但列出以供跟踪:
- [ ] `ContentBlock::Extension` 作为逃生舱的具体使用场景(OpenAI Response 内置工具、未知 block 类型)
- [ ] Anthropic 的 `/v1/messages` 流式 SSE 解析状态机细节(9d Provider 实现文档)
- [ ] `MessageRequest.extra` 中每个 Provider 实际需要的 key 清单(9b 已有草案,Phase 1 实现时细化和验证)
- [ ] Thinking signature 的端到端测试(9f 已有处理策略,Phase 2 时分配合并完成)
- [ ] cost 计算逻辑适配新类型(当前 `CostTracker``Usage` 上工作,类型不变、无需修改,但在集成测试中验证)
- [ ] `Message::ToolResult` 命名 — 9b 中叫 `Tool`(对应 OpenAI 的 `tool` role),本设计改为 `ToolResult`。Anthropic 没有独立的 `tool` roletool_result 是 content block),实施时需验证此命名与所有 Provider 映射的一致性
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+113
View File
@@ -0,0 +1,113 @@
# LLM Provider 统一接口设计(方案 C)
> 本文档已被拆分为独立的子文档以便深入推演和修改。以下保留背景与架构总览作为索引。
>
> **拆分日期**2026-06-15
> **拆分方式**:原 §1-§2 保留在本文件,§3-§13 移至 `9a`-`9g` 子文档。
> 所有"待深入推演"议题保留在对应子文档的原文位置。
---
## 1. 背景与目标
### 1.1 当前状态
`LlmProvider` trait 的请求/响应类型直接绑定到 OpenAI Chat Completion API 格式:
```rust
pub type ChatRequest = OpenaiChatRequest; // 类型别名
pub type ChatResponse = struct { message: OpenaiChatMessage, ... };
pub type Message = OpenaiChatMessage;
```
所有 "内部统一类型" 都是 OpenAI 格式的直接映射。这导致:
| API 类型 | 兼容性 | 代价 |
|----------|--------|------|
| OpenAI ChatDeepSeek、Qwen 等) | ✅ 原生兼容 | 零 |
| Anthropic Messages | ❌ 语义丢失 | 需逆向映射,丢失 thinking 等特性 |
| OpenAI Response API | ❌ 范式不兼容 | ChatResponse 无法表达多类型 output |
| 非标自定义 API | ❌ 无扩展点 | 只能走 extra_body 逃生舱 |
### 1.2 目标
设计一套**真正与 Provider 无关的内部统一类型(IR)**,使得:
1. 所有 Provider 对外暴露的接口完全一致(统一 trait)
2. 每个 Provider 内部自行完成 IR ↔ 原生格式的映射
3. 上层(LlmCycle、AgentSession)完全感知不到具体 Provider
4. 新 Provider 只需实现一次双向映射即可接入
5. 各 API 的独有特性(thinking、内置工具等)有表达空间
### 1.3 非目标
- 不追求覆盖所有 API 的每一个参数(90% 核心流程即可)
- 不追求在不改上层代码的情况下切换 Provider(接口一致足以)
- 不试图让 OpenAI Response API 的内置工具完全融入消息循环(通过逃生舱 + 可选能力 trait)
---
## 2. 架构总览
```
┌──────────────────────────────────────────────────────────────┐
│ 上 层(AgentSession / TaskAgent
│ 只与 IR 类型和 LlmProvider trait 交互 │
├──────────────────────────────────────────────────────────────┤
│ LlmCycle │
│ 循环 / 重试 / Tool 循环 / Hook / Compact / CostTracker │
│ 内部使用 Vec<Message> + MessageRequest │
├──────────────────────────────────────────────────────────────┤
│ LlmProvider trait(核心接口) │
│ chat(MessageRequest) → Result<MessageResponse, LlmError> │
│ chat_stream(MessageRequest) → Stream<StreamEvent> │
│ capabilities() → ProviderCapabilities │
├────────────────┬──────────────────────┬──────────────────────┤
│ OpenaiProvider │ AnthropicProvider │ DeepSeekProvider ... │
│ IR ↔ OpenAI │ IR ↔ Anthropic │ IR ↔ OpenAI 格式 │
│ JSON │ JSON │ (兼容 Chat API) │
└────────────────┴──────────────────────┴──────────────────────┘
```
### 2.1 分层原则
- **IR 层**:全项目唯一的内部表示,与任何具体 API 格式无关
- **Provider 层**:每个 Provider 实现 IR ↔ 原生格式的双向映射,复杂度隔离在此层
- **上层**:只与 IR 和 `LlmProvider` trait 交互,不感知具体 Provider
---
## 文件索引
### 核心设计
| 文件 | 内容 | 包含待深入推演 |
|------|------|---------------|
| [9a-background-and-architecture.md](9a-background-and-architecture.md) | §1 背景与目标 + §2 架构总览 | — |
| [9b-ir-type-system.md](9b-ir-type-system.md) | §3 IR 类型体系:[ContentBlock](9b-ir-type-system.md#31-contentblock--最小的内容单元)、[Message](9b-ir-type-system.md#32-message--统一消息类型)、[StopReason](9b-ir-type-system.md#33-stopreason--统一停止原因)、[MessageRequest](9b-ir-type-system.md#36-messagerequest--统一请求)、[MessageResponse](9b-ir-type-system.md#37-messageresponse--统一响应) | ToolResult 嵌套约束、extra 类型安全性 |
| [9c-llm-provider-trait.md](9c-llm-provider-trait.md) | §4 LlmProvider Trait[核心接口](9c-llm-provider-trait.md#41-核心接口)、[ProviderCapabilities](9c-llm-provider-trait.md#42-providercapabilities)、[StreamEvent](9c-llm-provider-trait.md#43-流式事件-streamevent)、[汇聚算法](9c-llm-provider-trait.md#44-partialmessageresponse--流式事件的汇聚算法) | — |
| [9d-provider-implementations.md](9d-provider-implementations.md) | §5 Provider 实现:[OpenAI](9d-provider-implementations.md#51-openai-provider兼容-chat-api)、[Anthropic](9d-provider-implementations.md#52-anthropicprovidermessages-api)、[Response API](9d-provider-implementations.md#53-openai-response-api草案)、[DeepSeek/Qwen](9d-provider-implementations.md#54-deepseek--qwen-等兼容-provider-的落地策略) | OpenAI 流式转换、Anthropic 流式状态机、Response API 映射、DeepSeek/Qwen 落地 |
| [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md) | §6-§8LlmCycle 改造(build_request、tool 循环、submit_stream、compact+ 上层影响 + 兼容策略 | system prompt 双重表达冲突、compact 适配 |
| [9f-edge-cases.md](9f-edge-cases.md) | §9 边界情况:工具定义传递、Thinking 端到端、Multiple ContentBlock、多 Choice、内置工具 | — |
### 辅助参考
| 文件 | 内容 |
|------|------|
| [9g-risk-and-migration.md](9g-risk-and-migration.md) | §10 风险评估 + §11 类型差异总结 + §12 迁移路径(4 Phase+ §13 验收标准(A1-A10 |
### 待深入推演完整清单
| # | 议题 | 所在文件 | 优先级 |
|---|------|---------|--------|
| 1 | ToolResult 嵌套约束 | [9b-ir-type-system.md](9b-ir-type-system.md#31-contentblock--最小的内容单元) | ✅ 已推演(方案 C:运行时过滤) |
| 2 | extra 的类型安全性 | [9b-ir-type-system.md](9b-ir-type-system.md#36-messagerequest--统一请求) | ✅ 已推演(方案 BResult-based access |
| 3 | StreamEvent 汇聚为 MessageResponse 算法 | [9c-llm-provider-trait.md](9c-llm-provider-trait.md#44-partialmessageresponse--流式事件的汇聚算法) | ✅ 已推演(方案 B:显式边界 + BTreeMap 分桶) |
| 4 | ToolCallStart index 归一化 | [9c-llm-provider-trait.md](9c-llm-provider-trait.md#43-流式事件-streamevent) | ✅ 已推演(自动解决,ToolCallStart 合并到 ContentBlockStart |
| 5 | OpenAI 流式转换实现 | [9d-provider-implementations.md](9d-provider-implementations.md#51-openai-provider兼容-chat-api) | ✅ 已推演(方案 ASseByteStream 通用层 + OpenaiStreamToEvents 状态机,ToolCallEnd 依赖 finalize 兜底,忽略多 Choice |
| 6 | Anthropic 流式状态机设计 | [9d-provider-implementations.md](9d-provider-implementations.md#52-anthropicprovidermessages-api) | ✅ 已推演(轻量分发器:3 状态 + 7 种事件映射 + 零 index 映射) |
| 7 | OpenAI Response API 完整映射表 | [9d-provider-implementations.md](9d-provider-implementations.md#53-openai-response-api草案) | 低 |
| 8 | DeepSeek/Qwen Provider 落地策略 | [9d-provider-implementations.md](9d-provider-implementations.md#54-deepseek--qwen-等兼容-provider-的落地策略) | 低 |
| 9 | system prompt 双重表达冲突 | [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md#62-build_request--新签名) | ✅ 已推演(方案 D:移除 system 字段,IR 只留一个入口,Provider 层负责映射) |
| 10 | compact 在 IR 上的改法与 token 估算 | [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md#66-compact-逻辑调整) | ✅ 已推演(三个子议题各有方案决策 + 二维决策框架) |
| 11 | Thinking signature 端到端传递 | [9f-edge-cases.md](9f-edge-cases.md#92-thinking-的端到端流程) | ✅ 已推演(方案 CMessageComplete 兜底 + finalize 回填) |
+70
View File
@@ -0,0 +1,70 @@
# 背景与架构总览
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §1 背景与目标 + §2 架构总览。
## 1. 背景与目标
### 1.1 当前状态
`LlmProvider` trait 的请求/响应类型直接绑定到 OpenAI Chat Completion API 格式:
```rust
pub type ChatRequest = OpenaiChatRequest; // 类型别名
pub type ChatResponse = struct { message: OpenaiChatMessage, ... };
pub type Message = OpenaiChatMessage;
```
所有 "内部统一类型" 都是 OpenAI 格式的直接映射。这导致:
| API 类型 | 兼容性 | 代价 |
|----------|--------|------|
| OpenAI ChatDeepSeek、Qwen 等) | ✅ 原生兼容 | 零 |
| Anthropic Messages | ❌ 语义丢失 | 需逆向映射,丢失 thinking 等特性 |
| OpenAI Response API | ❌ 范式不兼容 | ChatResponse 无法表达多类型 output |
| 非标自定义 API | ❌ 无扩展点 | 只能走 extra_body 逃生舱 |
### 1.2 目标
设计一套**真正与 Provider 无关的内部统一类型(IR)**,使得:
1. 所有 Provider 对外暴露的接口完全一致(统一 trait)
2. 每个 Provider 内部自行完成 IR ↔ 原生格式的映射
3. 上层(LlmCycle、AgentSession)完全感知不到具体 Provider
4. 新 Provider 只需实现一次双向映射即可接入
5. 各 API 的独有特性(thinking、内置工具等)有表达空间
### 1.3 非目标
- 不追求覆盖所有 API 的每一个参数(90% 核心流程即可)
- 不追求在不改上层代码的情况下切换 Provider(接口一致足以)
- 不试图让 OpenAI Response API 的内置工具完全融入消息循环(通过逃生舱 + 可选能力 trait)
---
## 2. 架构总览
```
┌──────────────────────────────────────────────────────────────┐
│ 上 层(AgentSession / TaskAgent
│ 只与 IR 类型和 LlmProvider trait 交互 │
├──────────────────────────────────────────────────────────────┤
│ LlmCycle │
│ 循环 / 重试 / Tool 循环 / Hook / Compact / CostTracker │
│ 内部使用 Vec<Message> + MessageRequest │
├──────────────────────────────────────────────────────────────┤
│ LlmProvider trait(核心接口) │
│ chat(MessageRequest) → Result<MessageResponse, LlmError> │
│ chat_stream(MessageRequest) → Stream<StreamEvent> │
│ capabilities() → ProviderCapabilities │
├────────────────┬──────────────────────┬──────────────────────┤
│ OpenaiProvider │ AnthropicProvider │ DeepSeekProvider ... │
│ IR ↔ OpenAI │ IR ↔ Anthropic │ IR ↔ OpenAI 格式 │
│ JSON │ JSON │ (兼容 Chat API) │
└────────────────┴──────────────────────┴──────────────────────┘
```
### 2.1 分层原则
- **IR 层**:全项目唯一的内部表示,与任何具体 API 格式无关
- **Provider 层**:每个 Provider 实现 IR ↔ 原生格式的双向映射,复杂度隔离在此层
- **上层**:只与 IR 和 `LlmProvider` trait 交互,不感知具体 Provider
+479
View File
@@ -0,0 +1,479 @@
# IR 类型体系
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §3 IR 类型体系。
>
> **相关文件:**
> - [9c-llm-provider-trait.md](9c-llm-provider-trait.md) — 使用本文定义的 IR 类型的 LlmProvider trait
> - [9d-provider-implementations.md](9d-provider-implementations.md) — Provider 的 IR ↔ 原生格式映射
> - [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md) — LlmCycle 改造(内部使用 `Vec<Message>` + `MessageRequest`
> - [9f-edge-cases.md](9f-edge-cases.md) — 边界情况(Thinking 端到端、Multiple ContentBlock 等)
## 3. IR 类型体系
### 3.1 ContentBlock —— 最小的内容单元
```rust
/// 跨 Provider 统一的内容块。
///
/// 设计思路:
/// - ToolUse/ToolResult 作为 content blockAnthropic 风格)
/// - Thinking 作为一等公民
/// - Extension 作为逃生舱
#[derive(Debug, Clone)]
pub enum ContentBlock {
/// 纯文本
Text {
text: String,
},
/// 图片(base64 或 URL
Image {
source: ImageSource,
},
/// 音频输入
Audio {
source: AudioSource,
},
/// 文件上传
File {
source: FileSource,
},
/// 工具调用请求(由 Assistant 发起)
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
/// 工具调用结果(回传)
ToolResult {
tool_use_id: String,
content: Vec<ContentBlock>,
is_error: bool,
},
```
> **✅ 推演结论(2026-06-16):采用方案 C —— 运行时过滤 + 构造时辅助 + 文档约定**
>
> **决策:** 保持 `content: Vec<ContentBlock>` 不变,不引入 `ToolResultContent` 类型。
>
> **理由:**
> 1. ToolResult 主要由 LlmCycle 的工具循环构建(`Message::tool_result()`),而非用户手写,
> 构造链本身已倾向于只产生 Text block。运行时过滤只是安全网。
> 2. 引入 `ToolResultContent` 会膨胀类型体系,遍历 content 的代码需要为 ToolResult
> 单独写一层,开发者负担大于收益。
> 3. 未来如果 Provider 放宽约束(如 Anthropic 支持 tool_result 嵌套 tool_use),
> 方向 B 只需要删掉过滤代码,方向 A 需要改类型定义 + 所有 match 分支,成本更高。
>
> **具体做法:**
> - `ContentBlock::ToolResult.content` 保持 `Vec<ContentBlock>` 不变
> - 新增辅助方法 `ContentBlock::is_valid_in_tool_result(&self) -> bool`
> 返回 `self` 是否是 ToolResult 中允许的类型(Text、Image、Audio、File、Extension
> - 每个 Provider 的 IR→原生格式映射层,在将 ToolResult content 转为 Provider 格式时,
> **静默过滤 + warn log**:过滤掉 Thinking、ToolUse、ToolResult 等不允许的 block
> 使用 `tracing::warn!("忽略 ContentBlock::{:?} 在 ToolResult 中", block)` 记录
> - `Message::tool_result()` 构造函数确保只产生 Text block(但不对类型做强制约束)
> - **不返回错误**:非法嵌套不阻塞主流程,静默丢掉无关内容
>
> **何时实现:** Phase 4 实现 AnthropicProvider 的 ToolResult 映射时一起做。
> **影响范围:** Provider 映射层(约 3-5 行过滤代码)+ `Message::tool_result()` 构造。
/// 思考内容(Anthropic thinking / OpenAI reasoning
Thinking {
text: String,
/// Anthropic 的 thinking 签名(用于验证思考未被篡改)。
/// OpenAI 无此概念,为 None。
signature: Option<String>,
},
/// 逃生舱 —— Provider 特有且无法映射的 content block
Extension {
kind: String,
data: serde_json::Value,
},
}
#[derive(Debug, Clone)]
pub struct ImageSource {
pub data: String, // base64 或 URL
pub mime_type: String, // "image/png", "image/jpeg", "image/webp"
pub is_url: bool, // true = URL, false = base64
}
#[derive(Debug, Clone)]
pub struct AudioSource {
pub data: String, // base64
pub format: AudioFormat, // 复用现有 AudioFormat
}
#[derive(Debug, Clone)]
pub struct FileSource {
pub data: String,
pub filename: Option<String>,
pub mime_type: Option<String>,
}
```
#### 设计决策:为什么把 ToolUse 放在 ContentBlock 中
| 维度 | OpenAI 风格(独立 tool_calls 字段) | Anthropic 风格(ContentBlock 内) |
|------|-----------------------------------|----------------------------------|
| Assistant 消息结构 | `{ content, tool_calls }` | `{ content: [text, tool_use, ...] }` |
| 文本与工具的顺序 | 分离,无法交错 | 按序排列,可交错 |
| 多工具表达 | `tool_calls: [...]` 数组 | content 中多个 `tool_use` block |
| **统一后的处理逻辑** | 需同时检查 content 和 tool_calls | **只需遍历一次 content blocks** |
选择 Anthropic 风格作为统一表达,因为遍历 content 即可获取所有信息,处理逻辑更简单。
### 3.2 Message —— 统一消息类型
```rust
#[derive(Debug, Clone)]
pub enum Message {
System {
content: Vec<ContentBlock>,
},
User {
content: Vec<ContentBlock>,
},
Assistant {
content: Vec<ContentBlock>,
// 注意:ToolUse 在 content 中,不需要独立字段
},
Tool {
content: Vec<ContentBlock>,
/// 关联的 tool_call_idOpenAI 格式需要)
tool_call_id: String,
},
}
```
#### 与当前 OpenaiChatMessage 的映射
| 当前类型 | 新 IR 类型 | 说明 |
|---------|-----------|------|
| `OpenaiChatMessage::Developer { content, name }` | `Message::System { content }` | 合并到 SystemAnthropic 无 Developer role |
| `OpenaiChatMessage::System { content, name }` | `Message::System { content }` | name 暂不保留 |
| `OpenaiChatMessage::User { content, name }` | `Message::User { content }` | `ContentField` 统一为 `Vec<ContentBlock>` |
| `OpenaiChatMessage::Assistant { content, tool_calls, refusal, name }` | `Message::Assistant { content }` | tool_calls → ContentBlock::ToolUserefusal → ContentBlock::Text |
| `OpenaiChatMessage::Tool { content, tool_call_id }` | `Message::Tool { content, tool_call_id }` | 基本一致 |
| `OpenaiChatMessage::Function { content, name }` | `Message::Tool { tool_call_id: name }` | 已废弃,映射到 Tool |
#### 便捷构造函数
```rust
impl Message {
pub fn user(text: impl Into<String>) -> Self;
pub fn assistant(text: impl Into<String>) -> Self;
pub fn system(text: impl Into<String>) -> Self;
pub fn tool_result(tool_call_id: impl Into<String>, text: impl Into<String>) -> Self;
}
```
### 3.3 StopReason —— 统一停止原因
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopReason {
/// 正常结束
Stop,
/// 达到 token 上限
Length,
/// 触发工具调用
ToolUse,
/// 被内容过滤
ContentFilter,
/// 达到最大 token 数
MaxTokens,
/// 命中停止序列
StopSequence,
/// 其他 / Provider 特有
Other,
}
```
#### 跨 Provider 映射
| IR StopReason | OpenAI (finish_reason) | Anthropic (stop_reason) |
|---|---|---|
| `Stop` | `"stop"` | `"end_turn"` |
| `Length` / `MaxTokens` | `"length"` | `"max_tokens"` |
| `ToolUse` | `"tool_calls"` | `"tool_use"` |
| `ContentFilter` | `"content_filter"` | — |
| `StopSequence` | — | `"stop_sequence"` |
| `Other` | `"function_call"` / 其他 | 其他 |
### 3.4 ThinkingConfig —— 思考配置
```rust
#[derive(Debug, Clone)]
pub struct ThinkingConfig {
/// 思考 token 预算
pub budget_tokens: u32,
}
```
跨 Provider 特性:Anthropic 原生支持,OpenAI 通过 `reasoning_tokens` 间接支持。
### 3.5 ToolDefinition —— 统一工具定义
```rust
#[derive(Debug, Clone)]
pub struct ToolDefinition {
pub name: String,
pub description: Option<String>,
pub parameters: serde_json::Value,
pub strict: Option<bool>,
}
// ToolChoice 枚举保持不变
```
与当前 `OpenaiToolDefinition` 一致,不需要改动。
### 3.6 MessageRequest —— 统一请求
```rust
#[derive(Debug, Clone)]
pub struct MessageRequest {
// ════════════════════════════════════════════
// 核心共通参数(所有 Provider 都有的概念)
// ════════════════════════════════════════════
pub model: String,
pub messages: Vec<Message>,
// 没有 `system` 字段——系统提示统一通过 `Message::System { content }`
// 在 `messages` 中表达。各 Provider 在 IR→原生映射层自行处理差异。
pub tools: Vec<ToolDefinition>,
pub tool_choice: ToolChoice,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub top_p: Option<f32>,
pub stop_sequences: Vec<String>,
pub stream: bool,
// ════════════════════════════════════════════
// 跨 Provider 但非全部支持
// ════════════════════════════════════════════
pub thinking: Option<ThinkingConfig>,
// ════════════════════════════════════════════
// 逃生舱:Provider 特有参数
// ════════════════════════════════════════════
/// Provider 特有的扩展参数。
/// 约定:每个 Provider 声明自己读取哪些 key
/// 遇到不认识的 key 静默忽略。
pub extra: HashMap<String, serde_json::Value>,
}
```
> **✅ 推演结论(2026-06-16):方案 B —— Result-based access + 可选 get_extra_as 扩展**
>
> **决策:** 保持 `HashMap<String, serde_json::Value>` 作为存储格式,不引入 Per-Provider extra struct
> 作为硬约束。核心改动是将 `get_extra` 返回类型从 `Option<T>` 改为 `Result<Option<T>, ExtraError>`
> 使类型错误可被发现和传播。
>
> **理由:**
> 1. extra 的本质是逃生舱——如果给逃生舱做全类型安全,就失去了逃生舱的灵活性。
> 方向 APer-Provider struct)的 N+1 膨胀和维护负担超过了收益。
> 2. 三种 extra 参数特性不同,需要不同的处理策略:
> - **非关键参数**`frequency_penalty``seed` 等):类型错了静默降级即可
> - **关键参数**`response_format``previous_response_id` 等):类型错了必须报错
> - **整体读取**:Provider 想一次性结构化读取时,通过 `get_extra_as` 自行定义 struct
> 3. `get_extra_as` 提供了"struct 方案"的可选路径,但不作为硬约束,
> 每个 Provider 在自己的模块中决定是否使用。
>
> **具体做法:**
> - 新增 `ExtraError` 枚举(`TypeMismatch` / `Deserialize` 两种变体)
> - `get_extra` 签名改为 `Result<Option<T>, ExtraError>`key 不存在 = `Ok(None)`,类型不匹配 = `Err`
> - 新增 `get_extra_opt`:类型不匹配时 warn log + 返回 `None`,用于非关键参数
> - 新增 `get_extra_as`:整体反序列化 extra 到自定义 structProvider 内部使用)
> - `set_extra` 保持 `impl Into<Value>` 不变,不引入 Result(调用点无错误处理负担)
> - Provider 映射层:关键参数用 `get_extra` + `?`,非关键参数用 `get_extra_opt`
> - **不引入运行时 key 声明验证**——Provider 支持的 keys 仅在代码注释中声明,依赖集成测试保证正确性
>
> **何时实现:** Phase 2 实现 `MessageRequest` 类型时一起完成
> **影响范围:** `MessageRequest`(方法签名变更)+ 每个 Provider 的映射层(适配新签名)
### ExtraError —— extra 参数访问错误
```rust
/// extra 参数访问错误。
///
/// 注意:`NotFound` 不等价于"错误"——optional 参数未设置(key 不存在)是正常状态,
/// 返回 `Ok(None)` 而非错误。本类型只覆盖"存在但类型不对"的场景。
#[derive(thiserror::Error, Debug)]
pub enum ExtraError {
/// key 存在但值的类型与要求不匹配。
#[error("extra 字段 `{key}` 类型不匹配: {details}")]
TypeMismatch {
key: String,
details: String,
},
/// 整体反序列化失败(用于 get_extra_as 场景)。
#[error("extra 反序列化失败: {0}")]
Deserialize(String),
}
```
### MessageRequest —— extra 访问方法
```rust
impl MessageRequest {
/// 核心方法:安全读取单个 extra 字段。
///
/// - key 不存在 → `Ok(None)`
/// - key 存在且类型匹配 → `Ok(Some(value))`
/// - key 存在但类型不匹配 → `Err(ExtraError::TypeMismatch)`
///
/// Provider 映射层对**关键参数**(如 response_format)使用此方法 + `?`
pub fn get_extra<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, ExtraError> {
match self.extra.get(key) {
None => Ok(None),
Some(value) => serde_json::from_value(value.clone())
.map(Some)
.map_err(|e| ExtraError::TypeMismatch {
key: key.to_string(),
details: e.to_string(),
}),
}
}
/// 宽松读取 —— 类型不匹配时 warn log + 返回 `None`
///
/// 适用于**非关键参数**(如 frequency_penalty、seed、presence_penalty)。
/// Provider 映射层无需处理 Result,遇到类型错误静默降级。
pub fn get_extra_opt<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
match self.get_extra(key) {
Ok(v) => v,
Err(e) => {
tracing::warn!("忽略 extra 参数 `{}`: {}", key, e);
None
}
}
}
/// 整体反序列化 extra 到自定义结构体。
///
/// 适用于 Provider 想在映射层内一次性读取所有 extra 参数的场景。
/// Provider 在自己的模块中定义 struct,借助 `#[serde(deny_unknown_fields)]` 获得约束:
///
/// ```rust,ignore
/// // 在 openai/provider.rs
/// #[derive(Deserialize)]
/// #[serde(deny_unknown_fields)]
/// struct OpenaiExtraParams {
/// frequency_penalty: Option<f32>,
/// seed: Option<i64>,
/// response_format: Option<ResponseFormat>,
/// }
///
/// // 映射层中:
/// let extra: OpenaiExtraParams = request.get_extra_as()?;
/// ```
///
/// **注意:** 使用 `deny_unknown_fields` 时,来自其他 Provider 的 extra key
/// 会导致反序列化失败。不设置 `deny_unknown_fields` 则自动忽略无关 key。
pub fn get_extra_as<T: DeserializeOwned>(&self) -> Result<T, ExtraError> {
let obj = self
.extra
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
serde_json::from_value(serde_json::Value::Object(obj))
.map_err(|e| ExtraError::Deserialize(e.to_string()))
}
/// 设置 extra 参数。
///
/// 值通过 `impl Into<serde_json::Value>` 传入,支持:
/// - `set_extra("user", "abc")` —— `&str``Value::String`
/// - `set_extra("seed", Value::from(42_i64))` —— 显式 Value 构造
/// - `set_extra("logit_bias", json!({"2435": -100}))` —— json! 宏
///
/// 对复杂类型推荐使用 `json!()` 宏以保证可读性。
/// 无返回值 —— 调用点无错误处理负担(HashMap insert 不会失败)。
pub fn set_extra(&mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) {
self.extra.insert(key.into(), value.into());
}
}
```
### Provider 映射层使用模式
```rust
// === OpenAI provider IR→native 映射示例 ===
// 非关键参数 —— get_extra_opt,类型错了静默降级
let frequency_penalty: Option<f32> = request.get_extra_opt("frequency_penalty");
let presence_penalty: Option<f32> = request.get_extra_opt("presence_penalty");
let seed: Option<i64> = request.get_extra_opt("seed");
// 关键参数 —— get_extra + ?,类型错误必须报出来
let response_format: Option<ResponseFormat> = request.get_extra("response_format").map_err(|e| {
LlmError::Other(format!("extra 参数读取失败: {e}"))
})?;
// 或者 Provider 定义自己的 struct 一把读取
// let extra: OpenaiExtraParams = request.get_extra_as()?;
```
### 使用约定
- 每个 Provider 在模块顶部的注释中声明自己读取的 extra key
- Provider 遇到不认识的 key **静默忽略**
- **key 命名规范**:使用 `snake_case`,与 Provider 原生 API 的参数名一致
- **key 去重规则**:extra 中出现的 key 不能与 `MessageRequest` 的命名参数字段重复
(如 `max_tokens` 是命名参数,不能在 extra 中重复设置)
- 非关键参数优先使用 `get_extra_opt`,关键参数使用 `get_extra` + 显式错误处理
### 典型 extra key 约定
| key | 值类型 | 使用者 | 读取方式 | 说明 |
|-----|--------|--------|---------|------|
| `frequency_penalty` | `f32` | OpenAI | `get_extra_opt` | 频率惩罚 |
| `presence_penalty` | `f32` | OpenAI | `get_extra_opt` | 存在惩罚 |
| `logit_bias` | `HashMap<String, f32>` | OpenAI | `get_extra_opt` | Token 偏置 |
| `seed` | `i64` | OpenAI | `get_extra_opt` | 随机种子 |
| `service_tier` | `String` | OpenAI | `get_extra_opt` | 服务等级 |
| `user` | `String` | OpenAI | `get_extra_opt` | 最终用户标识 |
| `response_format` | `ResponseFormat` | OpenAI | `get_extra` | **关键**:响应格式 |
| `parallel_tool_calls` | `bool` | OpenAI | `get_extra_opt` | 是否并行工具 |
| `built_in_tools` | `Vec<String>` | OpenAI Response | `get_extra_opt` | 内置工具列表 |
| `previous_response_id` | `String` | OpenAI Response | `get_extra` | **关键**:前序响应 ID |
### 3.7 MessageResponse —— 统一响应
```rust
#[derive(Debug, Clone)]
pub struct MessageResponse {
pub id: String,
pub model: String,
pub message: Message,
pub usage: Usage, // 复用现有 Usage 类型
pub stop_reason: StopReason,
/// Provider 特有扩展数据
pub extra: HashMap<String, serde_json::Value>,
}
impl MessageResponse {
/// 提取纯文本内容(拼接所有 Text block)
pub fn text(&self) -> String;
}
```
#### Usage 的跨 Provider 兼容性
当前 `Usage` 类型:
```rust
pub struct Usage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
pub completion_tokens_details: Option<CompletionTokensDetails>,
pub prompt_tokens_details: Option<PromptTokensDetails>,
}
```
| Provider | prompt_tokens | completion_tokens | 其他 | 映射方式 |
|----------|--------------|-------------------|------|---------|
| OpenAI | `usage.prompt_tokens` | `usage.completion_tokens` | `details.*` | 直接使用 |
| Anthropic | `usage.input_tokens` | `usage.output_tokens` | `cache_*` 映射到 `details.cached_tokens` | 映射赋值 |
`Usage` 类型可以直接复用,无需修改。
+446
View File
@@ -0,0 +1,446 @@
# LlmProvider Trait 设计
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §4 LlmProvider Trait 设计。
>
> **相关文件:**
> - [9b-ir-type-system.md](9b-ir-type-system.md) — IR 类型定义(MessageRequest、MessageResponse、StreamEvent 等)
> - [9d-provider-implementations.md](9d-provider-implementations.md) — 各 Provider 的具体实现策略
> - [9f-edge-cases.md](9f-edge-cases.md) — Thinking signature 等边界情况(与 StreamEvent 设计关联)
## 4. LlmProvider Trait 设计
### 4.1 核心接口
```rust
#[async_trait]
pub trait LlmProvider: Send + Sync {
/// 发送消息请求,获取完整响应。
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError>;
/// 流式消息请求,返回语义化事件流。
async fn chat_stream(
&self,
request: MessageRequest,
) -> Result<
Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>,
LlmError,
>;
/// 返回 Provider 的能力描述。
fn capabilities(&self) -> ProviderCapabilities;
}
```
### 4.2 ProviderCapabilities
```rust
#[derive(Debug, Clone)]
pub struct ProviderCapabilities {
/// Provider 标识名称
pub provider_name: &'static str,
/// 支持的模型列表(None = 不限制)
pub supported_models: Option<Vec<String>>,
/// 特性标记
pub features: ProviderFeatures,
}
#[derive(Debug, Clone, Default)]
pub struct ProviderFeatures {
pub streaming: bool,
pub thinking: bool,
pub vision: bool,
pub audio_input: bool,
pub tool_use: bool,
pub parallel_tool_calls: bool,
/// true=OpenAI风格(system in messages), false=Anthropic风格(system as param)
pub system_prompt_in_messages: bool,
pub max_context_window: u32,
}
```
#### capabilities() 的使用场景
1. **LlmCycle**:根据 `features.thinking` 决定是否启用 thinking 模式
2. **AgentBuilder**:在构建时校验模型是否支持所需特性
3. **UI/CLI**:展示 Provider 的能力矩阵
4. **智能路由**:根据能力自动选择最佳 Provider
### 4.3 流式事件 StreamEvent
```rust
/// 流式事件 —— 流式响应的语义化增量构建过程。
/// 一组 StreamEvent 最终可汇聚为一个完整的 MessageResponse。
#[derive(Debug, Clone)]
pub enum StreamEvent {
// ── Meta ──
/// 消息开始
MessageStart { id: String, model: String },
// ── Content Block 边界 ──
/// Content block 开始(index = 全局 content block 序号)
ContentBlockStart { index: u32, block_type: ContentBlockType },
/// Content block 结束
ContentBlockEnd { index: u32 },
// ── 块内增量(无 index,隐含属于当前活跃 block)──
/// 文本增量
TextDelta { text: String },
/// 思考增量(Anthropic thinking block
ThinkingDelta { text: String },
/// 拒绝增量(OpenAI refusal,汇聚为 ContentBlock::Text
RefusalDelta { text: String },
// ── Tool Call 参数(index = block index)──
/// 工具参数增量
ToolCallArgumentsDelta { index: u32, arguments: String },
/// 工具参数结束,可尝试解析 JSON
ToolCallEnd { index: u32 },
// ── 汇总 ──
/// Token 用量(字段级合并,见 PartialUsage
CostUpdate { usage: PartialUsage },
/// 消息完成(thinking_signature 仅 Anthropic 场景,回填到最后的 Thinking block
MessageComplete { stop_reason: StopReason, thinking_signature: Option<String> },
// ── 错误 ──
Error { message: String },
}
/// ContentBlock 的类型标识,嵌入在 ContentBlockStart 事件中。
#[derive(Debug, Clone)]
pub enum ContentBlockType {
Text,
Thinking,
Refusal,
ToolUse { id: String, name: String },
}
/// CostUpdate 中携带的用量数据——只含本次更新中真正下发的字段。
///
/// 汇聚算法做字段级合并(覆盖各字段的 Some 值),而非全量覆盖。
/// 解决 Provider 分多次下发 Usage 的问题:
/// - OpenAI: 一次全量下发(所有字段都有值)
/// - Anthropic: message_start 时下发 input_tokensmessage_delta 时下发 output_tokens
#[derive(Debug, Clone, Default)]
pub struct PartialUsage {
pub prompt_tokens: Option<u32>,
pub completion_tokens: Option<u32>,
pub total_tokens: Option<u32>,
pub completion_tokens_details: Option<CompletionTokensDetails>,
pub prompt_tokens_details: Option<PromptTokensDetails>,
}
```
#### 设计决策:为什么移除 ToolCallStart
`ToolCallStart { index, id, name }` 的语义本质是一个 ContentBlock 的开始(类型为 ToolUse),
没有单独存在的必要。合并到 `ContentBlockStart.block_type = ToolUse { id, name }` 后:
- **index 语义统一**ContentBlockStart.index 是全文档唯一的 content block 序号
- **事件减少**:每次 tool_use block 开始少发一个事件,Anthropic 映射更自然(`content_block_start``ContentBlockStart`
- **#4议题自动解决**:index 在 IR 层面始终为全局序号,OpenAI Provider 内部的局部→全局映射完全封装在 Provider 层,
不在 IR 中暴露差异
#### 设计决策:为什么 TextDelta 不带 index
- 单 SSE 连接内 TCP 保证字节序,TextDelta 必然属于最后一个 ContentBlockStart 开始的 block
- 不携带 index 可减少事件体积
- 如果未来 LlmCycle 需要跨 Provider 合并流,可向后兼容地添加 index 字段(降级策略:无 index 时默认归属当前活跃 block)
#### 设计决策:CostUpdate 使用 PartialUsage 做字段级合并
覆盖策略(取最新值)在 Anthropic 场景下出错——Anthropic 分两次下发:
```
CostUpdate #1: { prompt_tokens: Some(100), completion_tokens: None, total_tokens: Some(100) }
CostUpdate #2: { prompt_tokens: None, completion_tokens: Some(50), total_tokens: Some(150) }
```
如果直接 `Usage` 全量覆盖,第一次的 prompt_tokens 会被第二次的 `None` 清零。
改为 `PartialUsage`(字段级 `Option`)后,汇聚算法只更新 `Some` 的字段,避免清零。
#### 与当前 StreamEvent 的对比
| 当前 StreamEvent | 新 StreamEvent | 理由 |
|-----------------|---------------|------|
| `AssistantTextDelta { text }` | `TextDelta { text }` | 简洁化 |
| — | `ThinkingDelta { text }` | Anthropic 需要 |
| — | `RefusalDelta { text }` | OpenAI 需要 |
| `ToolExecutionStarted { tool_name, input, tool_call_id }` | `ContentBlockStart { index, ToolUse { id, name } }` + `ToolCallArgumentsDelta { index, arguments }` | 拆分为 block 边界 + 参数增量 |
| `ToolExecutionCompleted` | **移除** | LlmCycle 层事件,非 Provider 层 |
| — | `ContentBlockStart` / `ContentBlockEnd` | 显式 block 边界标记 |
| — | `BlockContentType` | ContentBlock 类型标识 |
| `CostUpdate { usage: Usage }` | `CostUpdate { usage: PartialUsage }` | 字段级合并,兼容多 Provider |
| `TurnComplete { reason }` | `MessageComplete { stop_reason, thinking_signature: Option<String> }` | 语义更准确 + thinking 签名回填 |
| `Error { message }` | 保留 | ✅ |
| — | `MessageStart { id, model }` | Anthropic 需要 |
| — | `ToolCallEnd { index }` | 明确参数完整时间点 |
| `ToolCallStart { index, id, name }` | **移除** | 合并到 ContentBlockStart.ToolUse |
---
### 4.4 PartialMessageResponse —— 流式事件的汇聚算法
> **✅ 推演结论(2026-06-17):采用方案 B(显式边界)—— ContentBlockStart/End 声明 block 边界,BTreeMap 按 index 分桶组装**
>
> **核心思路:** 放弃"类型切换推断 block 边界"的隐含方案。为 StreamEvent 增加 `ContentBlockStart``ContentBlockEnd`
> 事件,使每个 block 的开始和结束都有精确的事件标记。汇聚算法由"线性累积 + 隐含 flush + 延迟排序"改为
> **"按 index 分桶 + 排序组装"**,彻底消除对事件到达顺序的依赖。
>
> **决策理由:**
> 1. 类型切换推断在 ToolCallEnd 后跟 TextDelta 的场景下导致顺序错乱(ToolUse 被延迟插入到 Text 之后)
> 2. 显式边界将位置信息绑定到 index,不依赖事件到达时序,容错性更强
> 3. Anthropic 的 `content_block_start/stop` 天然映射为 `ContentBlockStart/End`
> 4. OpenAI 的无边界流式由 Provider 内部分析 delta 类型来合成边界,封装在 Provider 层
> 5. #4ToolCallStart index 归一化)自动解决——index 统一为全局 content block 序号
#### PartialMessageResponse 结构体
```rust
use std::collections::{BTreeMap, HashMap, HashSet};
use serde_json::Value;
/// 流式事件的累积状态 —— 按 index 分桶,逐个构建 ContentBlock。
#[derive(Debug, Default)]
pub struct PartialMessageResponse {
// ── 来自 MessageStart ──
pub id: Option<String>,
pub model: Option<String>,
/// 按 index 分桶的 ContentBlock 构建器(BTreeMap 保证升序遍历)
pub blocks: BTreeMap<u32, ContentBlockBuilder>,
/// Tool call 参数累积(按 block index 关联)
pub tool_call_args: HashMap<u32, String>,
/// 已收到 ContentBlockEnd 的 block index 集合
pub block_completion: HashSet<u32>,
/// 当前最后打开的 block index(供无 index 的 TextDelta 定位)
pub last_open_index: Option<u32>,
/// Token 用量(字段级合并后的最终值)
pub usage: Usage,
/// 结束状态
pub stop_reason: Option<StopReason>,
/// MessageComplete 携带的 thinking signature(留待 finalize 回填)
pub thinking_signature: Option<String>,
pub is_errored: bool,
pub is_complete: bool,
}
/// 按 index 分桶的 ContentBlock 构建器。
#[derive(Debug)]
pub enum ContentBlockBuilder {
Text(String),
Thinking { buffer: String, signature: Option<String> },
Refusal(String),
ToolUse { id: String, name: String },
}
```
#### apply_to 算法
```rust
impl StreamEvent {
/// 将当前事件应用到 PartialMessageResponse 上。
/// 返回 true 表示正常处理,false 表示应停止处理后续事件。
pub fn apply_to(&self, state: &mut PartialMessageResponse) -> bool {
match self {
// ──────────────── Meta ────────────────
StreamEvent::MessageStart { id, model } => {
state.id = Some(id.clone());
state.model = Some(model.clone());
true
}
// ──────────────── Block 边界 ────────────────
StreamEvent::ContentBlockStart { index, block_type } => {
let builder = match block_type {
ContentBlockType::Text => ContentBlockBuilder::Text(String::new()),
ContentBlockType::Thinking => ContentBlockBuilder::Thinking {
buffer: String::new(),
signature: None,
},
ContentBlockType::Refusal => ContentBlockBuilder::Refusal(String::new()),
ContentBlockType::ToolUse { id, name } =>
ContentBlockBuilder::ToolUse { id: id.clone(), name: name.clone() },
};
state.blocks.entry(*index).or_insert(builder);
state.last_open_index = Some(*index);
true
}
StreamEvent::ContentBlockEnd { index } => {
state.block_completion.insert(*index);
true
}
// ──────────────── 块内增量 ────────────────
StreamEvent::TextDelta { text } => {
// 定位到最后打开的 block
if let Some(idx) = state.last_open_index {
if let Some(ContentBlockBuilder::Text(ref mut buf)) = state.blocks.get_mut(&idx) {
buf.push_str(text);
}
} else {
tracing::warn!("TextDelta 到达时无活跃 block");
}
true
}
StreamEvent::ThinkingDelta { text } => {
if let Some(idx) = state.last_open_index {
if let Some(ContentBlockBuilder::Thinking { ref mut buffer, .. }) =
state.blocks.get_mut(&idx)
{
buffer.push_str(text);
}
} else {
tracing::warn!("ThinkingDelta 到达时无活跃 block");
}
true
}
StreamEvent::RefusalDelta { text } => {
if let Some(idx) = state.last_open_index {
if let Some(ContentBlockBuilder::Refusal(ref mut buf)) =
state.blocks.get_mut(&idx)
{
buf.push_str(text);
}
} else {
tracing::warn!("RefusalDelta 到达时无活跃 block");
}
true
}
// ──────────────── Tool Call 参数 ────────────────
StreamEvent::ToolCallArgumentsDelta { index, arguments } => {
state
.tool_call_args
.entry(*index)
.or_default()
.push_str(arguments);
true
}
StreamEvent::ToolCallEnd { index } => {
// ToolCallEnd 只做标记,参数在 finalize 时解析
state.block_completion.insert(*index);
true
}
// ──────────────── 汇总 ────────────────
StreamEvent::CostUpdate { usage } => {
Self::apply_cost_update(state, usage);
true // 即使 is_complete 后 CostUpdate 仍然可以到达
}
StreamEvent::MessageComplete {
stop_reason,
thinking_signature,
} => {
state.stop_reason = Some(*stop_reason);
state.thinking_signature = thinking_signature.clone();
state.is_complete = true;
true
}
// ──────────────── 错误 ────────────────
StreamEvent::Error { message } => {
state.is_errored = true;
tracing::error!("流式处理中发生错误: {}", message);
false // 终止处理
}
}
}
/// 字段级合并 CostUpdate(仅覆盖 Some 的字段)。
fn apply_cost_update(state: &mut PartialMessageResponse, update: &PartialUsage) {
if let Some(v) = update.prompt_tokens {
state.usage.prompt_tokens = v;
}
if let Some(v) = update.completion_tokens {
state.usage.completion_tokens = v;
}
if let Some(v) = update.total_tokens {
state.usage.total_tokens = v;
}
if update.completion_tokens_details.is_some() {
state.usage.completion_tokens_details = update.completion_tokens_details;
}
if update.prompt_tokens_details.is_some() {
state.usage.prompt_tokens_details = update.prompt_tokens_details;
}
}
}
```
#### finalize —— 完成逻辑
```rust
impl PartialMessageResponse {
/// 将所有累积状态转化为最终的 MessageResponse。
pub fn finalize(mut self) -> Result<MessageResponse, LlmError> {
let id = self.id.unwrap_or_else(|| "stream-unknown".to_string());
let model = self.model.unwrap_or_else(|| "unknown".to_string());
let stop_reason = self.stop_reason.unwrap_or(StopReason::Other);
// 1. 按 index 升序遍历所有 blocks,转换为 ContentBlock
let mut content: Vec<ContentBlock> = Vec::new();
for (_index, builder) in std::mem::take(&mut self.blocks).into_iter() {
let block = match builder {
ContentBlockBuilder::Text(text) => ContentBlock::Text { text },
ContentBlockBuilder::Thinking { buffer, mut signature } => {
// 如果 Thinking block 的 signature 尚未填充,用 MessageComplete 的回填
if signature.is_none() {
signature = self.thinking_signature.clone();
}
ContentBlock::Thinking {
text: buffer,
signature,
}
}
ContentBlockBuilder::Refusal(text) => ContentBlock::Text { text },
ContentBlockBuilder::ToolUse { id, name } => {
let arguments = self.tool_call_args.remove(&(_index as u32))
.unwrap_or_default();
let input: Value = serde_json::from_str(&arguments)
.unwrap_or(Value::Null);
ContentBlock::ToolUse { id, name, input }
}
};
content.push(block);
}
Ok(MessageResponse {
id,
model,
message: Message::Assistant { content },
usage: self.usage,
stop_reason,
extra: HashMap::new(),
})
}
/// 检查是否已收到足以构造"有意义"响应的数据。
pub fn is_meaningful(&self) -> bool {
self.id.is_some() && (self.is_complete || self.is_errored)
}
}
```
#### 边界情况处理
| 边界场景 | 处理策略 | 理由 |
|----------|---------|------|
| MessageStart 前收到 TextDelta | warn log,忽略增量 | 不合法流,防御性处理 |
| ToolCallArgumentsDelta 乱序 | HashMap key 天然容忍 | index 作为关联 key,到达顺序不影响最终累积 |
| 多次 CostUpdate | 字段级合并(PartialUsage | OpenAI 一次全量,Anthropic 分两次,OpenAI Response 可能多次 |
| 同一 index 收到多次 ContentBlockStart | `entry(*index).or_insert()` 幂等 | 第二次不覆盖已有内容 |
| MessageComplete 后 CostUpdate 才到 | 不阻断,仍然合并 | Usage 最终值可能最后才到 |
| ContentBlockEnd 缺失 | finalize 时仍然输出内容 | 不完整的 block 也是内容 |
| 无任何 ContentBlockStart | finalize 返回空的 Assistant 消息 | 边界场景,不阻断 |
| Error 后收到其他事件 | Error 返回 false,上层停止调用 apply_to | 一旦出错,不再处理后续事件 |
| thinking_signature 未填 | Thinking block 的 signature 为 None | 安全降级 |
+459
View File
@@ -0,0 +1,459 @@
# Provider 实现策略
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §5 Provider 实现策略。
>
> **相关文件:**
> - [9b-ir-type-system.md](9b-ir-type-system.md) — IR 类型定义(ContentBlock、Message、MessageRequest/Response 等)
> - [9c-llm-provider-trait.md](9c-llm-provider-trait.md) — LlmProvider trait 定义(chat、chat_stream 签名)
> - [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md) — LlmCycle 改造(system prompt 冲突等与 Provider 相关)
> - [9f-edge-cases.md](9f-edge-cases.md) — Thinking signature 端到端传递(与 Anthropic 流式强相关)
## 5. Provider 实现策略
### 5.1 OpenaiProvider(兼容 Chat API
```
MessageRequest
├── model → model
├── messages → messages (逐条映射,见下方)
├── system → 插入为首条 System message(如无 System message 时)
├── tools → tools (OpenaiTool::Function)
├── tool_choice → tool_choice
├── max_tokens → max_tokens
├── temperature → temperature
├── top_p → top_p
├── stop_sequences → stop (StopSequence::Multiple)
├── thinking → 忽略(OpenAI 不支持)
└── extra.* → 对应字段 / extra_body
Message → OpenaiChatMessage:
System { content } → System { content: to_openai_content(content) }
User { content } → User { content: to_openai_content(content) }
Assistant { content } → Assistant {
content: to_openai_content(text_blocks),
tool_calls: extract_tool_calls(content)
}
Tool { content, tool_call_id } → Tool { content, tool_call_id }
ContentBlock → OpenaiContentPart:
Text { text } → Text { text }
Image { source } → Image { image_url: { url, detail } }
Audio { source } → InputAudio { input_audio }
File { source } → File { file }
ToolUse { id, name, input } → 转为 OpenaiToolCall 放入 tool_calls 字段
ToolResult { .. } → 通过 tool_call_id 关联到 Tool 消息
Thinking { .. } → 忽略
Extension { .. } → 忽略
OpenaiChatResponse → MessageResponse:
id → id
model → model
usage → usage
choices[0].finish_reason → stop_reason
choices[0].message → Message::Assistant {
content: [
ContentBlock::Text { text },
... (msg.tool_calls → ContentBlock::ToolUse)
]
}
```
> **✅ 推演结论(2026-06-18):**
>
> ### 架构分层
>
> OpenAI 的流式转换拆为**两层**,字节解析层通用、事件转换层 OpenAI 特定:
>
> ```
> bytes_stream()
> │
> ▼
> SseByteStream [sse.rs — 通用层]
> │ 逐行分割、按空行分帧、解析 event:/data: 行前缀、
> │ 过滤 "[DONE]"/ping、缓冲拼接碎片、多行 data: 自动拼接
> ▼
> Stream<Item=Result<SseEvent, LlmError>> ← 结构化 SSE 帧
> │ SseEvent { event_name: Option<String>, data: String }
> │ - OpenAI: event_name = None(未命名事件)
> │ - Anthropic: event_name = Some("message_start" | "content_block_delta" | ...)
> │
> ├─ OpenAI ────→ OpenaiStreamToEvents [openai/stream.rs]
> │ 忽略 event_name,反序列化 data → OpenaiChatChunk → 状态机
> │
> └─ Anthropic ──→ AnthropicStreamToEvents [anthropic/stream.rs]
> 按 event_name 分发事件类型 → 事件映射
> ```
>
> **Layer 1 — `SseByteStream<S>`(新增 `src/llm/provider/sse.rs`**
>
> 从当前 `SseChunkStream``openai.rs` 内联实现)提取字节解析逻辑为通用 SSE 解析器。
> 产出 `SseEvent` 结构体,携带 `event_name` + `data` 两部分信息。
> OpenAI 和 Anthropic 均直接复用此层,各自的事件转换器按需使用 `event_name`
>
> **Layer 2 — `OpenaiStreamToEvents`(新增 `src/llm/provider/openai/stream.rs`**
>
> 接收 `SseEvent` 流,忽略 `event_name`OpenAI 为 `None`),
> 将 `data` 反序列化为 `OpenaiChatChunk`(保留作为内部格式),
> 通过状态机转换为 `StreamEvent` 事件流。
>
> ### 状态机设计
>
> ```rust
> pub struct OpenaiStreamToEvents<S> {
> inner: S, // Stream<Item=Result<String, LlmError>>
> // ── 状态 ──
> global_index: u32, // 下一个可用 content block 序号
> current_block: Option<(u32, CurrentBlockType)>, // 当前活跃 block
> tool_call_indices: HashMap<u32, u32>, // OpenAI tool_call.index → 全局序号
> is_complete: bool,
> }
>
> enum CurrentBlockType { Text, Refusal, ToolUse }
> ```
>
> **每条 JSON 行的处理流程:**
>
> ```
> 收到一行 JSON 字符串
> ├─ 解析为 OpenaiChatChunk
> │
> ├─ Phase 1: 处理 delta 内容(先增量)
> │ ├─ delta.content → ensure_block(Text) → TextDelta
> │ ├─ delta.refusal → ensure_block(Refusal) → RefusalDelta
> │ └─ delta.tool_calls
> │ ├─ 新 tool callfunction.name 有值)
> │ │ → ensure_block(ToolUse, id, name) → (不产参数事件,等后续 arguments)
> │ └─ 已有 tool callfunction.arguments 有值)
> │ → ToolCallArgumentsDelta(index, arguments)
> │
> └─ Phase 2: 处理汇总(后收束)
> ├─ finish_reason 存在 → close_current_block() + MessageComplete
> ├─ usage 存在 → CostUpdate
> └─ 同时存在 → CostUpdate → close_block → MessageComplete
> ```
>
> **核心抽象 `ensure_block`** 当新 chunk 的 delta 类型与当前活跃 block 不同时,
> 自动关闭当前 blockemit `ContentBlockEnd`)并开启新 blockemit `ContentBlockStart`)。
> 同类型继续时只发增量事件,不切换。
>
> **状态转移表:**
>
> | 当前状态 | 收到 delta.content | 收到 delta.tool_calls (new) | 收到 delta.tool_calls (延续) | 收到 finish_reason |
> |----------|-------------------|----------------------------|----------------------------|-------------------|
> | 无活跃 block | → ContentBlockStart(Text)<br>→ TextDelta | → ContentBlockStart(ToolUse) | (不应发生) | → MessageComplete |
> | Text 活跃中 | → TextDelta | → ContentBlockEnd<br>→ ContentBlockStart(ToolUse) | (不应发生,与 content 互斥) | → ContentBlockEnd<br>→ CostUpdate<br>→ MessageComplete |
> | ToolUse 活跃中 | → ContentBlockEnd<br>→ ContentBlockStart(Text) | → ContentBlockEnd<br>→ ContentBlockStart(new ToolUse) | → ToolCallArgumentsDelta | → ContentBlockEnd<br>→ CostUpdate<br>→ MessageComplete |
>
> ### 各议题结论
>
> | # | 议题 | 结论 | 理由 |
> |---|------|------|------|
> | 1 | **SSE 字节解析复用** | 提取为通用 `SseByteStream``sse.rs`),支持 `event:` + `data:` 双行解析;`OpenaiStreamToEvents` / `AnthropicStreamToEvents` 分别在其上封装 | Anthropic 复用相同字节协议,通过 `SseEvent.event_name` 区分事件类型;分层测试、职责清晰 |
> | 2 | **ContentBlockStart/End 合成** | "类型切换推断边界"策略——来什么类型就关旧开新,依赖 content/tool_calls 互斥保证 | 状态机 3 种当前类型覆盖全部场景,假设验证通过 |
> | 3 | **Tool call 序号映射** | `HashMap<openai_index, global_block_index>`,新 tool call 出现时分配全局序号 | OpenAI index 是 tool 数组级别全局的,但与 IR content block 体系不同,需映射 |
> | 4 | **多 Choice** | 忽略 choices[1..],不暴露 | 当前架构无多 choice 概念,80% 场景 n=1,非目标已明确 |
> | 5 | **Usage 时机** | 先发 `CostUpdate` → 再发 `MessageComplete`,同一 chunk 内串行 | 汇聚算法兼容两者顺序,但语义上先用量后完成更合理 |
> | 6 | **ToolCallEnd 发出** | OpenAI 层不显式发出 `ToolCallEnd`,依赖汇聚算法 `finalize()` 兜底 | `ToolCallEnd` 保留给 Anthropic`content_block_stop` 场景);`ContentBlockEnd` 已标 block 完成,`finalize``tool_call_args` 已累积完整 |
> | 7 | **Refusal 处理** | 检测 `delta.refusal`emit `ContentBlockStart(Refusal)` + `RefusalDelta` + `ContentBlockEnd` | OpenAI 特有字段,IR 已有 `ContentBlockType::Refusal` |
> | 8 | **代码消重** | 删除 `stream.rs::parse_chunk_stream`(无人调用);`cycle.rs::submit_stream` 直接消费 `Result<StreamEvent>` 流 | 转换逻辑统一到 Provider 层,LlmCycle 只负责编排和 hook |
>
> ### 边界情况
>
> | 场景 | 处理方式 |
> |------|---------|
> | **`[DONE]` 行** | `SseByteStream` 层过滤,不传递到事件层(当前已有逻辑) |
> | **delta 为空 + finish_reason** | 只处理 Phase 2,关闭当前 block 后 emit MessageComplete |
> | **同一 chunk 含 delta.content + finish_reason** | Phase 1 先处理 delta 发 TextDeltaPhase 2 关闭 block 发 Complete |
> | **同一 chunk 含 delta.tool_calls + finish_reason** | 先处理所有 tool calls(映射 + arguments 累积),再关 block |
> | **usage 单独 chunk 下发** | 触发 Phase 2 但 finish_reason 为 None → 只发 CostUpdate,不发 MessageComplete |
> | **网络断开** | reqwest 返回 Err → emit StreamEvent::Erroris_complete = true |
> | **JSON 解析失败** | emit StreamEvent::Error,终止流(防御性处理) |
>
> ### 文件变更清单
>
> | 操作 | 文件 | 说明 |
> |------|------|------|
> | 新增 | `src/llm/provider/sse.rs` | 通用 SSE 字节解析层,含 `SseEvent` 结构体;从当前 `openai.rs``SseChunkStream` 提取核心逻辑并增强为支持 `event:` + `data:` 双行解析 + 空行分帧 |
> | 新增 | `src/llm/provider/openai/stream.rs` | `OpenaiStreamToEvents` 转换器 + 状态机 |
> | 修改 | `src/llm/provider/openai.rs` | `chat_stream` 返回 `Result<StreamEvent>`,组合 `SseByteStream` + `OpenaiStreamToEvents` |
> | 修改 | `src/llm/provider.rs` | `LlmProvider::chat_stream` 签名改为 `Result<StreamEvent>` |
> | 修改 | `src/llm/cycle.rs` | `submit_stream` 直接消费 `StreamEvent` 流,移除内联 chunk→event 转换 |
> | 修改 | `src/llm/stream.rs` | 删除 `parse_chunk_stream`(无人调用) |
>
> 优先级:高(Phase 2 OpenAI Provider 重构的核心任务)
### 5.2 AnthropicProviderMessages API
```
MessageRequest → Anthropic Messages Request:
model → "anthropic-xxx"
messages → [只包含 User / Assistant / Tool role]
system → system (顶层参数)
tools → tools (Anthropic 原生格式)
tool_choice → tool_choice
max_tokens → max_tokens
temperature → temperature
top_p → top_p
stop_sequences → stop_sequences
thinking → thinking (原生支持)
extra.* → 忽略或不支持
Message → Anthropic Message:
System { } → 跳过(已在 system 参数中)
User { content } → { role: "user", content: to_anthropic_content(content) }
Assistant { content } → { role: "assistant", content: to_anthropic_content(content) }
Tool { content, tool_call_id } → { role: "user", content: [tool_result block] }
ContentBlock → Anthropic Content Block:
Text { text } → { type: "text", text }
Image { source } → { type: "image", source: { type: "base64", ... } }
ToolUse { id, name, input } → { type: "tool_use", id, name, input }
ToolResult { tool_use_id, content, is_error } → { type: "tool_result", ... }
Thinking { text, signature } → { type: "thinking", thinking: text, signature }
Audio / File / Extension → 忽略或不支持
Anthropic Response → MessageResponse:
id → id
model → model
content → Message::Assistant { content: map_blocks(content) }
usage.input_tokens → usage.prompt_tokens
usage.output_tokens → usage.completion_tokens
stop_reason → stop_reason
```
> **✅ 推演结论(2026-06-18):**
>
> ### 设计思路:轻量分发器
>
> Anthropic 的流式事件**自带语义块边界**(`content_block_start/stop` 显式声明),
> 不像 OpenAI 需从扁平 delta 推断。因此 Anthropic 状态机采用**轻量分发器**模式——
> 每个事件自描述,状态机仅做顺序合法性校验,不做 block 边界推断或 index 映射。
>
> **与 OpenAI 流式转换的核心差异:**
>
> | 维度 | OpenaiStreamToEvents | AnthropicStreamToEvents |
> |------|---------------------|------------------------|
> | 核心复杂度 | 中——需从扁平 delta 推断 block 边界 | 低——事件自带语义边界 |
> | 状态数 | 3(无活跃、Text、ToolUse | 3PendingStart、Active、Terminated |
> | index 管理 | `HashMap<openai_idx, global_idx>` 映射 | 直接使用 Anthropic index1:1 |
> | Block 边界推断 | 类型切换推断 | 原生 content_block_start/stop |
> | Thinking 处理 | 无 | 通过 message_delta.thinking.signature |
>
> ### 架构分层
>
> 沿用 OpenAI 的两层架构,`SseByteStream` 共享(已增强为支持命名事件),
> `AnthropicStreamToEvents` 在事件层按 `event_name` 分发:
>
> ```
> bytes_stream()
> │
> ▼
> SseByteStream [sse.rs — 通用层(已增强)]
> │ 逐行分割、按空行分帧、解析 event:/data: 行前缀、过滤 "[DONE]"/ping
> ▼
> Stream<Item=Result<SseEvent, LlmError>> ← SseEvent { event_name: Option<String>, data }
> │
> ▼
> AnthropicStreamToEvents [anthropic/stream.rs — Anthropic 特定]
> │ 按 SseEvent.event_name 分发事件类型 → 直接映射
> ▼
> Stream<Item=Result<StreamEvent, LlmError>> ← IR 语义事件
> ```
>
> ### 结构体设计
>
> ```rust
> pub struct AnthropicStreamToEvents<S> {
> inner: S, // Stream<Item=Result<SseEvent, LlmError>>
> state: AnthropicStreamState, // 仅做顺序校验
> usage: PartialUsage, // 从 message_start + message_delta 累积
> pending_thinking_signature: Option<String>, // message_delta 中到达
> }
>
> /// 状态机状态 —— 仅做合法性校验,事件本身已自描述。
> enum AnthropicStreamState {
> PendingStart, // 等待 message_start
> Active, // 已收到 message_start,正在接收 content block 事件
> Terminated, // 已终结,不再处理后续事件
> }
> ```
>
> 状态足够简单的原因:Anthropic 每个事件自带完整语义——
> - `content_block_delta` 自带 `index`,不需要追踪"当前活跃 block"
> - `content_block_stop` 自带 `index`,不需要追踪"当前关闭哪个"
> - 状态只拒绝非法到达顺序的事件
>
> ### 事件映射表(完整)
>
> | Anthropic SSE 事件 | 产出的 StreamEvent | 说明 |
> |-------------------|-------------------|------|
> | `message_start` | `MessageStart { id, model }`<br>`CostUpdate { prompt_tokens }` | 从 `message.usage.input_tokens` 提取 |
> | `ping` | —(忽略) | Anthropic 心跳 |
> | `content_block_start`<br>`block.type="text"` | `ContentBlockStart { index, Text }` | |
> | `content_block_start`<br>`block.type="tool_use"` | `ContentBlockStart { index, ToolUse { id, name } }` | block 自带 id + name |
> | `content_block_start`<br>`block.type="thinking"` | `ContentBlockStart { index, Thinking }` | |
> | `content_block_delta`<br>`delta.type="text_delta"` | `TextDelta { text: delta.text }` | |
> | `content_block_delta`<br>`delta.type="thinking_delta"` | `ThinkingDelta { text: delta.thinking }` | |
> | `content_block_delta`<br>`delta.type="input_json_delta"` | `ToolCallArgumentsDelta { index, arguments: delta.partial_json }` | index 透传 |
> | `content_block_stop` | `ContentBlockEnd { index }` | |
> | `message_delta` | `CostUpdate { completion_tokens }`<br>`MessageComplete { stop_reason, thinking_signature }` | signature 从 `delta.thinking?.signature` 提取 |
> | `message_stop` | —(流结束标记,不产事件) | 仅切状态到 Terminated |
> | `error` | `Error { message: error.message }` | 切状态到 Terminated |
>
> ### 状态转移表
>
> **当前状态:`PendingStart`**
>
> | 输入事件 | 输出 StreamEvent | 新状态 | 备注 |
> |---------|----------------|--------|------|
> | `message_start` | → `MessageStart` + `CostUpdate` | `Active` | ✅ 正常流程入口 |
> | 其他任何事件 | — ⚠ warn | 不变 | 防御性跳过 |
> | `error` | → `Error` | `Terminated` | ❌ 错误路径 |
>
> **当前状态:`Active`**
>
> | 输入事件 | 输出 StreamEvent | 新状态 | 备注 |
> |---------|----------------|--------|------|
> | `ping` | —(忽略) | `Active` | ✅ 心跳 |
> | `content_block_start` | → `ContentBlockStart` | `Active` | ✅ 新 block 开始 |
> | `content_block_delta` | → `TextDelta` / `ThinkingDelta` / `ToolCallArgumentsDelta` | `Active` | ✅ 块内增量 |
> | `content_block_stop` | → `ContentBlockEnd` | `Active` | ✅ block 结束 |
> | `message_delta` | → `CostUpdate` + `MessageComplete` | `Active` | ✅ 消息完成信息 |
> | `message_stop` | — | `Terminated` | ✅ 正常结束 |
> | `error` | → `Error` | `Terminated` | ❌ 错误路径 |
> | 未知 delta type | — ⚠ warn | `Active` | 防御性忽略 |
>
> **当前状态:`Terminated`**
>
> | 输入事件 | 输出 StreamEvent | 新状态 | 备注 |
> |---------|----------------|--------|------|
> | 任何事件 | — ⚠ warn "已完结" | `Terminated` | 防御性忽略 |
>
> ### 各议题结论
>
> | # | 议题 | 结论 | 理由 |
> |---|------|------|------|
> | 1 | **状态机模式** | 轻量分发器(3 状态),不做 block 推断 | Anthropic 事件自带语义边界,不需要像 OpenAI 那样推断 |
> | 2 | **index 映射** | 无需映射,直接使用 Anthropic index1:1 | Anthropic 的 `index` 是全局 content block 序号,与 IR 完全对齐 |
> | 3 | **Thinking signature** | ✅ 已推演(方案 C):message_delta 提取 → MessageComplete 传递 → finalize 回填 | 已在 [9f-edge-cases.md](9f-edge-cases.md#92-thinking-的端到端流程) 中完成推演 |
> | 4 | **SSE 字节解析复用** | 通过增强的 `SseByteStream`(支持 `event:` 行解析)与 OpenAI 共享通用层 | 同一字节协议,仅在事件解析层差异化 |
> | 5 | **Usage 分次到达** | `message_start` 提取 `input_tokens``message_delta` 提取 `output_tokens``PartialUsage` 字段级合并 | 与 StreamEvent 汇聚算法兼容 |
> | 6 | **错误恢复** | `error` 事件 → `StreamEvent::Error` + state=Terminated,后续事件全部忽略 | 不同于 OpenAI 的 HTTP 错误路径,但 IR 层统一为 `StreamEvent::Error` |
> | 7 | **ContentBlockType::ToolUse 嵌入** | `content_block_start` 中的 `id` + `name` 直接填入 `ContentBlockType::ToolUse { id, name }` | 与已推演的 ContentBlockStart 设计一致 |
> | 8 | **block 切换** | content_block_stop(index) → content_block_start(index') 自然过渡,状态机不追踪 | 事件本身已确定边界,无需状态机参与 |
>
> ### 与已推演设计的对齐
>
> **与 Thinking signature 方案的对齐(方案 C):**
> ```
> content_block_start { type: "thinking" }
> → ContentBlockStart(Thinking) ← signature 未到达
> content_block_delta { thinking_delta }
> → ThinkingDelta(...)
> content_block_stop
> → ContentBlockEnd ← signature 仍未到达
> message_delta { delta.thinking.signature = "0x..." }
> → MessageComplete { thinking_signature: Some("0x...") }
> → finalize() 回填到最后一个 Thinking block
> ```
>
> **与 StreamEvent 汇聚算法的对齐:**
> 本状态机产出的 StreamEvent 可直接喂入已推演的 `PartialMessageResponse::apply_to()` 算法。
> 上述事件序列在汇聚算法中:
> 1. `MessageStart` → state.id/model
> 2. `ContentBlockStart/Delta/End``BTreeMap` 按 index 分桶组装
> 3. `CostUpdate` → PartialUsage 字段级合并
> 4. `MessageComplete` → stop_reason + thinking_signature + is_complete
> 5. `finalize()` → 回填 signature → `MessageResponse`
>
> ### 边界情况
>
> | 场景 | 处理方式 |
> |------|---------|
> | **message_start 前收 content_block_start** | ⚠ warn 忽略,不发射事件 |
> | **message_delta 前收 message_stop** | ⚠ warn,强制 Terminated |
> | **content_block_stop 无对应 start** | ⚠ warn 忽略(index 无对应) |
> | **index 跳跃(0 → 2** | 正常处理,index 透传,content 数组留空位 |
> | **delta index 与最新 start 不匹配** | ⚠ warn,仍然按 delta 自带 index 处理 |
> | **message_delta 缺 thinking.signature** | `thinking_signature`: None |
> | **message_delta 缺 usage** | 不发射 CostUpdate,仅发射 MessageComplete |
> | **两次 message_delta** | ⚠ warn,第二次忽略 |
> | **content_block_stop 后同 index 又来 delta** | ⚠ warn 忽略 |
> | **ping 事件** | 忽略,不发射任何事件 |
> | **网络断开** | emit `Error`state = Terminated |
> | **JSON 解析失败** | emit `Error`state = Terminated |
> | **stop_reason 映射** | `"end_turn"``Stop`, `"max_tokens"``MaxTokens`, `"tool_use"``ToolUse`, `"stop_sequence"``StopSequence`, 其他→`Other` |
>
> ### 文件变更清单
>
> | 操作 | 文件 | 说明 |
> |------|------|------|
> | 新增 | `src/llm/provider/anthropic.rs` | AnthropicProvider 实现(chat + chat_stream |
> | 新增 | `src/llm/provider/anthropic/` | 目录,按 2018 版风格组织 |
> | 新增 | `src/llm/provider/anthropic/stream.rs` | `AnthropicStreamToEvents` 转换器 + 事件分发器 |
> | 增强 | `src/llm/provider/sse.rs` | `SseByteStream` 增强为支持 `event:` 行 + 空行分帧(已在 §5.1 中描述) |
> | 修改 | `src/llm/provider.rs` | `ProviderType` 增加 `Anthropic``create_provider` 增加分支 |
> | 无变更 | `src/llm/cycle.rs` | StreamEvent 事件序列格式不变,无需改动 |
> | 无变更 | `src/llm/stream.rs` | 汇聚算法 `apply_to/finalize` 不变 |
>
> 优先级:高(Phase 4 AnthropicProvider 实现的前提条件)
### 5.3 OpenAI Response API(草案)
```rust
// 核心思路:Response API 的 "input as messages" 模式映射到 IR
// MessageRequest → Response API Request:
// model → model
// messages → input (作为 multi-turn conversation)
// tools → tools (tool 定义)
// extra.previous_response_id → previous_response_id
// extra.built_in_tools → tools (内置工具配置)
// extra.instructions → instructions
// Response → MessageResponse:
// output[0] (type="message") → message
// output[1..n] → ContentBlock::Extension
// 内置工具(web_search 等)需要额外的能力 trait:
#[async_trait]
pub trait BuiltInToolsCapable: LlmProvider {
fn available_builtin_tools(&self) -> Vec<(&'static str, serde_json::Value)>;
async fn execute_builtin_tool(&self, name: &str, input: Value) -> Result<Value, LlmError>;
}
```
> **🔄 待深入推演:OpenAI Response API 完整映射表**
> 当前 §5.3 只有注释级别的草案,缺乏完整映射。
> **需要推演:**
> 1. `input` 字段支持三种模式:字符串、`Vec<Message>`IR 消息列表)、`response_id`(前序响应)。
> IR 的 `MessageRequest.messages` 能否同时覆盖这三种?`previous_response_id` 通过 extra 传递后,
> `messages` 是否还需要存在?
> 2. `output` 中的每种类型(`message`, `web_search_call`, `file_search_call`,
> `code_interpreter_call`, `computer_call`, `reasoning`)如何映射到 `ContentBlock`
> 当前 `Extension` 逃生舱能否承载?是否需要新增 ContentBlock variant
> 3. Response API 的 `tools` 参数除了定义 function 工具外,还支持配置内置工具的参数
> (如 `web_search``search_context_size`)。`ToolDefinition` 能否表达?
> 4. Streaming 差异:Response API 的流式事件类型(`response.output_items.added` 等)与 Chat API
> 完全不同,如何映射到 StreamEvent
> 优先级:低(Phase 4 之后的远期计划)
### 5.4 DeepSeek / Qwen 等兼容 Provider 的落地策略
当前 `ProviderType` 枚举中已列出 DeepSeek 和 Qwen,但实现均标记为 `unimplemented!()`
> **🔄 待深入推演:DeepSeek/Qwen Provider 的落地策略**
> 这些 Provider 通常兼容 OpenAI Chat API 格式。在新 IR 设计下,有两种落地路径:
> **路径 A — 复用 OpenaiProvider(推荐):**
> 在 `ProviderRegistry` 中注册时直接使用 `OpenaiProvider::new(base_url, api_key, model)`
> 仅需换 base_url。适合 DeepSeek、Qwen、Groq、Azure 等 API 格式与 OpenAI 完全一致的场景。
> 此时 `ProviderType` 枚举可能不再需要(`OpenaiProvider` 通过 `capabilities().provider_name`
> 标识自身为 "openai-compatible" 或具体名称)。
> **路径 B — 独立 Provider 实现:**
> 如果某 Provider 在 OpenAI 格式基础上做了扩展/修改(如自定义参数、不同的错误格式),
> 可独立实现 `LlmProvider` trait,复用 IR 类型,仅在 IR ↔ 原生格式映射层做差异处理。
> **需要推演:**
> 1. `ProviderType` 枚举在新的注册体系中是否还有存在的必要(工厂函数模式 vs 直接 new Provider
> 2. `OpenaiProvider` 是否要重命名为更通用的 `OpenaiCompatibleProvider`
> 优先级:低(Phase 4 后梳理)
+435
View File
@@ -0,0 +1,435 @@
# LlmCycle 改造与上层适配
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §6 LlmCycle 改造 + §7 对上层的影响 + §8 兼容性策略。
>
> **相关文件:**
> - [9b-ir-type-system.md](9b-ir-type-system.md) — IR 类型定义(Message、MessageRequest、ContentBlock 等)
> - [9c-llm-provider-trait.md](9c-llm-provider-trait.md) — LlmProvider traitchat、chat_stream 签名)
> - [9d-provider-implementations.md](9d-provider-implementations.md) — Provider 实现策略(system prompt 处理方式)
> - [9f-edge-cases.md](9f-edge-cases.md) — 边界情况(tool 定义传递路径等)
## 6. LlmCycle 改造
### 6.1 内部存储变化
```rust
pub struct LlmCycle {
provider: Arc<dyn LlmProvider>,
config: CycleConfig,
usage: CostTracker,
messages: Vec<Message>, // ← 原 Vec<OpenaiChatMessage>
system_prompt: Option<String>,
hook_executor: Option<Arc<HookExecutor>>,
compact_config: Option<CompactConfig>,
compact_state: CompactState,
}
```
### 6.2 build_request → 新签名
```rust
fn build_request(&self, tools: &[ToolDefinition]) -> MessageRequest {
let mut messages = self.messages.clone();
if let Some(sys_prompt) = &self.system_prompt {
// `system_prompt` 是权威来源:显式设置时替换 messages 中的任何 System
messages.retain(|m| !matches!(m, Message::System { .. }));
messages.insert(0, Message::system(sys_prompt));
}
// 如果 `system_prompt` 为 Nonemessages 中的 System 保持原样,
// 由各 Provider 在 IR→原生映射层各自处理
MessageRequest {
model: self.config.model.clone(),
messages,
tools: tools.to_vec(),
tool_choice: ToolChoice::Auto,
max_tokens: self.config.max_tokens,
temperature: self.config.temperature,
..Default::default()
}
}
```
> **✅ 推演结论(2026-06-22):方案 D —— 移除 `system` 字段,IR 中只留一个入口**
>
> **决策:**`MessageRequest` 中移除 `system: Option<String>` 字段,统一通过 `messages: Vec<Message>`
> 中的 `Message::System { content }` 表达系统提示。各 Provider 在 IR→原生 映射层自行处理差异。
>
> **理由:**
> 1. **与整套 IR 设计的理念一致**——IR 只描述"有什么",不关心"怎么传"。ToolResult 嵌套约束、
> Thinking 端到端、StreamEvent 汇总等问题的解决方向都是"Provider 层负责格式差异"
> system prompt 的双重入口是同一个问题,应用同样的原则。
> 2. **消除歧义的最佳方式是砍掉一个入口**——两个入口导致的"谁优先"问题在类型层面就解决了,
> 不需要运行时规则。
> 3. **每个 Provider 做自己的转换本来就是 Provider 层的职责**——OpenaiProvider 几乎零成本
> System 直接序列化为 role=`system`),AnthropicProvider 做提取+移除(约 10 行代码),
> DeepSeek/Qwen 同 OpenAI。没有 Provider 需要额外做反向工作。
>
> **具体做法:**
>
> **`MessageRequest`[9b-ir-type-system.md](9b-ir-type-system.md#36-messagerequest--统一请求)**
> 删除 `system: Option<String>` 字段。`messages: Vec<Message>` 是系统提示的唯一载体。
>
> **`LlmCycle::build_request`(本节上方代码)**
> 增加"替换"语义:`self.system_prompt` 设置时,先 `retain` 移除 messages 中已有的所有
> `Message::System`,再插入新的。确保 `system_prompt` 作为权威来源。
>
> **`OpenaiProvider::ir_to_native`**
> 零改动。`Message::System { content }` 直接映射为 `role: "system"`(或 `role: "developer"`)。
>
> **`AnthropicProvider::ir_to_native`**[9d-provider-implementations.md](9d-provider-implementations.md)
> 新增提取逻辑:
> ```rust
> // 1. 遍历 messages,收集所有 System 的纯文本内容
> // 2. 若有多个 System,合并为一个字符串(Anthropic 只接受一个)
> // 3. 设置 Anthropic 请求的顶层 `system` 参数
> // 4. 从 messages 中移除所有 System 消息
> // 5. 非文本 ContentBlock 静默丢弃 + warn! log
> ```
>
> **边界情况处理:**
> | `self.system_prompt` | messages 中已有的 System | 结果 |
> |---|---|---|
> | `None` | 无 System | messages 不变 |
> | `None` | `System("B")` | 保留,Provider 层处理 |
> | `Some("A")` | 无 System | 插入 System("A") |
> | `Some("A")` | `System("B")` | **移除 B,插入 A**(显式设置优先) |
> | `Some("A")` | 多个 System("B1"), ("B2") | **移除所有,插入 A** |
>
> **何时实现:** Phase 2-3 实现 AnthropicProvider 时同步完成。
> **影响范围:** `MessageRequest` 删除一个字段 + `build_request` 增 2 行 `retain` + AnthropicProvider 增约 10 行提取逻辑。
主要变化:
- 返回类型 `MessageRequest`(非 `ChatRequest`
- `tools` 直接传入,不再需要 `OpenaiTool::Function` 包装
- `..Default::default()` 填充剩余字段
### 6.3 submit_with_tools —— 新的 tool 循环逻辑
```rust
pub async fn submit_with_tools(
&mut self,
prompt: String,
registry: &ToolRegistry,
) -> Result<MessageResponse, LlmError> {
let tools = registry.definitions();
let max_turns = self.config.max_tool_turns.unwrap_or(10);
self.messages.push(Message::user(prompt));
self.maybe_compact();
let mut turn = 0;
loop {
turn += 1;
if turn > max_turns { /* error */ }
let response = self.submit_request(&tools).await?;
// 从 content blocks 中检测 ToolUse(不再需要额外函数)
let tool_uses: Vec<&ContentBlock> = response.message.content.iter()
.filter_map(|b| if let ContentBlock::ToolUse { .. } = b { Some(b) } else { None })
.collect();
let should_execute = matches!(response.stop_reason, StopReason::ToolUse) && !tool_uses.is_empty();
self.messages.push(response.message.clone());
if !should_execute { return Ok(response); }
let calls: Vec<(String, Value)> = tool_uses.iter()
.map(|b| match b {
ContentBlock::ToolUse { name, input, .. } => (name.clone(), input.clone()),
_ => unreachable!(),
})
.collect();
let results = registry.invoke_all(calls, self.config.tool_timeout_secs).await;
for result in results {
let content = /* 序列化/截断逻辑 ... */;
self.messages.push(Message::tool_result(result.tool_name, content));
}
self.maybe_compact();
}
}
```
关键简化:
- 不再需要 `has_tool_calls_in_message()``extract_tool_calls_from_message()` 辅助函数
- 仅仅遍历 `content` 即可发现所有 `ToolUse` block
- 逻辑对 Provider 类型**完全透明**
### 6.4 submit_stream —— Provider 直接返回 StreamEvent
```rust
pub async fn submit_stream(
&mut self,
prompt: String,
tools: Vec<ToolDefinition>,
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError> {
self.messages.push(Message::user(prompt));
let request = self.build_request(&tools);
// Provider 直接返回 StreamEvent 流,无需二次转换
let stream = self.provider.chat_stream(request).await?;
// 如果需要,可在 LlmCycle 层叠加额外处理
// (当前 pipelinestream → hook 触发 → 直接返回)
Ok(stream)
}
```
不再需要 `parse_chunk_stream()``stream.rs` 中的 `ChunkToEventStream` 可以移除)。
### 6.5 HookContext 引用调整
```rust
pub struct HookContext<'a> {
pub request: Option<&'a MessageRequest>, // ← 原 &'a ChatRequest
pub error: Option<&'a LlmError>,
pub attempt: u32,
pub turn_index: Option<u32>,
pub plan_step_index: Option<usize>,
}
```
### 6.6 compact 逻辑调整
`compact.rs` 中的 `estimate_message_tokens()``microcompact()` 需要从 `Vec<OpenaiChatMessage>` 改为 `Vec<Message>`,核心逻辑不变。
> **✅ 推演结论(2026-06-22):`microcompact` 压缩对象 + 估算策略 + Thinking 压缩策略**
>
> 推演覆盖三个议题(压缩对象、token 估算、Thinking 压缩),并引入**大小 × 重要性二维决策框架**作为统一的压缩决策模型。
>
> ---
>
> ### 议题 1`microcompact` 压缩哪个?
>
> **决策:方案 B —— 同时压缩 `Message::Tool``Message::Assistant` 中的 `ContentBlock::ToolResult`。**
>
> **理由:**
> 1. 两者存储的是相同语义的数据(工具执行结果),组织结构不同但语义等价。只压缩一种会漏掉另一种。
> 2. LlmCycle 工具循环主路径产生 `Message::Tool`,但 Anthropic 格式反序列化后可能出现 `ToolResult` 嵌入在 Assistant 中。未来 `AnthropicProvider` 实现后后者场景会增加。
> 3. `ContentBlock::ToolUse` **不压缩**——id/name/input 是工具调用的必需元数据,体积小(通常 < 1K),压缩反而破坏后续工具重放。
>
> 无需优先级排序(两者不在同一条消息中,不存在先后问题):
> - `Message::Tool`:独立的 Tool 消息,压缩其整个 `content` 字段
> - `Message::Assistant` 中的 `ToolResult`:遍历 content 找到所有 `ToolResult` block,压缩其内部的 `content`
>
> **实现示意:**
>
> ```rust
> pub fn microcompact(messages: &mut [Message], keep_recent: usize, config: &CompactConfig) -> u32 {
> if messages.len() <= keep_recent { return 0; }
> let prune_start = messages.len() - keep_recent;
> let mut freed_tokens: u32 = 0;
>
> // Phase 1:估算压缩前 token 数
> for msg in &messages[..prune_start] {
> freed_tokens += estimate_compressible_tokens(msg, config);
> }
>
> // Phase 2:执行压缩
> for msg in &mut messages[..prune_start] {
> apply_compact_action(msg, config);
> }
>
> freed_tokens
> }
> ```
>
> ---
>
> ### 议题 2`estimate_message_tokens` 的估算策略
>
> **决策:方案 B —— 按 ContentBlock 类型分档估算,非 Text block 使用差异化经验值。**
>
> **理由:**
> - compact 是启发式压缩,不需要精确 token 计数(最终由 Provider 的 tokenizer 精确计算),方案 C 的精度收益不值得这个复杂度
> - 方案 A(统一固定 50)精度太低——Thinking block 可长达几万 token,固定 50 会导致严重低估
>
> **分档表:**
>
> | `ContentBlock` 类型 | 估算方式 | 说明 |
> |---|---|------|
> | `Text { text }` | `(len * 4).div_ceil(3)` | 保持当前字符估算逻辑 |
> | `Image { .. }` | 固定 85 | OpenAI 低分辨率固定定价 |
> | `Audio { .. }` | 固定 100 | 音频通常大于图片 |
> | `File { source }` | 50 + 文件名文本估算 | 元数据开销 + 文件名 |
> | `ToolUse { name, input }` | `estimate_text(name) + estimate_text(input.to_string())` | name + JSON 参数 |
> | `ToolResult { content }` | 递归估算内部 content block 列表 | 递归到叶子节点 |
> | `Thinking { text }` | `estimate_text(text)` | 按文本估算(内容往往很长) |
> | `Extension { data }` | `estimate_text(data.to_string())` | 逃生舱,按 JSON 大小估算 |
>
> ---
>
> ### 议题 3Thinking 是否在压缩范围内
>
> **决策:方案 A —— 默认不压缩 Thinking`CompactConfig` 增加 `compact_thinking: bool` 选项。**
>
> **理由:**
> 1. Thinking 不同于 ToolResult——ToolResult 是"已执行的事实结果",压缩后语义无损;Thinking 是"推理过程",压缩后可能丢失决策上下文
> 2. 但 thinking 内容确实可能很长(尤其 Anthropic extended thinking 模式),不应完全放弃压缩能力
> 3. 提供选项让用户根据场景自行决定:任务型 Agent(可压) vs 推理密集型 Agent(不压)
>
> ```rust
> #[derive(Debug, Clone)]
> pub struct CompactConfig {
> pub context_window: u32,
> pub reserved_tokens: u32,
> pub keep_recent: usize,
> /// 是否压缩 Thinking 内容。默认 false。
> pub compact_thinking: bool,
> }
> ```
>
> 压缩时将 Thinking block 的 `text` 替换为 `"[pruned thinking]"` 以区别于 ToolResult 的 `"[pruned]"`
>
> ---
>
> ### ⭐ 新推演维度:大小 × 重要性二维决策框架
>
> 上述三个议题各自独立解决,但**压缩强度的选择**需要融合两个互补指标:
>
> | 维度 | 解决什么问题 | 来源 | 性质 |
> |------|------------|------|------|
> | **大小** | "这东西有多重?"——为释放空间值不值得动手 | 纯计算(字符数) | 精确 |
> | **重要性** | "动了之后损失什么?"——压缩对后续推理的影响 | 多来源综合 | 语义 |
>
> #### 二维决策矩阵
>
> ```
> 重 要 性
> 低 (Action) 高 (Factual)
> ┌────────────────────────────────
> 小 │ 跳过 跳过
> │ (< 200 char, 重要但小,不值得为它费劲
> 大 | 不值得)
> | │
> 小 │ 截断保留开头 结构化摘要
> │ ("[前200字]...") (保留 JSON 骨架 + 截断数据体)
> │
> 大 │ 替换 [pruned] 截断优先 → 元数据保留
> │ (零价值 + 大体积) (实在太大才 [pruned])
> ```
>
> #### 重要性的四个来源
>
> | 来源 | 时机 | 输入 | 输出 |
> |------|------|------|------|
> | **① 工具注册声明** | 编译/初始化时 | `ToolDefinition.result_semantic: ResultSemantic` | 基础分:Factual=+1, Action=-1, Mixed=0 |
> | **② 内容模式启发式** | compact 触发时 | 检测结果中的 JSON 特征 | 加分:列表数据+1,状态确认-1,错误结果→跳过 |
> | **③ 对话轮次衰退** | compact 触发时 | 距离最后一次被引用的轮次 | 每超 K 轮 → -0.5 |
> | **④ 后续引用跟踪** | 事后(为下次积累) | Assistant 内容与结果的关键词重叠 | 更新引用时间戳 |
>
> ```rust
> /// 工具注册时声明结果的语义类型
> #[derive(Debug, Clone, Copy)]
> pub enum ResultSemantic {
> /// 结果是"事实依据"——后续对话可能反复引用(搜索、文档查询)
> Factual,
> /// 结果是"一次性动作确认"——执行完就过了(发送邮件、创建记录)
> Action,
> /// 兼具两者特征 / 不确定(默认)
> Mixed,
> }
>
> /// 压缩动作 —— 由 [大小, 重要性] 综合决定
> pub enum CompactAction {
> /// 不压缩
> Skip,
> /// 截断保留前 N 字符
> Truncate { keep: usize },
> /// 尝试保留 JSON 结构 + 截断数据体
> TruncateStructured { keep: usize },
> /// 替换为 [pruned] 或 [pruned thinking]
> Replace,
> }
> ```
>
> #### 综合评分 → 动作映射
>
> ```rust
> fn decide_strategy(size: usize, score: i8) -> CompactAction {
> match (size, score) {
> (0..=200, _) => CompactAction::Skip, // 太小不压
> (201..=2000, s) if s >= 1 => CompactAction::Skip, // 重要 + 中等 → 保留
> (201..=2000, _) => CompactAction::Truncate { keep: 200 },
> (2001.., s) if s >= 2 => CompactAction::TruncateStructured { keep: 500 },
> (2001.., s) if s >= 1 => CompactAction::Truncate { keep: 200 },
> (2001.., _) => CompactAction::Replace, // 大 + 不重要 → 全换
> }
> }
> ```
>
> **何时实现:** Phase 3 适配 LlmCycle 时同步修改 compact.rs。
> **影响范围:** `compact.rs` 全部重写(保留函数签名,内部逻辑适配 IR)+ `CompactConfig` 变更 + 需要在 `ToolDefinition` 中新增 `result_semantic` 字段(Phase 3+ `llm-cycle.rs` 中 compact 调用点的接口适配。
---
## 7. 对上层的影响
### 7.1 ProviderRegistry —— 零改动
```rust
pub struct ProviderRegistry {
providers: HashMap<String, Box<dyn LlmProvider>>,
default_name: Option<String>,
}
// 所有方法逻辑不变
```
### 7.2 AgentSession —— 极小影响
```rust
// 当前
let response: ChatResponse = cycle.submit_with_tools(input, &registry).await?;
self.cost_so_far.add(&response.usage); // Usage 类型不变
// 新
let response: MessageResponse = cycle.submit_with_tools(input, &registry).await?;
self.cost_so_far.add(&response.usage); // 仍然可用——Usage 类型一致
```
`response.usage` 类型不变(仍是 `Usage`),`response.text()` 替代了 `response.message.content` 的文本提取。
### 7.3 Agent trait —— 零改动
`Agent::tool_definitions()` 返回 `Vec<ToolDefinition>`,类型不变。
### 7.4 PromptComposer —— 内部类型替换
`PromptComposer` 内部存储从 `Vec<OpenaiChatMessage>` 改为 `Vec<Message>`,公共方法签名不变(返回 `Message` 类型)。
---
## 8. 兼容性策略
### 8.1 From trait 双向转换
提供新旧类型之间的转换,平滑迁移:
```rust
// IR → 旧类型(兼容层)
impl From<ChatResponse> for MessageResponse { ... }
impl From<MessageResponse> for ChatResponse { ... }
impl From<OpenaiChatMessage> for Message { ... }
impl From<Message> for OpenaiChatMessage { ... }
impl From<ChatRequest> for MessageRequest { ... }
impl From<MessageRequest> for ChatRequest { ... }
```
### 8.2 LlmCycle 兼容 getter
```rust
impl LlmCycle {
// 新
pub fn messages(&self) -> &[Message] { &self.messages }
// 兼容旧
pub fn messages_openai(&self) -> Vec<OpenaiChatMessage> {
self.messages.iter().map(|m| m.clone().into()).collect()
}
}
```
### 8.3 测试代码的过渡
测试中大量使用 `MockProvider` 和旧类型,需要更新为 IR 类型。可在 Phase 1 中先保留旧类型别名以减少改动。
+121
View File
@@ -0,0 +1,121 @@
# 边界情况
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §9 边界情况。
>
> **相关文件:**
> - [9b-ir-type-system.md](9b-ir-type-system.md) — IR 类型定义(ContentBlock、ThinkingConfig、MessageRequest/Response 等)
> - [9c-llm-provider-trait.md](9c-llm-provider-trait.md) — StreamEvent 类型定义(ThinkingDelta 等)
> - [9d-provider-implementations.md](9d-provider-implementations.md) — Anthropic 流式状态机(与 Thinking signature 强相关)
> - [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md) — LlmCycle 的 tool 循环与 compact 逻辑
## 9. 边界情况
### 9.1 工具定义的传递路径变化
| 阶段 | 路径 |
|------|------|
| **当前** | `ToolRegistry.definitions()``Vec<ToolDefinition>``build_request()` 包装为 `Vec<OpenaiTool>``ChatRequest.tools` |
| **方案 C** | `ToolRegistry.definitions()``Vec<ToolDefinition>``build_request()` 直接放入 → `MessageRequest.tools` → **Provider 内部**包装为原生格式 |
工具定义的抽象层次从 LlmCycle 下移到 Provider 内部,更合理。
### 9.2 Thinking 的端到端流程
```
Agent 启用 thinking:
config.thinking = Some(ThinkingConfig { budget_tokens: 16000 })
LlmCycle.build_request():
→ MessageRequest { thinking: config.thinking, ... }
OpenaiProvider:
→ capabilities().features.thinking == false
→ 忽略 thinking 字段(或转为 extra.reasoning_tokens
AnthropicProvider:
→ capabilities().features.thinking == true
→ 将 thinking 写入 Anthropic 请求参数
→ 流式响应中返回 StreamEvent::ThinkingDelta
→ 最终消息的 content 中包含 ContentBlock::Thinking
```
> **✅ 推演结论(2026-06-17):方案 C —— MessageComplete 兜底 + finalize 统一回填**
>
> **决策:** Thinking signature 不在 event 级传递,而是通过 `MessageComplete.thinking_signature` 携带,
> 由 `PartialMessageResponse::finalize()` 统一回填到最后一个 Thinking block。
>
> **具体路径:**
> ```
> Anthropic message_delta
> → AnthropicProvider 提取 thinking.signature
> → 发出 MessageComplete { stop_reason, thinking_signature: Some("0x...") }
>
> PartialMessageResponse:
> ContentBlockEnd(thinking_idx) ← signature 还没到,builder 中 signature = None
> ...
> MessageComplete { thinking_signature: Some("0x...") }
> → state.thinking_signature = Some("0x...")
>
> finalize():
> 遍历 blocks → 找到 Thinking builder
> → builder.signature 为 None,用 state.thinking_signature 回填
> → ContentBlock::Thinking { text, signature: Some("0x...") }
> ```
>
> **理由:**
> 1. 不新增独立事件类型(保持 StreamEvent 简洁)
> 2. signature 在 message_delta 中下发(晚于 content_block_stop),MessageComplete 是该时刻的天然载体
> 3. Anthropic 同一响应中最多一个 thinking block,不存在"多 thinking block 归属"的歧义
> 4. 非流式响应中,thinking block 的 signature 直接通过 IR→原生映射填充,路径不变
>
> **影响范围:**
> - `StreamEvent::MessageComplete` 增加 `thinking_signature: Option<String>` 字段
> - `PartialMessageResponse` 增加 `thinking_signature: Option<String>` 暂存字段
> - `PartialMessageResponse::finalize()` 增加回填逻辑(约 3 行代码)
> - AnthropicProvider 流式状态机在 `message_delta` 处理中提取 thinking.signature
>
> **何时实现:** Phase 4 AnthropicProvider 流式状态机实现时一并完成
### 9.3 Multiple ContentBlock 的处理
消息的 `content``Vec<ContentBlock>`,可包含多种类型的混合:
```rust
Message::Assistant {
content: vec![
ContentBlock::Text { text: "让我思考一下..." },
ContentBlock::Thinking { text: "先用加法工具...", signature: None },
ContentBlock::Text { text: "答案是 3" },
ContentBlock::ToolUse { id: "call_1", name: "add", input: json!({"a":1,"b":2}) },
],
}
```
LlmCycle 处理 tool 循环时只关心 `ToolUse` block,其他 block 按原样传递给消息历史。
### 9.4 多 Choice 场景
OpenAI 的 `n > 1` 参数在 IR 中通过 `extra` 传递:
```rust
// 请求
request.set_extra("n", json!(3));
// 响应
response.extra["all_choices"] = json!([...choices 2..n]);
```
`MessageResponse` 只承载 `choices[0]`(主消息),其他 choices 放 `extra`
### 9.5 OpenAI Response API 的内置工具
通过 `extra` + 可选能力 trait 支持:
```rust
// 配置内置工具
request.set_extra("built_in_tools", json!(["web_search", "file_search"]));
// Response API 返回的搜索结果
// → ContentBlock::Extension { kind: "web_search_result", data: {...} }
```
如果未来内置工具成为跨 Provider 通用特性,将 `BuiltInToolsCapable` 升级为核心 trait。
+96
View File
@@ -0,0 +1,96 @@
# 风险评估、迁移路径与验收标准
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §10 风险评估 + §11 类型差异总结 + §12 迁移路径 + §13 验收标准。
>
> **相关文件:**
> - [9a-background-and-architecture.md](9a-background-and-architecture.md) — 背景与架构总览
> - [9b-ir-type-system.md](9b-ir-type-system.md) — IR 类型体系
> - [9c-llm-provider-trait.md](9c-llm-provider-trait.md) — LlmProvider Trait 设计
> - [9d-provider-implementations.md](9d-provider-implementations.md) — Provider 实现策略
> - [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md) — LlmCycle 改造与上层适配
> - [9f-edge-cases.md](9f-edge-cases.md) — 边界情况
## 10. 风险评估
| 风险 | 等级 | 缓解措施 |
|------|------|----------|
| ContentField::String 与 Vec<ContentBlock> 的统一导致文本消息需要包装 | 低 | Message::user("text") 便捷函数自动包装 |
| 无法完整覆盖 OpenAI 所有参数 | 中 | extra 逃生舱兜底 |
| IR ↔ OpenAI 的转换有性能开销 | 低 | 纯字段映射,相对 HTTP 延迟可忽略 |
| HookContext 引用类型变更影响 Hook 实现 | 中 | 影响范围小(主要为测试代码) |
| LlmCycle 返回类型变化破坏上层 | 中 | 提供兼容层 + From 转换 |
| compact.rs 需要适配新 Message 类型 | 低 | 核心逻辑不变,仅改类型匹配 |
| 流式事件格式变化破坏现有 StreamEvent 使用者 | 中 | 影响 LlmCycle::submit_stream 的调用者(主要是测试层) |
| ProviderType 枚举需要扩展 | 低 | 新增变体即可 |
---
## 11. 当前类型与 IR 的差异总结
| 当前类型 | 方案 C IR | 核心变化 |
|---------|----------|---------|
| `ChatRequest` (= `OpenaiChatRequest`) | `MessageRequest` | 独立类型,不绑定 OpenAI |
| `ChatResponse` | `MessageResponse` | 独立类型,content 用 ContentBlock |
| `OpenaiChatMessage` | `Message` | tool_calls 融入 content |
| `ContentField` | `Vec<ContentBlock>` | 统一为数组,不再有 String variant |
| `OpenaiContentPart` | `ContentBlock` | 新增 ToolUse/ToolResult/Thinking/Extension |
| `FinishReason` | `StopReason` | 语义化(如 tool_calls → ToolUse |
| `OpenaiChatChunk` | — | 移除(StreamEvent 取代)|
| `OpenaiChatResponse` | — | 不再暴露(Provider 内部使用)|
| `OpenaiToolCall` | `ContentBlock::ToolUse` | 融入 content block |
| `OpenaiToolDefinition` | `ToolDefinition` | 不变 |
| `Usage` | `Usage` | 不变 |
| `StreamEvent` | `StreamEvent` | 扩展(ThinkingDelta、MessageStart、ToolCallStart 等) |
---
## 12. 迁移路径
### Phase 1:定义 IR 类型 + From 转换
- 新增 `src/llm/types/ir.rs`,包含完整的 IR 类型定义
- 实现 IR ↔ 现有类型的 `From`/`Into` trait
- 新增 `pub type` 别名保持现有代码可编译
- ✅ 零已有代码改动
### Phase 2:重写 LlmProvider trait
- 修改 `src/llm/provider.rs`trait 签名改为 IR 类型
- 重构 `OpenaiProvider`:内部 IR → OpenAI → IR 转换
- 新增 `chat_stream``StreamEvent` 实现
- 新增 `capabilities()` 方法
- ❌ OpenaiProvider 需重构;LlmCycle 暂时不兼容
### Phase 3:适配 LlmCycle
- LlmCycle 内部消息历史改为 `Vec<Message>`
- `build_request` 改为生成 `MessageRequest`
- tool 循环逻辑改为遍历 `ContentBlock`
- `HookContext` 引用改为 `MessageRequest`
- `compact.rs` 适配新消息类型
- ❌ LlmCycle API 变更;AgentSession 需适配
### Phase 4:实现 AnthropicProvider + 清理
- 新增 `src/llm/provider/anthropic.rs`
- 实现 IR ↔ Anthropic JSON 映射 + 流式转换
- 移除旧的 `parse_chunk_stream``ChunkToEventStream`
- 清理不再需要的旧类型公开使用
- 补测试
---
## 13. 验收标准
| 编号 | 标准 | 验证方式 |
|------|------|----------|
| A1 | `OpenaiProvider` 通过 IR trait 正常工作 | 现有测试通过 + ChatCompletion 集成测试 |
| A2 | `AnthropicProvider` 通过 IR trait 正常工作 | Anthropic Messages API 集成测试 |
| A3 | Tool 循环在 IR 上正确工作(在 content 中检测 ToolUse | `submit_with_tools` 测试通过 |
| A4 | 流式事件包含 ThinkingDelta 等新类型 | Provider 流式测试 |
| A5 | ProviderCapabilities 正确描述 Provider 特性 | 单元测试 |
| A6 | `extra` 逃生舱可传递 Provider 特有参数 | 测试各 Provider 的 extra 参数 |
| A7 | 兼容层保持旧 API 可用 | 旧代码编译通过 |
| A8 | `compact.rs` 在 IR 上正常工作 | 压缩测试通过 |
| A9 | HookContext 使用 MessageRequest | Hook 测试通过 |
| A10 | AgentSession 编译通过 | 编译检查 |
+24 -14
View File
@@ -4,23 +4,32 @@ use agcore::init_tracing;
use agcore::llm::{
cycle::{CycleConfig, LlmCycle},
provider::{create_provider, ProviderConfig, ProviderType},
types::{ChatResponse, OpenaiContentPart},
types::{message::ContentBlock, message::Message, response_v2::MessageResponse},
};
fn extract_response_text(response: &ChatResponse) -> &str {
fn extract_response_text(response: &MessageResponse) -> &str {
// Phase 0MessageResponse.text() 直接给出拼接好的 Assistant 文本。
if !response.text().is_empty() {
// ⚠️ 返回的是 owned String 的引用,调用方需在 response 生命周期内使用。
// 此 example 短命,足以演示。
return response_text_ref(response);
}
"[无文本内容]"
}
fn response_text_ref(response: &MessageResponse) -> &str {
// ponytail: example 助手 —— 不在正式 crate API 中,单纯绕开 borrow 限制。
// 真实调用请直接使用 `response.text()` 拿到 owned String。
match &response.message {
agcore::llm::types::OpenaiChatMessage::Assistant { content, .. } => match content {
agcore::llm::types::ContentField::String(s) => s,
agcore::llm::types::ContentField::Array(parts) => {
for part in parts {
if let OpenaiContentPart::Text { text } = part {
return text;
}
agcore::llm::types::message::Message::Assistant { content } => {
for block in content {
if let ContentBlock::Text { text } = block {
return text.as_str();
}
"[无文本内容]"
}
},
_ => "[非 assistant 消息]",
""
}
_ => "",
}
}
@@ -54,8 +63,9 @@ async fn main() {
..CycleConfig::default()
};
let mut cycle = LlmCycle::new(provider, cycle_config)
.with_system_prompt("你是一个简洁的助手,对于任何问题都是用一句话回答。".to_string());
let mut cycle = LlmCycle::new(provider, cycle_config).with_messages(vec![
Message::system("你是一个简洁的助手,对于任何问题都是用一句话回答。"),
]);
println!("发送请求...");
+20 -3
View File
@@ -118,17 +118,34 @@ impl AgentBuilder {
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::provider::LlmProvider;
use crate::llm::types::{ChatRequest, ChatResponse};
use crate::llm::error::LlmError;
use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};
use crate::llm::types::request_v2::MessageRequest;
use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
use async_trait::async_trait;
use futures_core::Stream;
use std::pin::Pin;
struct StubProvider;
#[async_trait]
impl LlmProvider for StubProvider {
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
async fn chat(&self, _request: MessageRequest) -> Result<MessageResponse, LlmError> {
unimplemented!()
}
async fn chat_stream(
&self,
_request: MessageRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
{
unimplemented!()
}
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
provider_name: "stub",
supported_models: None,
features: ProviderFeatures::default(),
}
}
}
#[test]
+47 -28
View File
@@ -15,7 +15,9 @@ use crate::agent::runtime::RuntimeBundle;
use crate::agent::session_memory::SessionMemory;
use crate::llm::cycle::{CostTracker, CycleConfig, LlmCycle};
use crate::llm::hooks::{HookContext, HookEvent};
use crate::llm::types::ChatResponse;
use crate::llm::types::message::{ContentBlock, Message};
use crate::llm::types::openai_message::OpenaiChatMessage;
use crate::llm::types::response_v2::MessageResponse;
use crate::memory::store::InMemoryStore;
/// Agent 会话实例。
@@ -120,7 +122,7 @@ impl AgentSession {
pub async fn submit_turn(
&mut self,
user_input: impl Into<String>,
) -> Result<ChatResponse, AgentError> {
) -> Result<MessageResponse, AgentError> {
let turn_index = self.turn_index;
let hook_executor = Arc::clone(&self.bundle.hook_executor);
@@ -138,8 +140,14 @@ impl AgentSession {
let _ = self.agent.tool_definitions(&self.bundle);
let mut cycle = LlmCycle::new_with_arc(Arc::clone(&self.bundle.provider), CycleConfig::default())
.with_messages(Vec::new());
// Phase 2 切换 system_prompt 字段为 Message::SystemFIX-D)。
// 若 agent 自带 system prompt,预置到 messages 列表头部。
let mut initial_messages: Vec<Message> = Vec::new();
if let Some(prompt) = self.agent.system_prompt() {
cycle = cycle.with_system_prompt(prompt.to_string());
initial_messages.push(Message::system(prompt));
}
if !initial_messages.is_empty() {
cycle = cycle.with_messages(initial_messages);
}
if let Some(cfg) = self.bundle.config.compact_config.clone() {
cycle = cycle.with_compact_config(cfg);
@@ -168,13 +176,15 @@ impl AgentSession {
mod tests {
use super::*;
use crate::agent::builder::AgentBuilder;
use crate::llm::error::LlmError;
use crate::llm::hooks::{Hook, HookContext, HookExecutor, HookResult};
use crate::llm::provider::LlmProvider;
use crate::llm::types::{
ChatRequest, ChatResponse, FinishReason, OpenaiChatMessage,
};
use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};
use crate::llm::types::request_v2::MessageRequest;
use crate::llm::types::response_v2::{MessageResponse, StopReason, StreamEvent};
use crate::tools::ToolRegistry;
use async_trait::async_trait;
use futures_core::Stream;
use std::pin::Pin;
use std::sync::atomic::{AtomicU32, Ordering};
/// 计数 hook —— 每被调用一次 +1。
@@ -200,11 +210,11 @@ mod tests {
/// MockProvider:按调用顺序返回预设响应。
struct MockProvider {
responses: std::sync::Mutex<Vec<ChatResponse>>,
responses: std::sync::Mutex<Vec<MessageResponse>>,
}
impl MockProvider {
fn new(responses: Vec<ChatResponse>) -> Self {
fn new(responses: Vec<MessageResponse>) -> Self {
Self {
responses: std::sync::Mutex::new(responses),
}
@@ -213,15 +223,27 @@ mod tests {
#[async_trait]
impl LlmProvider for MockProvider {
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, crate::llm::error::LlmError> {
async fn chat(&self, _request: MessageRequest) -> Result<MessageResponse, LlmError> {
let mut responses = self.responses.lock().unwrap();
if responses.is_empty() {
return Err(crate::llm::error::LlmError::Other(
"no more mock responses".into(),
));
return Err(LlmError::Other("no more mock responses".into()));
}
Ok(responses.remove(0))
}
async fn chat_stream(
&self,
_request: MessageRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
{
unimplemented!()
}
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
provider_name: "mock",
supported_models: None,
features: ProviderFeatures::default(),
}
}
}
struct StubAgent {
@@ -238,11 +260,18 @@ mod tests {
}
}
fn assistant_text(text: &str) -> ChatResponse {
ChatResponse {
message: OpenaiChatMessage::assistant_text(text),
fn assistant_text(text: &str) -> MessageResponse {
MessageResponse {
id: String::new(),
model: String::new(),
message: Message::Assistant {
content: vec![ContentBlock::Text {
text: text.into(),
}],
},
usage: crate::llm::types::Usage::from_input_output(10, 5),
stop_reason: Some(FinishReason::Stop),
stop_reason: StopReason::Stop,
extra: std::collections::HashMap::new(),
}
}
@@ -267,17 +296,7 @@ mod tests {
assert_eq!(session.turn_index(), 0);
let response = session.submit_turn("hi").await.unwrap();
let text = match &response.message {
OpenaiChatMessage::Assistant { content, .. } => {
if let crate::llm::types::ContentField::String(s) = content {
s.clone()
} else {
String::new()
}
}
_ => String::new(),
};
assert_eq!(text, "hello back");
assert_eq!(response.text(), "hello back");
assert_eq!(session.turn_index(), 1);
assert_eq!(session.usage().total().prompt_tokens, 10);
assert_eq!(session.usage().total().completion_tokens, 5);
+1
View File
@@ -1,6 +1,7 @@
//! LLM 调用周期 —— 大模型基础调用周期控制。
pub mod compact;
pub mod convert;
pub mod cycle;
pub mod error;
pub mod hooks;
+133 -28
View File
@@ -1,6 +1,6 @@
//! 上下文自动压缩 —— 当对话历史过长时自动压缩。
use crate::llm::types::{ContentField, OpenaiChatMessage, OpenaiContentPart};
use crate::llm::types::message::{ContentBlock, Message};
const AUTOCOMPACT_BUFFER_TOKENS: u32 = 13_000;
const RESERVED_OUTPUT_TOKENS: u32 = 20_000;
@@ -72,37 +72,43 @@ impl CompactState {
}
/// 粗略估计消息列表的 token 数(基于字符数,4 字符 ≈ 1 token)。
pub fn estimate_message_tokens(messages: &[OpenaiChatMessage]) -> u32 {
pub fn estimate_message_tokens(messages: &[Message]) -> u32 {
messages
.iter()
.map(estimate_single_message_tokens)
.sum()
}
fn estimate_single_message_tokens(msg: &OpenaiChatMessage) -> u32 {
fn estimate_single_message_tokens(msg: &Message) -> u32 {
let role_overhead: u32 = 4;
let content_tokens = match msg {
OpenaiChatMessage::Developer { content, .. }
| OpenaiChatMessage::System { content, .. }
| OpenaiChatMessage::User { content, .. }
| OpenaiChatMessage::Assistant { content, .. }
| OpenaiChatMessage::Function { content, .. } => estimate_content_tokens(content),
OpenaiChatMessage::Tool { content, .. } => estimate_content_tokens(content),
Message::System { content }
| Message::User { content }
| Message::Assistant { content }
| Message::ToolResult { content, .. } => estimate_content_blocks_tokens(content),
Message::UserImage { .. } => 50,
};
role_overhead + content_tokens
}
fn estimate_content_tokens(content: &ContentField) -> u32 {
match content {
ContentField::String(s) => estimate_text_tokens(s),
ContentField::Array(parts) => parts.iter().map(estimate_part_tokens).sum(),
}
fn estimate_content_blocks_tokens(blocks: &[ContentBlock]) -> u32 {
blocks.iter().map(estimate_block_tokens).sum()
}
fn estimate_part_tokens(part: &OpenaiContentPart) -> u32 {
match part {
OpenaiContentPart::Text { text } => estimate_text_tokens(text),
_ => 50,
fn estimate_block_tokens(block: &ContentBlock) -> u32 {
match block {
ContentBlock::Text { text } => estimate_text_tokens(text),
ContentBlock::Thinking { text, .. } => estimate_text_tokens(text),
ContentBlock::ToolUse { input, .. } => {
estimate_text_tokens(&input.to_string())
}
ContentBlock::ToolResult { content, .. } => estimate_content_blocks_tokens(content),
// ponytail: Image / Audio / File / Extension 在 IR 中固定估算。
// 无文本的视觉/音频 block 用兜底估算,避免 token 计数膨胀。
ContentBlock::Image { .. }
| ContentBlock::Audio { .. }
| ContentBlock::File { .. }
| ContentBlock::Extension { .. } => 50,
}
}
@@ -115,11 +121,7 @@ fn estimate_text_tokens(text: &str) -> u32 {
}
/// 判断是否需要触发自动压缩。
pub fn should_compact(
messages: &[OpenaiChatMessage],
config: &CompactConfig,
state: &CompactState,
) -> bool {
pub fn should_compact(messages: &[Message], config: &CompactConfig, state: &CompactState) -> bool {
if state.consecutive_failures >= MAX_CONSECUTIVE_FAILURES {
return false;
}
@@ -133,7 +135,10 @@ pub fn should_compact(
/// 保留最近的 `keep_recent` 条消息不变。
///
/// 返回释放的估算 token 数。
pub fn microcompact(messages: &mut [OpenaiChatMessage], keep_recent: usize) -> u32 {
///
/// **审查 FIX-F**:仅压缩 `is_error: false` 的 `ToolResult` —— 错误结果包含对 LLM
/// 理解失败原因至关重要的诊断信息,压缩后 LLM 无法理解。
pub fn microcompact(messages: &mut [Message], keep_recent: usize) -> u32 {
if messages.len() <= keep_recent {
return 0;
}
@@ -141,19 +146,119 @@ pub fn microcompact(messages: &mut [OpenaiChatMessage], keep_recent: usize) -> u
let prune_start = messages.len() - keep_recent;
let mut freed_tokens: u32 = 0;
// 第一遍:计算可释放 token(仅非错误 ToolResult
for msg in &messages[..prune_start] {
if matches!(msg, OpenaiChatMessage::Tool { .. }) {
if matches!(msg, Message::ToolResult { is_error: false, .. }) {
freed_tokens += estimate_single_message_tokens(msg);
}
}
// 第二遍:替换内容(仅非错误 ToolResult
for msg in &mut messages[..prune_start] {
if let OpenaiChatMessage::Tool { content, .. } = msg {
*content = ContentField::Array(vec![OpenaiContentPart::Text {
if let Message::ToolResult { content, is_error: false, .. } = msg {
*content = vec![ContentBlock::Text {
text: "[pruned]".to_string(),
}]);
}];
}
}
freed_tokens
}
#[cfg(test)]
mod tests {
use super::*;
fn user_msg(s: &str) -> Message {
Message::user_text(s)
}
#[test]
fn estimate_message_tokens_handles_all_variants() {
let messages = vec![
Message::System {
content: vec![ContentBlock::Text {
text: "sys".into(),
}],
},
Message::user_text("hi"),
Message::assistant("ans"),
Message::user_image("b64", "image/png", crate::llm::types::shared::ImageDetail::Auto),
Message::tool_result("call_1", "tool res", false),
];
let tokens = estimate_message_tokens(&messages);
// 至少 5 条消息 × 4 role overhead = 20 + 文本/估算
assert!(tokens > 20);
}
#[test]
fn microcompact_replaces_old_tool_result_with_pruned() {
let mut messages = vec![
user_msg("hi"),
Message::tool_result("call_1", "raw result".repeat(50), false),
user_msg("again"),
user_msg("keep recent 1"),
user_msg("keep recent 2"),
];
let before_len = messages.len();
let freed = microcompact(&mut messages, 2);
assert!(freed > 0);
assert_eq!(messages.len(), before_len); // 只改内容,不删消息
// 索引 1 是被压缩的 ToolResult
if let Message::ToolResult { content, is_error, .. } = &messages[1] {
assert_eq!(content.len(), 1);
assert!(matches!(&content[0], ContentBlock::Text { text } if text == "[pruned]"));
assert!(!is_error);
} else {
panic!("expected ToolResult at index 1");
}
}
/// FIX-F 验证:错误结果不被压缩,诊断信息完整保留。
#[test]
fn microcompact_preserves_error_tool_results() {
let mut messages = vec![
user_msg("hi"),
Message::tool_result("call_1", "important error info: backend down", true),
user_msg("keep recent 1"),
user_msg("keep recent 2"),
];
let before_len = messages.len();
let freed = microcompact(&mut messages, 2);
assert_eq!(freed, 0); // 错误 ToolResult 不计入
assert_eq!(messages.len(), before_len);
// 错误信息保留完整
if let Message::ToolResult { content, is_error, .. } = &messages[1] {
assert!(is_error);
assert!(matches!(&content[0], ContentBlock::Text { text } if text.contains("backend down")));
} else {
panic!("expected ToolResult at index 1");
}
}
#[test]
fn microcompact_keeps_recent_messages() {
let mut messages = vec![
Message::tool_result("c1", "old", false),
user_msg("m1"),
user_msg("m2"),
user_msg("recent"),
];
let freed = microcompact(&mut messages, 1);
// keep_recent=1 → 仅最近 1 条不动,其余 ToolResult 压缩
// 索引 0 (ToolResult) 被压缩,索引 1-3 保留
assert!(freed > 0);
if let Message::ToolResult { content, .. } = &messages[0] {
assert!(matches!(&content[0], ContentBlock::Text { text } if text == "[pruned]"));
}
assert!(matches!(messages[3], Message::User { .. }));
}
#[test]
fn should_compact_respects_threshold() {
let cfg = CompactConfig::default();
let state = CompactState::new();
let empty: Vec<Message> = vec![];
assert!(!should_compact(&empty, &cfg, &state));
}
}
+455
View File
@@ -0,0 +1,455 @@
//! 跨 Provider 类型转换 —— `Message` ↔ OpenAI `OpenaiChatMessage`。
//!
//! Phase 1 引入:避免转换逻辑在 `LlmCycle`、各 Provider 中重复。
//! 这些函数期望作为**纯函数**被调用 —— 无内部状态,方便跨 Provider 复用。
//!
//! 转换范围:仅处理 `OpenaiChatMessage` ↔ `Message`、`ContentField` ↔ `Vec<ContentBlock>`。
//! 流式 chunk 转换见各 Provider 内部(OpenAI Chat / Anthropic)。
use serde_json::Value;
use crate::llm::types::message::{ContentBlock, Message};
use crate::llm::types::openai_message::{
ContentField, OpenaiChatMessage, OpenaiContentPart,
};
use crate::llm::types::OpenaiToolCall;
/// `OpenaiChatMessage` → IR `Message`。
///
/// 转换规则:
/// - `Developer` / `System` → `Message::System`
/// - `User` → `Message::User`(含图片/音频等多模态 part → `ContentBlock`
/// - `Assistant` → `Message::Assistant``tool_calls` 转为 `ContentBlock::ToolUse`
/// - `Tool` → `Message::ToolResult``is_error` 暂为 `false`OpenAI 不携带此标记)
/// - `Function`(已废弃)→ `Message::ToolResult``name` 作为 `tool_call_id` 兜底)
pub fn from_openai(msg: &OpenaiChatMessage) -> Message {
match msg {
OpenaiChatMessage::Developer { content, .. } | OpenaiChatMessage::System { content, .. } => {
Message::System {
content: content_to_blocks(content),
}
}
OpenaiChatMessage::User { content, .. } => Message::User {
content: content_to_blocks(content),
},
OpenaiChatMessage::Assistant {
content,
tool_calls,
..
} => {
let mut blocks = content_to_blocks(content);
if let Some(calls) = tool_calls {
for call in calls {
if let OpenaiToolCall::Function { id, function } = call {
let input: Value =
serde_json::from_str(&function.arguments).unwrap_or(Value::Null);
blocks.push(ContentBlock::ToolUse {
id: id.clone(),
name: function.name.clone(),
input,
});
}
}
}
Message::Assistant { content: blocks }
}
OpenaiChatMessage::Tool {
content,
tool_call_id,
} => Message::ToolResult {
tool_call_id: tool_call_id.clone(),
content: content_to_blocks(content),
is_error: false,
},
// ponytail: `function` 是 OpenAI 旧版 `function_call` API 残留变体;
// 当前主流为 `tool_calls`,此处仅保留兼容路径。
OpenaiChatMessage::Function { content, name } => Message::ToolResult {
tool_call_id: name.clone(),
content: content_to_blocks(content),
is_error: false,
},
}
}
/// IR `Message` → `OpenaiChatMessage`。
///
/// ponytail 简化:当前 `Assistant.content` 仅序列化 Text → `content: String`
/// `ToolUse` 拆出到 `tool_calls` 字段;thinking / image / audio / file / extension
/// 视为非 OpenAI 原生,**回传时丢弃**。Phase 2 切换为 `Vec<Message>` 后该丢弃
/// 自然消失(不再需要回传旧 wire 格式)。
pub fn to_openai(msg: &Message) -> OpenaiChatMessage {
match msg {
Message::System { content } => OpenaiChatMessage::System {
content: blocks_to_content(content),
name: None,
},
Message::User { content } => OpenaiChatMessage::User {
content: blocks_to_content(content),
name: None,
},
Message::UserImage { data, mime_type, detail } => {
// ponytail: 构造为单 image part 的 User 消息(OpenAI 多模态格式)。
let mime = mime_type.clone();
let is_url = data.starts_with("http://") || data.starts_with("https://");
let image_url = if is_url {
crate::llm::types::openai_message::ImageURL {
url: data.clone(),
detail: Some(*detail),
}
} else {
crate::llm::types::openai_message::ImageURL {
url: format!("data:{mime};base64,{data}"),
detail: Some(*detail),
}
};
OpenaiChatMessage::User {
content: ContentField::Array(vec![OpenaiContentPart::Image {
image_url,
detail: Some(*detail),
}]),
name: None,
}
}
Message::Assistant { content } => {
let mut text_blocks: Vec<String> = Vec::new();
let mut tool_call_blocks: Vec<OpenaiToolCall> = Vec::new();
for block in content {
match block {
ContentBlock::Text { text } => text_blocks.push(text.clone()),
ContentBlock::ToolUse { id, name, input } => {
tool_call_blocks.push(OpenaiToolCall::Function {
id: id.clone(),
function: crate::llm::types::tool::FunctionCall {
name: name.clone(),
arguments: serde_json::to_string(input)
.unwrap_or_else(|_| "null".to_string()),
},
});
}
// ponytail: thinking/refusal/image/audio/file/extension 在回传时被丢弃。
// Phase 2 切换后此函数整体移除,自然修复。
_ => {}
}
}
let text: String = text_blocks.into_iter().collect();
OpenaiChatMessage::Assistant {
content: if text.is_empty() {
ContentField::Array(vec![])
} else {
ContentField::String(text)
},
refusal: None,
name: None,
tool_calls: if tool_call_blocks.is_empty() {
None
} else {
Some(tool_call_blocks)
},
}
}
Message::ToolResult {
tool_call_id,
content,
is_error: _,
} => OpenaiChatMessage::Tool {
content: blocks_to_content(content),
tool_call_id: tool_call_id.clone(),
},
}
}
/// `ContentField` → `Vec<ContentBlock>`。
///
/// 仅产出 OpenAI 有 wire 对应的 `Text` / `Image` / `Audio` /
/// `Refusal`(合并为 `Text`block`File` 被截断。
pub fn content_to_blocks(field: &ContentField) -> Vec<ContentBlock> {
match field {
ContentField::String(s) => vec![ContentBlock::Text { text: s.clone() }],
ContentField::Array(parts) => parts
.iter()
.filter_map(|p| match p {
OpenaiContentPart::Text { text } => {
Some(ContentBlock::Text { text: text.clone() })
}
OpenaiContentPart::Refusal { refusal } => {
Some(ContentBlock::Text { text: refusal.clone() })
}
OpenaiContentPart::Image { image_url, .. } => {
// ponytail: 简化处理 —— URL 直接通过,data URI 拆出
// data:<mime>;base64,<b64> → ImageSource { data: b64, mime, is_url: false }。
let url = &image_url.url;
if let Some(rest) = url.strip_prefix("data:") {
if let Some((mime, b64)) = rest.split_once(";base64,") {
return Some(ContentBlock::Image {
source: crate::llm::types::message::ImageSource {
data: b64.to_string(),
mime_type: mime.to_string(),
is_url: false,
},
});
}
}
Some(ContentBlock::Image {
source: crate::llm::types::message::ImageSource {
data: url.clone(),
mime_type: "image/url".to_string(),
is_url: true,
},
})
}
OpenaiContentPart::InputAudio { input_audio } => Some(ContentBlock::Audio {
source: crate::llm::types::message::AudioSource {
data: input_audio.data.clone(),
format: input_audio.format,
},
}),
// File 暂不映射(OpenAI File API 与 IR 不对齐)
OpenaiContentPart::File { .. } => None,
})
.collect(),
}
}
/// `Vec<ContentBlock>` → `ContentField`。
///
/// 单文本块 → `ContentField::String`;多块或非文本主导 → `ContentField::Array`。
pub fn blocks_to_content(blocks: &[ContentBlock]) -> ContentField {
if let [ContentBlock::Text { text }] = blocks {
return ContentField::String(text.clone());
}
let parts: Vec<OpenaiContentPart> = blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(OpenaiContentPart::Text { text: text.clone() }),
ContentBlock::Image { source } => Some(OpenaiContentPart::Image {
image_url: if source.is_url {
crate::llm::types::openai_message::ImageURL {
url: source.data.clone(),
detail: None,
}
} else {
crate::llm::types::openai_message::ImageURL {
url: format!("data:{};base64,{}", source.mime_type, source.data),
detail: None,
}
},
detail: None,
}),
ContentBlock::Audio { source } => Some(OpenaiContentPart::InputAudio {
input_audio: crate::llm::types::openai_message::InputAudio {
data: source.data.clone(),
format: source.format,
},
}),
// ponytail: 其他 block 类型在 OpenAI wire 上无对应,回传时被截断。
_ => None,
})
.collect();
if parts.is_empty() {
ContentField::Array(vec![])
} else {
ContentField::Array(parts)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::types::message::ImageSource;
use crate::llm::types::shared::{AudioFormat, ImageDetail};
use serde_json::json;
#[test]
fn from_openai_system_maps_to_message_system() {
let m = OpenaiChatMessage::system_text("you are helpful");
let ir = from_openai(&m);
match ir {
Message::System { content } => {
assert_eq!(content.len(), 1);
assert!(matches!(&content[0], ContentBlock::Text { text } if text == "you are helpful"));
}
_ => panic!("expected System variant"),
}
}
#[test]
fn from_openai_assistant_tool_call_to_tool_use_block() {
let m = OpenaiChatMessage::Assistant {
content: ContentField::String("ok".into()),
refusal: None,
name: None,
tool_calls: Some(vec![OpenaiToolCall::Function {
id: "call_1".into(),
function: crate::llm::types::tool::FunctionCall {
name: "search".into(),
arguments: r#"{"q":"rust"}"#.into(),
},
}]),
};
let ir = from_openai(&m);
match ir {
Message::Assistant { content } => {
assert_eq!(content.len(), 2);
match &content[1] {
ContentBlock::ToolUse { id, name, input } => {
assert_eq!(id, "call_1");
assert_eq!(name, "search");
assert_eq!(input, &json!({"q": "rust"}));
}
_ => panic!("expected ToolUse"),
}
}
_ => panic!("expected Assistant"),
}
}
#[test]
fn from_openai_tool_to_tool_result() {
let m = OpenaiChatMessage::Tool {
content: ContentField::String("ok".into()),
tool_call_id: "call_1".into(),
};
let ir = from_openai(&m);
match ir {
Message::ToolResult {
tool_call_id,
content,
is_error,
} => {
assert_eq!(tool_call_id, "call_1");
assert!(!is_error);
assert_eq!(content.len(), 1);
}
_ => panic!("expected ToolResult"),
}
}
#[test]
fn to_openai_assistant_text_singular_string_content() {
let m = Message::Assistant {
content: vec![ContentBlock::Text { text: "hi".into() }],
};
let wire = to_openai(&m);
match wire {
OpenaiChatMessage::Assistant {
content,
tool_calls,
..
} => {
assert!(matches!(content, ContentField::String(s) if s == "hi"));
assert!(tool_calls.is_none());
}
_ => panic!("expected Assistant"),
}
}
#[test]
fn to_openai_assistant_with_tool_use_produces_tool_calls() {
let m = Message::Assistant {
content: vec![
ContentBlock::Text { text: "ok".into() },
ContentBlock::ToolUse {
id: "call_1".into(),
name: "search".into(),
input: json!({"q": "rust"}),
},
],
};
let wire = to_openai(&m);
match wire {
OpenaiChatMessage::Assistant {
content,
tool_calls,
..
} => {
assert!(matches!(content, ContentField::String(_)));
let calls = tool_calls.expect("tool_calls");
assert_eq!(calls.len(), 1);
if let OpenaiToolCall::Function { id, function } = &calls[0] {
assert_eq!(id, "call_1");
assert_eq!(function.name, "search");
assert!(function.arguments.contains("rust"));
} else {
panic!("expected Function variant");
}
}
_ => panic!("expected Assistant"),
}
}
#[test]
fn to_openai_user_image_builds_data_uri() {
let m = Message::UserImage {
data: "BASE64DATA".into(),
mime_type: "image/png".into(),
detail: ImageDetail::High,
};
let wire = to_openai(&m);
match wire {
OpenaiChatMessage::User { content, .. } => match content {
ContentField::Array(parts) => {
assert_eq!(parts.len(), 1);
match &parts[0] {
OpenaiContentPart::Image { image_url, .. } => {
assert_eq!(
image_url.url,
"data:image/png;base64,BASE64DATA"
);
}
_ => panic!("expected Image part"),
}
}
_ => panic!("expected Array content"),
},
_ => panic!("expected User"),
}
}
#[test]
fn blocks_to_content_singular_text_uses_string() {
let blocks = vec![ContentBlock::Text { text: "hi".into() }];
let f = blocks_to_content(&blocks);
assert!(matches!(f, ContentField::String(s) if s == "hi"));
}
#[test]
fn blocks_to_content_multiple_blocks_uses_array() {
let blocks = vec![
ContentBlock::Text { text: "a".into() },
ContentBlock::Text { text: "b".into() },
];
let f = blocks_to_content(&blocks);
assert!(matches!(f, ContentField::Array(parts) if parts.len() == 2));
}
#[test]
fn content_to_blocks_refusal_merges_to_text() {
let field = ContentField::Array(vec![OpenaiContentPart::Refusal {
refusal: "policy violation".into(),
}]);
let blocks = content_to_blocks(&field);
assert_eq!(blocks.len(), 1);
assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "policy violation"));
}
#[test]
fn roundtrip_image_via_data_uri() {
let field = ContentField::Array(vec![OpenaiContentPart::Image {
image_url: crate::llm::types::openai_message::ImageURL {
url: "data:image/png;base64,AAA".into(),
detail: Some(ImageDetail::Auto),
},
detail: None,
}]);
let blocks = content_to_blocks(&field);
assert_eq!(blocks.len(), 1);
match &blocks[0] {
ContentBlock::Image { source } => {
assert_eq!(source.data, "AAA");
assert_eq!(source.mime_type, "image/png");
assert!(!source.is_url);
}
_ => panic!("expected Image"),
}
}
}
+212 -214
View File
@@ -16,12 +16,12 @@ use crate::llm::compact::{should_compact, microcompact, CompactConfig, CompactSt
use crate::llm::cycle::retry::should_retry;
use crate::llm::error::LlmError;
use crate::llm::hooks::{HookContext, HookExecutor};
use crate::llm::provider::LlmProvider;
use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};
use crate::llm::stream::StreamEvent;
use crate::llm::types::{
ChatRequest, ChatResponse, FinishReason, OpenaiChatMessage, OpenaiTool, OpenaiToolCall,
ToolChoice, ToolDefinition,
};
use crate::llm::types::message::{ContentBlock, Message};
use crate::llm::types::request_v2::MessageRequest;
use crate::llm::types::response_v2::{MessageResponse, StopReason};
use crate::llm::types::{ToolChoice, ToolDefinition};
/// LLM 调用周期配置。
pub struct CycleConfig {
@@ -62,12 +62,18 @@ impl Default for CycleConfig {
}
/// LLM 调用周期 —— 管理一次或多次 LLM 请求的生命周期。
///
/// Phase 2 修订:
/// - `messages` 字段从 `Vec<OpenaiChatMessage>` 切换为 `Vec<Message>`IR 类型)。
/// - 移除 `system_prompt` 字段(FIX-D)—— 调用方通过 `Message::system_text()` +
/// `with_messages()` 自行管理系统提示消息,避免重复维护两条路径。
/// - `with_system_prompt()` 方法标记 `#[deprecated]`,过渡期内仍可用。
pub struct LlmCycle {
provider: Arc<dyn LlmProvider>,
config: CycleConfig,
usage: CostTracker,
messages: Vec<OpenaiChatMessage>,
system_prompt: Option<String>,
/// 消息历史 —— 直接存储 IR `Message` 类型,build_request 不再做转换。
messages: Vec<Message>,
hook_executor: Option<Arc<HookExecutor>>,
compact_config: Option<CompactConfig>,
compact_state: CompactState,
@@ -91,16 +97,22 @@ impl LlmCycle {
config,
usage: CostTracker::default(),
messages: Vec::new(),
system_prompt: None,
hook_executor: None,
compact_config: None,
compact_state: CompactState::new(),
}
}
/// 设置系统提示词。
/// 设置系统提示词**已废弃** —— Phase 2 起使用 `Message::system_text()` + `with_messages()`
///
/// 当前实现为过渡期保留:在 messages 头部插入 `Message::System { content: [Text { text }] }`。
#[deprecated(
since = "0.2.0",
note = "请改用 Message::system_text() + with_messages()"
)]
pub fn with_system_prompt(mut self, prompt: String) -> Self {
self.system_prompt = Some(prompt);
self.messages
.insert(0, Message::System { content: vec![ContentBlock::Text { text: prompt }] });
self
}
@@ -121,8 +133,8 @@ impl LlmCycle {
&self.usage
}
/// 获取消息历史引用。
pub fn messages(&self) -> &[OpenaiChatMessage] {
/// 获取消息历史引用Phase 2:返回 `&[Message]` IR 类型)
pub fn messages(&self) -> &[Message] {
&self.messages
}
@@ -137,45 +149,42 @@ impl LlmCycle {
}
/// 直接设置消息历史(覆盖已有消息),支持 Builder 链式调用。
pub fn with_messages(mut self, messages: Vec<OpenaiChatMessage>) -> Self {
pub fn with_messages(mut self, messages: Vec<Message>) -> Self {
self.messages = messages;
self
}
/// 追加消息到历史尾部。
pub fn extend_messages(&mut self, messages: Vec<OpenaiChatMessage>) {
pub fn extend_messages(&mut self, messages: Vec<Message>) {
self.messages.extend(messages);
}
/// 追加单条消息到历史尾部。
///
/// 公开给 `submit_stream()` 消费方在收到 `MessageComplete` 事件后调用。
pub fn push_message(&mut self, msg: Message) {
self.messages.push(msg);
}
/// 使用预构建消息提交(跳过自动 push user prompt)。
///
/// 与 `submit()` 不同,不自动添加 `user_text(prompt)`,也不自动插入 system prompt
/// 与 `submit()` 不同,不自动添加 `user_text(prompt)`。
/// 调用方完全控制消息序列内容。
pub async fn submit_messages(
&mut self,
messages: Vec<OpenaiChatMessage>,
messages: Vec<Message>,
tools: Vec<ToolDefinition>,
) -> Result<ChatResponse, LlmError> {
let openai_tools: Option<Vec<OpenaiTool>> = if tools.is_empty() {
None
} else {
Some(
tools
.iter()
.map(|t| OpenaiTool::Function {
function: t.clone(),
})
.collect(),
)
};
) -> Result<MessageResponse, LlmError> {
let ir_messages: Vec<Message> = messages;
let ir_tools: Vec<ToolDefinition> = tools.clone();
let request = ChatRequest {
let request = MessageRequest {
model: self.config.model.clone(),
messages,
messages: ir_messages,
tools: ir_tools,
tool_choice: ToolChoice::Auto,
max_tokens: self.config.max_tokens,
temperature: self.config.temperature,
tools: openai_tools,
tool_choice: Some(ToolChoice::Auto),
..Default::default()
};
@@ -198,11 +207,7 @@ impl LlmCycle {
match self.provider.chat(request).await {
Ok(response) => {
if let Some(ref executor) = self.hook_executor {
let post_request = ChatRequest {
model: self.config.model.clone(),
messages: vec![],
..Default::default()
};
let post_request = MessageRequest::default();
let ctx = HookContext::new(crate::llm::hooks::HookEvent::PostRequest)
.with_request(&post_request);
executor
@@ -229,8 +234,8 @@ impl LlmCycle {
&mut self,
prompt: String,
tools: Vec<ToolDefinition>,
) -> Result<ChatResponse, LlmError> {
self.messages.push(OpenaiChatMessage::user_text(prompt));
) -> Result<MessageResponse, LlmError> {
self.messages.push(Message::user_text(prompt));
if let Some(ref config) = self.compact_config
&& should_compact(&self.messages, config, &self.compact_state)
@@ -273,6 +278,7 @@ impl LlmCycle {
.await;
}
// ponytail: Phase 2 直接存储 IR Message —— 不再转换。
self.messages.push(response.message.clone());
self.usage.add(&response.usage);
@@ -308,16 +314,38 @@ impl LlmCycle {
}
}
/// 提交用户消息并返回语义事件流。
/// 提交用户消息并返回语义事件流Phase 2 / FIX-E 简化方案)
///
/// 与 `submit` 不同,该方法返回流式事件而非完整响应。
/// 适用于需要实时处理 LLM 输出的场景。
///
/// **Phase 2 设计决策(FIX-E**
/// - `Item = StreamEvent`**不再是 `Result<StreamEvent, LlmError>`**
/// - 错误统一以 `StreamEvent::Error { message }` 形式在流中传出
/// - `self.messages` 不会在流结束后自动 push Assistant 响应 ——
/// **调用方** 在收到 `StreamEvent::MessageComplete` 后手动调用
/// `cycle.push_message(response.message.clone())` 完成消息历史追加
///
/// 调用模式(FIX-E 推荐):
/// ```ignore
/// use futures_util::StreamExt;
/// let mut stream = cycle.submit_stream(prompt, tools).await?;
/// let mut final_response: Option<MessageResponse> = None;
/// while let Some(event) = stream.next().await {
/// if let StreamEvent::MessageComplete { full_response } = &event {
/// final_response = Some(full_response.clone());
/// }
/// }
/// if let Some(resp) = final_response {
/// cycle.push_message(resp.message.clone());
/// }
/// ```
pub async fn submit_stream(
&mut self,
prompt: String,
tools: Vec<ToolDefinition>,
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError> {
self.messages.push(OpenaiChatMessage::user_text(prompt));
self.messages.push(Message::user_text(prompt));
if let Some(ref config) = self.compact_config
&& should_compact(&self.messages, config, &self.compact_state)
@@ -330,6 +358,7 @@ impl LlmCycle {
let request = self.build_request(&tools);
// PreRequest hook
if let Some(ref executor) = self.hook_executor {
let ctx = HookContext::new(crate::llm::hooks::HookEvent::PreRequest)
.with_request(&request);
@@ -346,62 +375,27 @@ impl LlmCycle {
}
}
let chunk_stream = self.provider.chat_stream(request).await?;
let ir_event_stream = self.provider.chat_stream(request).await?;
let hook_executor = self.hook_executor.clone();
let post_request = self.build_request(&tools);
// ponytail: Phase 2 简化方案(FIX-E)。流是延迟求值的,&mut self 无法进入闭包。
// 调用方在收到 MessageComplete 后手动调 push_message()。
Ok(Box::pin(stream! {
use futures_util::StreamExt;
let mut chunk_stream = chunk_stream;
let mut ir_event_stream = ir_event_stream;
while let Some(result) = chunk_stream.next().await {
while let Some(result) = ir_event_stream.next().await {
match result {
Ok(chunk) => {
let mut assistant_text = String::new();
let mut tool_started: Option<(String, String, String)> = None;
for choice in &chunk.choices {
let delta = &choice.delta;
if let Some(content) = &delta.content {
assistant_text.push_str(content);
}
if let Some(tool_calls) = &delta.tool_calls
&& let Some(tc) = tool_calls.first()
{
let crate::llm::types::OpenaiToolCall::Function { id, function } = tc;
tool_started = Some((id.clone(), function.name.clone(), function.arguments.clone()));
}
}
if !assistant_text.is_empty() {
yield StreamEvent::AssistantTextDelta { text: assistant_text };
}
if let Some((tool_call_id, tool_name, arguments)) = tool_started {
let args: serde_json::Value = serde_json::from_str(&arguments)
.unwrap_or(serde_json::Value::Null);
yield StreamEvent::ToolExecutionStarted {
tool_name,
input: args,
tool_call_id,
};
}
for choice in &chunk.choices {
if let Some(finish_reason) = &choice.finish_reason {
yield StreamEvent::TurnComplete {
reason: *finish_reason,
};
}
}
if let Some(usage_info) = &chunk.usage {
yield StreamEvent::CostUpdate { usage: *usage_info };
Ok(event) => {
let is_terminal =
matches!(event, StreamEvent::MessageComplete { .. } | StreamEvent::Error { .. });
yield event;
if is_terminal {
break;
}
}
Err(e) => {
// ponytail: 错误不再走 Err 分支,统一以 StreamEvent::Error 传出。
if let Some(ref executor) = hook_executor {
let ctx = crate::llm::hooks::HookContext::new(
crate::llm::hooks::HookEvent::OnError,
@@ -417,11 +411,13 @@ impl LlmCycle {
}
}
// ponytail: post_request hook 收到的消息列表**不包含本次 Assistant 响应**
// (因为流是延迟求值,本轮响应还未到达)。调用方如需完整上下文,
// 应在收到 MessageComplete 后手动触发 hook。
if let Some(ref executor) = hook_executor {
let ctx = crate::llm::hooks::HookContext::new(
crate::llm::hooks::HookEvent::PostRequest,
)
.with_request(&post_request);
);
executor
.execute(crate::llm::hooks::HookEvent::PostRequest, &ctx)
.await;
@@ -429,37 +425,16 @@ impl LlmCycle {
}))
}
fn build_request(&self, tools: &[ToolDefinition]) -> ChatRequest {
let mut messages = self.messages.clone();
if let Some(sys_prompt) = &self.system_prompt
&& !messages
.iter()
.any(|m| matches!(m, OpenaiChatMessage::System { .. }))
{
messages.insert(0, OpenaiChatMessage::system_text(sys_prompt));
}
let openai_tools: Option<Vec<OpenaiTool>> = if tools.is_empty() {
None
} else {
Some(
tools
.iter()
.map(|t| OpenaiTool::Function {
function: t.clone(),
})
.collect(),
)
};
ChatRequest {
fn build_request(&self, tools: &[ToolDefinition]) -> MessageRequest {
// ponytail: Phase 2 简化 —— 直接 clone self.messages,无任何转换 / system prompt 注入。
// 系统消息如需存在,由调用方通过 `with_messages()` 自行管理。
MessageRequest {
model: self.config.model.clone(),
messages,
messages: self.messages.clone(),
tools: tools.to_vec(),
tool_choice: ToolChoice::Auto,
max_tokens: self.config.max_tokens,
temperature: self.config.temperature,
tools: openai_tools,
tool_choice: Some(ToolChoice::Auto),
..Default::default()
}
}
@@ -470,7 +445,7 @@ impl LlmCycle {
async fn submit_request(
&mut self,
tools: &[ToolDefinition],
) -> Result<ChatResponse, LlmError> {
) -> Result<MessageResponse, LlmError> {
let mut attempts = 0;
loop {
@@ -548,13 +523,13 @@ impl LlmCycle {
&mut self,
prompt: String,
registry: &crate::tools::ToolRegistry,
) -> Result<ChatResponse, LlmError> {
) -> Result<MessageResponse, LlmError> {
let tools = registry.definitions();
let max_turns = self.config.max_tool_turns.unwrap_or(10);
let tool_timeout = self.config.tool_timeout_secs;
let max_bytes = self.config.max_tool_result_bytes;
self.messages.push(OpenaiChatMessage::user_text(prompt));
self.messages.push(Message::user_text(prompt));
self.maybe_compact();
let mut turn = 0;
@@ -570,10 +545,10 @@ impl LlmCycle {
let response = self.submit_request(&tools).await?;
// 判断是否需要执行工具
let should_execute = matches!(response.stop_reason, Some(FinishReason::ToolCalls))
&& has_tool_calls_in_message(&response.message);
let should_execute = matches!(response.stop_reason, StopReason::ToolUse)
&& has_tool_calls_in_response(&response);
// 将 Assistant 响应(含 tool_calls 或最终文本)追加到消息历史
// ponytail: Phase 2 直接存储 IR Message —— 不再转换。
self.messages.push(response.message.clone());
if !should_execute {
@@ -581,13 +556,13 @@ impl LlmCycle {
}
// 解析 tool_calls 并执行
let tool_calls = extract_tool_calls_from_message(&response.message);
let calls: Vec<(String, serde_json::Value)> = tool_calls
let tool_calls = extract_tool_calls_from_response(&response);
let calls: Vec<(String, String, serde_json::Value)> = tool_calls
.into_iter()
.map(|(_id, name, args)| {
let args: serde_json::Value =
.map(|(id, name, args)| {
let value: serde_json::Value =
serde_json::from_str(&args).unwrap_or(serde_json::Value::Null);
(name, args)
(id, name, value)
})
.collect();
@@ -595,9 +570,10 @@ impl LlmCycle {
// 回传工具结果
for result in results {
let content = match result.output {
let is_error = result.output.is_err();
let content = match &result.output {
Ok(value) => {
let serialized = serde_json::to_string(&value).unwrap_or_else(|e| {
let serialized = serde_json::to_string(value).unwrap_or_else(|e| {
tracing::warn!("工具结果序列化失败: {}", e);
"{}".to_string()
});
@@ -613,8 +589,16 @@ impl LlmCycle {
}
};
self.messages
.push(OpenaiChatMessage::tool_result(result.tool_name, content));
// ponytail: 当前 self.messages 仍是 Vec<OpenaiChatMessage> (Phase 2 切换后
// 改为 Message::tool_result 并传入 result.tool_call_id)。当前实现已经使用
// 真实 tool_call_id 而非 tool_name 充当 —— 这条 FIX-A 修复与 Phase 2 消息切换
// 同步生效。
// ponytail: Phase 2 直接存储 Message::ToolResultis_error 由 ToolInvocation.output 推断。
self.messages.push(Message::tool_result(
result.tool_call_id,
content,
is_error,
));
}
// 每轮工具执行后触发 compaction
@@ -642,38 +626,35 @@ impl LlmCycle {
}
/// 判断 Assistant 消息是否包含 tool_calls。
fn has_tool_calls_in_message(msg: &OpenaiChatMessage) -> bool {
matches!(
msg,
OpenaiChatMessage::Assistant {
tool_calls: Some(calls),
..
} if !calls.is_empty()
)
fn has_tool_calls_in_response(response: &MessageResponse) -> bool {
match &response.message {
Message::Assistant { content } => content
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { .. })),
_ => false,
}
}
/// 提取 Assistant 消息中的 tool_calls。
///
/// 返回 `(tool_call_id, tool_name, arguments_json_string)` 列表。
fn extract_tool_calls_from_message(
msg: &OpenaiChatMessage,
///
/// ponytail: 当前 Phase 0 实现,`arguments_json_string` 内含 JSON 序列化的 input。
/// 消费方在调用 `registry.invoke_all()` 时反序列化一次。该小段冗余序列化
/// 在 Phase 2 切换为 `Vec<Message>` 后可整体消除。
fn extract_tool_calls_from_response(
response: &MessageResponse,
) -> Vec<(String, String, String)> {
if let OpenaiChatMessage::Assistant {
tool_calls: Some(calls),
..
} = msg
{
calls
.iter()
.map(|c| match c {
OpenaiToolCall::Function { id, function } => {
(id.clone(), function.name.clone(), function.arguments.clone())
}
})
.collect()
} else {
Vec::new()
let mut out = Vec::new();
if let Message::Assistant { content } = &response.message {
for block in content {
if let ContentBlock::ToolUse { id, name, input } = block {
let args = serde_json::to_string(input).unwrap_or_else(|_| "null".to_string());
out.push((id.clone(), name.clone(), args));
}
}
}
out
}
/// 截断工具结果到指定字节数。
@@ -691,19 +672,20 @@ fn truncate_tool_result(s: &str, max_bytes: usize) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::types::{ContentField, OpenaiContentPart};
use crate::tools::{BaseTool, ToolRegistry};
use async_trait::async_trait;
use futures_core::Stream;
use serde_json::{json, Value};
use std::pin::Pin;
/// 模拟 Provider —— 预定义响应序列,按调用顺序返回。
struct MockProvider {
responses: std::sync::Mutex<Vec<ChatResponse>>,
responses: std::sync::Mutex<Vec<MessageResponse>>,
call_count: std::sync::Mutex<u32>,
}
impl MockProvider {
fn new(responses: Vec<ChatResponse>) -> Self {
fn new(responses: Vec<MessageResponse>) -> Self {
Self {
responses: std::sync::Mutex::new(responses),
call_count: std::sync::Mutex::new(0),
@@ -713,7 +695,7 @@ mod tests {
#[async_trait]
impl LlmProvider for MockProvider {
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
async fn chat(&self, _request: MessageRequest) -> Result<MessageResponse, LlmError> {
let mut count = self.call_count.lock().unwrap();
*count += 1;
let mut responses = self.responses.lock().unwrap();
@@ -722,45 +704,61 @@ mod tests {
}
Ok(responses.remove(0))
}
async fn chat_stream(
&self,
_request: MessageRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
{
unimplemented!()
}
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
provider_name: "mock",
supported_models: None,
features: ProviderFeatures::default(),
}
}
}
fn empty_usage() -> crate::llm::types::Usage {
crate::llm::types::Usage::default()
}
fn assistant_text_response(text: &str) -> ChatResponse {
ChatResponse {
message: OpenaiChatMessage::assistant_text(text),
fn assistant_text_response(text: &str) -> MessageResponse {
MessageResponse {
id: String::new(),
model: String::new(),
message: Message::Assistant {
content: vec![ContentBlock::Text {
text: text.into(),
}],
},
usage: empty_usage(),
stop_reason: Some(FinishReason::Stop),
stop_reason: StopReason::Stop,
extra: std::collections::HashMap::new(),
}
}
fn assistant_tool_call_response(
calls: Vec<(&str, &str, &str)>,
) -> ChatResponse {
use crate::llm::types::{OpenaiToolCall, FunctionCall};
let tool_calls: Vec<OpenaiToolCall> = calls
fn assistant_tool_call_response(calls: Vec<(&str, &str, &str)>) -> MessageResponse {
let tool_blocks: Vec<ContentBlock> = calls
.into_iter()
.map(|(id, name, args)| OpenaiToolCall::Function {
id: id.to_string(),
function: FunctionCall {
.map(|(id, name, args)| {
let input: serde_json::Value =
serde_json::from_str(args).unwrap_or(serde_json::Value::Null);
ContentBlock::ToolUse {
id: id.to_string(),
name: name.to_string(),
arguments: args.to_string(),
},
input,
}
})
.collect();
ChatResponse {
message: OpenaiChatMessage::Assistant {
content: ContentField::Array(vec![OpenaiContentPart::Text {
text: String::new(),
}]),
refusal: None,
name: None,
tool_calls: Some(tool_calls),
},
MessageResponse {
id: String::new(),
model: String::new(),
message: Message::Assistant { content: tool_blocks },
usage: empty_usage(),
stop_reason: Some(FinishReason::ToolCalls),
stop_reason: StopReason::ToolUse,
extra: std::collections::HashMap::new(),
}
}
@@ -790,7 +788,6 @@ mod tests {
#[tokio::test]
async fn test_submit_with_tools_single_turn() {
// 第一轮:返回 tool_call;第二轮:返回最终文本
let responses = vec![
assistant_tool_call_response(vec![("call_1", "add", r#"{"a":1,"b":2}"#)]),
assistant_text_response("答案是 3"),
@@ -805,24 +802,37 @@ mod tests {
.submit_with_tools("1+2=?".to_string(), &registry)
.await
.unwrap();
// 验证最终响应是文本响应
assert!(matches!(
response.message,
OpenaiChatMessage::Assistant { .. }
));
// 最终响应是 Assistant 消息
assert!(matches!(response.message, Message::Assistant { .. }));
// 验证消息历史:user, assistant(tool_calls), tool, assistant(text)
// 验证消息历史:
// user, assistant(含 tool_use), tool_result, assistant(text)
let messages = cycle.messages();
assert_eq!(messages.len(), 4);
assert!(matches!(messages[0], OpenaiChatMessage::User { .. }));
assert!(matches!(messages[1], OpenaiChatMessage::Assistant { .. }));
assert!(matches!(messages[2], OpenaiChatMessage::Tool { .. }));
assert!(matches!(messages[3], OpenaiChatMessage::Assistant { .. }));
assert!(matches!(messages[0], Message::User { .. }));
assert!(matches!(
messages[1],
Message::Assistant {
content: _,
}
));
if let Message::Assistant { content } = &messages[1] {
assert!(content
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { .. })));
}
assert!(matches!(
messages[2],
Message::ToolResult {
is_error: false,
..
}
));
assert!(matches!(messages[3], Message::Assistant { .. }));
}
#[tokio::test]
async fn test_submit_with_tools_multi_turn() {
// 3 轮 tool 调用后给出最终答案
let responses = vec![
assistant_tool_call_response(vec![("call_1", "add", r#"{"a":1,"b":2}"#)]),
assistant_tool_call_response(vec![("call_2", "add", r#"{"a":3,"b":4}"#)]),
@@ -839,10 +849,7 @@ mod tests {
.submit_with_tools("计算总和".to_string(), &registry)
.await
.unwrap();
assert!(matches!(
response.message,
OpenaiChatMessage::Assistant { .. }
));
assert!(matches!(response.message, Message::Assistant { .. }));
// user + 3*(assistant + tool) + final assistant = 8
let messages = cycle.messages();
@@ -851,10 +858,8 @@ mod tests {
#[tokio::test]
async fn test_submit_with_tools_max_turns_exceeded() {
// 配置 max_tool_turns = 2
let mut config = CycleConfig::default();
config.max_tool_turns = Some(2);
// 4 轮 tool 调用 + 终止
let responses = vec![
assistant_tool_call_response(vec![("c1", "add", r#"{"a":1,"b":1}"#)]),
assistant_tool_call_response(vec![("c2", "add", r#"{"a":1,"b":1}"#)]),
@@ -867,15 +872,12 @@ mod tests {
let mut registry = ToolRegistry::new();
registry.register(std::sync::Arc::new(AddTool)).unwrap();
let result = cycle
.submit_with_tools("test".to_string(), &registry)
.await;
let result = cycle.submit_with_tools("test".to_string(), &registry).await;
assert!(matches!(result, Err(LlmError::Other(msg)) if msg.contains("达到最大工具循环轮次")));
}
#[tokio::test]
async fn test_submit_with_tools_no_tool_call_response() {
// LLM 直接给出最终响应(不调用工具)
let responses = vec![assistant_text_response("直接回答")];
let provider = Box::new(MockProvider::new(responses));
let mut cycle = LlmCycle::new(provider, CycleConfig::default());
@@ -887,10 +889,7 @@ mod tests {
.submit_with_tools("直接回答".to_string(), &registry)
.await
.unwrap();
assert!(matches!(
response.message,
OpenaiChatMessage::Assistant { .. }
));
assert!(matches!(response.message, Message::Assistant { .. }));
}
#[test]
@@ -911,7 +910,6 @@ mod tests {
fn test_truncate_tool_result_chinese_chars() {
let s = "".repeat(100);
let truncated = truncate_tool_result(&s, 50);
// 不会在字符中间截断
assert!(truncated.starts_with(""));
}
}
+3 -3
View File
@@ -3,7 +3,7 @@
use async_trait::async_trait;
use crate::llm::error::LlmError;
use crate::llm::types::ChatRequest;
use crate::llm::types::request_v2::MessageRequest;
/// 生命周期钩子事件点。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -30,7 +30,7 @@ pub struct HookContext<'a> {
/// 当前事件点。
pub event: HookEvent,
/// 当前的请求(部分事件点可用)。
pub request: Option<&'a ChatRequest>,
pub request: Option<&'a MessageRequest>,
/// 当前错误(仅 OnError 和 OnRetry 可用)。
pub error: Option<&'a LlmError>,
/// 当前重试次数(从 1 开始,仅 OnRetry 可用)。
@@ -53,7 +53,7 @@ impl<'a> HookContext<'a> {
}
}
pub(crate) fn with_request(mut self, request: &'a ChatRequest) -> Self {
pub(crate) fn with_request(mut self, request: &'a MessageRequest) -> Self {
self.request = Some(request);
self
}
+92 -28
View File
@@ -1,18 +1,33 @@
pub mod anthropic;
pub mod openai;
pub mod openai_compat;
pub mod registry;
use std::pin::Pin;
use tokio_stream::Stream;
use futures_core::Stream;
use serde::{Deserialize, Serialize};
use crate::llm::error::LlmError;
use crate::llm::types::{ChatRequest, ChatResponse, OpenaiChatChunk};
use async_trait::async_trait;
use crate::llm::types::request_v2::MessageRequest;
use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
/// Provider 类型枚举 —— `create_provider()` 在编译期 exhaustive match 中使用。
///
/// 设计依据(见 `docs/10-llm-provider-refinement.md` §2.5 Decision-05):
/// 当前协议数量(5 种以内)完全可控,enum 的编译期安全检查优于运行时的 `HashMap::get()`。
/// 未来如果扩展到 15+ 种以上,再改为注册表模式。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderType {
OpenAI,
/// OpenAI Chat Completions API(兼容 DeepSeek / Qwen 等 `/chat/completions` 端点)。
OpenaiChat,
/// OpenAI Response API。
OpenaiResponse,
/// Anthropic Messages API。
Anthropic,
/// DeepSeekOpenAI-compatible `/chat/completions`)。
DeepSeek,
/// Qwen / 阿里云百炼(OpenAI-compatible `/chat/completions`)。
Qwen,
}
@@ -21,60 +36,109 @@ impl std::str::FromStr for ProviderType {
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"openai" => Ok(ProviderType::OpenAI),
"openai" | "openai-chat" | "openai_chat" => Ok(ProviderType::OpenaiChat),
"openai-response" | "openai_response" | "response" => Ok(ProviderType::OpenaiResponse),
"anthropic" | "claude" => Ok(ProviderType::Anthropic),
"deepseek" => Ok(ProviderType::DeepSeek),
"qwen" | "dashscope" | "tongyi" => Ok(ProviderType::Qwen),
_ => Err(format!("未知的 Provider 类型: {}", s)),
_ => Err(format!("未知的 Provider 类型: {s}")),
}
}
}
/// Provider 构造参数 —— 通用 base_url + api_key + model。
pub struct ProviderConfig {
pub base_url: String,
pub api_key: String,
pub model: String,
}
/// Provider 工厂 —— exhaustive match 在编译期保证新 Provider 被注册。
pub fn create_provider(
provider_type: ProviderType,
config: ProviderConfig,
) -> Result<Box<dyn LlmProvider>, LlmError> {
match provider_type {
ProviderType::OpenAI => Ok(Box::new(openai::OpenaiProvider::new(
ProviderType::OpenaiChat => Ok(Box::new(openai::OpenaiChatProvider::new(
config.base_url,
config.api_key,
config.model,
))),
ProviderType::OpenaiResponse => Err(LlmError::Other(
"OpenaiResponse Provider 在 Phase 1 暂不实现;请使用 OpenaiChat".into(),
)),
ProviderType::Anthropic => Ok(Box::new(anthropic::AnthropicProvider::new(
config.base_url,
config.api_key,
config.model,
))),
ProviderType::DeepSeek => Ok(Box::new(openai_compat::DeepSeekProvider::new(
config.base_url,
config.api_key,
config.model,
))),
ProviderType::Qwen => Ok(Box::new(openai_compat::QwenProvider::new(
config.base_url,
config.api_key,
config.model,
))),
ProviderType::DeepSeek => {
unimplemented!("DeepSeek Provider 尚未实现")
}
ProviderType::Qwen => {
unimplemented!("Qwen Provider 尚未实现")
}
}
}
/// Provider 能力描述 —— 静态元信息,调用方据此决定可用特性。
///
/// 设计依据(见 `docs/10-llm-provider-refinement.md` §4 任务 6 决策):
/// `ProviderCapabilities` 与 trait 同文件(`provider.rs`),不分散到类型目录。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderCapabilities {
/// 人类可读的 Provider 名(如 `"openai"` / `"anthropic"`)。
pub provider_name: &'static str,
/// 支持的模型列表(`None` 表示"未列举全部")。
pub supported_models: Option<Vec<String>>,
/// 详细功能开关。
pub features: ProviderFeatures,
}
/// Provider 功能开关集合。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProviderFeatures {
/// 是否支持流式响应。
pub streaming: bool,
/// 是否支持 thinking / 推理。
pub thinking: bool,
/// 是否支持图片输入。
pub vision: bool,
/// 是否支持音频输入。
pub audio_input: bool,
/// 是否支持工具调用。
pub tool_use: bool,
/// 是否支持并行工具调用。
pub parallel_tool_calls: bool,
/// system prompt 是否放在 messages 中(`true`)还是顶层 `system` 字段(`false`)。
pub system_prompt_in_messages: bool,
/// 模型上下文窗口(tokens);`0` 表示未知。
pub max_context_window: u32,
}
/// LLM Provider 抽象接口。
///
/// 所有具体的 LLM 后端实现(OpenAI、Anthropic、Azure 等)
/// 所有具体的 LLM 后端实现(OpenAI、Anthropic、DeepSeek、Qwen 等)
/// 均需实现此 trait,以实现可插拔替换。
#[async_trait]
///
/// 修订(Phase 0):签名由 `chat(ChatRequest) → ChatResponse` 切换为
/// `chat(MessageRequest) → MessageResponse``chat_stream` 返回新 `StreamEvent` 流,
/// 新增 `capabilities()` 方法。
#[async_trait::async_trait]
pub trait LlmProvider: Send + Sync {
/// 发送聊天请求并返回完整响应。
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, LlmError>;
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError>;
/// 流式聊天请求 —— 返回原始 SSE chunk 流。
///
/// 默认实现回退到非流式调用(包装为单元素流)。
/// 流式聊天请求 —— 返回新 IR `StreamEvent` 流。
async fn chat_stream(
&self,
request: ChatRequest,
) -> Result<
Pin<Box<dyn Stream<Item = Result<OpenaiChatChunk, LlmError>> + Send>>,
LlmError,
> {
let response = self.chat(request).await?;
let chunk = OpenaiChatChunk::from(response);
Ok(Box::pin(tokio_stream::once(Ok(chunk))))
}
request: MessageRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>;
/// 返回 Provider 静态能力描述。
fn capabilities(&self) -> ProviderCapabilities;
}
File diff suppressed because it is too large Load Diff
+879 -117
View File
File diff suppressed because it is too large Load Diff
+234
View File
@@ -0,0 +1,234 @@
//! DeepSeek / Qwen Provider —— OpenAI-compatible 协议的 newtype 包装。
//!
//! DeepSeek 与 Qwen 都是 OpenAI-compatible(共享 `/v1/chat/completions` 协议),
//! 但二者 base_url 与 Qwen 需要额外请求头(`X-DashScope-SSE: enable`)。
//! 通过 newtype 包装 `GenericOpenaiProvider` 提供:
//! - 独立的 `capabilities().provider_name`
//! - 未来可独立扩展(如 Qwen 的特殊错误映射、DeepSeek 的特殊响应解析)
//!
//! 类型别名方案(`pub type DeepSeekProvider = GenericOpenaiProvider`)被否决:类型别名
//! 无法在编译期区分 DeepSeek vs OpenAI 调用,编译期安全检查失效。
//! (参考 `docs/10b-phase1-provider-adaptation.md` §"OpenAI-compatible 复用策略"
use std::pin::Pin;
use async_trait::async_trait;
use futures_core::Stream;
use super::openai::GenericOpenaiProvider;
use super::ProviderCapabilities;
use crate::llm::error::LlmError;
use crate::llm::types::request_v2::MessageRequest;
use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
use crate::llm::provider::LlmProvider;
// =============================================================================
// DeepSeek
// =============================================================================
pub struct DeepSeekProvider(pub GenericOpenaiProvider);
impl DeepSeekProvider {
pub fn new(base_url: String, api_key: String, model: String) -> Self {
let url = if base_url.is_empty() {
"https://api.deepseek.com".to_string()
} else {
base_url
};
Self(GenericOpenaiProvider::new_with_name(
url,
api_key,
model,
"deepseek",
))
}
}
impl DeepSeekProvider {
/// 测试中(带 mock_client)使用的构造器。
pub fn new_with_client(
base_url: String,
api_key: String,
model: String,
client: reqwest::Client,
) -> Self {
let url = if base_url.is_empty() {
"https://api.deepseek.com".to_string()
} else {
base_url
};
let mut inner = GenericOpenaiProvider::new_with_name(url, api_key, model, "deepseek");
inner.http_client = client;
Self(inner)
}
}
#[async_trait]
impl LlmProvider for DeepSeekProvider {
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError> {
self.0.chat(request).await
}
async fn chat_stream(
&self,
request: MessageRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
{
self.0.chat_stream(request).await
}
fn capabilities(&self) -> ProviderCapabilities {
let mut caps = self.0.capabilities();
caps.provider_name = "deepseek";
caps
}
}
// =============================================================================
// Qwen
// =============================================================================
pub struct QwenProvider(pub GenericOpenaiProvider);
impl QwenProvider {
pub fn new(base_url: String, api_key: String, model: String) -> Self {
let url = if base_url.is_empty() {
"https://dashscope.aliyuncs.com/compatible-mode/v1".to_string()
} else {
base_url
};
// Qwen 兼容模式流式需要 DashScope 特定的 SSE 启用头。
let inner = GenericOpenaiProvider::new_with_name_and_headers(
url,
api_key,
model,
"qwen",
vec![("X-DashScope-SSE".to_string(), "enable".to_string())],
);
Self(inner)
}
/// 测试构造器。
pub fn new_with_client(
base_url: String,
api_key: String,
model: String,
client: reqwest::Client,
) -> Self {
let url = if base_url.is_empty() {
"https://dashscope.aliyuncs.com/compatible-mode/v1".to_string()
} else {
base_url
};
let mut inner = GenericOpenaiProvider::new_with_name_and_headers(
url,
api_key,
model,
"qwen",
vec![("X-DashScope-SSE".to_string(), "enable".to_string())],
);
inner.http_client = client;
Self(inner)
}
}
#[async_trait]
impl LlmProvider for QwenProvider {
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError> {
self.0.chat(request).await
}
async fn chat_stream(
&self,
request: MessageRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
{
self.0.chat_stream(request).await
}
fn capabilities(&self) -> ProviderCapabilities {
let mut caps = self.0.capabilities();
caps.provider_name = "qwen";
caps
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::types::request_v2::MessageRequest;
use crate::llm::types::message::Message as IrMessage;
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn deepseek_chat_basic_text_response() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "ds-1",
"object": "chat.completion",
"created": 1718000000,
"model": "deepseek-chat",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "DeepSeek hi"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}
})))
.mount(&server)
.await;
let provider = DeepSeekProvider::new(
server.uri(),
"sk-test".into(),
"deepseek-chat".into(),
);
let response = provider
.chat(MessageRequest {
model: "deepseek-chat".into(),
messages: vec![IrMessage::user_text("hi")],
..Default::default()
})
.await
.unwrap();
assert_eq!(response.text(), "DeepSeek hi");
assert_eq!(provider.capabilities().provider_name, "deepseek");
}
#[tokio::test]
async fn qwen_chat_basic_text_response() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "qw-1",
"object": "chat.completion",
"created": 1718000000,
"model": "qwen-plus",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "Qwen 你好"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 6, "completion_tokens": 2, "total_tokens": 8}
})))
.mount(&server)
.await;
let provider = QwenProvider::new(server.uri(), "sk-test".into(), "qwen-plus".into());
let response = provider
.chat(MessageRequest {
model: "qwen-plus".into(),
messages: vec![IrMessage::user_text("hi")],
..Default::default()
})
.await
.unwrap();
assert_eq!(response.text(), "Qwen 你好");
assert_eq!(provider.capabilities().provider_name, "qwen");
}
}
+140 -44
View File
@@ -1,4 +1,14 @@
//! 流式事件系统 —— 将 LLM 流式响应解析为语义化事件。
//!
//! Phase 0 修订(参见 `docs/10a-phase0-types-and-trait.md` §"StreamEvent 命名冲突处理"):
//! - 对外暴露的 `StreamEvent` 是高精度 IR 版本(来自 `response_v2::StreamEvent`)。
//! - 旧变体(`AssistantTextDelta` / `ToolExecutionStarted` 等)重命名为 `LegacyStreamEvent`
//! 放在 `crate::llm::types::old_stream` 模块,本文件内部消费。
//! - Phase 1 重写 Provider 时可直接消费新事件流后整体删除 `LegacyStreamEvent` 相关代码。
//!
//! 当前实现:旧的 `parse_chunk_stream` 内部消费 `OpenaiChatChunk`,映射为
//! `LegacyStreamEvent`,再在 `LegacyToIrEventStream` 中映射为新 IR `StreamEvent`
//! 后输出。Phase 1 会重写此层(OpenAI Provider 直接产出新事件流)。
use std::pin::Pin;
use std::task::{Context, Poll};
@@ -9,54 +19,47 @@ use futures_util::FutureExt;
use serde_json::Value;
use crate::llm::error::LlmError;
use crate::llm::types::{FinishReason, OpenaiChatChunk, OpenaiToolCall, Usage};
use crate::llm::types::old_stream::LegacyStreamEvent;
use crate::llm::types::response_v2::MessageResponse;
use crate::llm::types::response_v2::StopReason;
use crate::llm::types::usage::Usage;
use crate::llm::types::{FinishReason, OpenaiChatChunk, OpenaiToolCall};
/// 流式事件 —— LLM 调用全生命周期的语义化事件
#[derive(Debug, Clone)]
pub enum StreamEvent {
/// 助手回复文本增量。
AssistantTextDelta { text: String },
/// 工具调用开始
ToolExecutionStarted {
tool_name: String,
input: Value,
tool_call_id: String,
},
/// 工具调用完成。
ToolExecutionCompleted {
tool_name: String,
output: Value,
is_error: bool,
},
/// Token 用量更新。
CostUpdate { usage: Usage },
/// 一轮会话完成。
TurnComplete { reason: FinishReason },
/// 错误事件。
Error { message: String },
}
// 唯一的对外 `StreamEvent` 定义(高精度 IR 事件,来自 `response_v2`
//
// 此 `pub use` 同时起到两个作用:
// 1. 让 `crate::llm::stream::StreamEvent` 路径仍指向新高精度 IR 事件,
// 保持与既有 `use crate::llm::stream::StreamEvent` 的代码兼容;
// 2. 把模块内部的 `StreamEvent` 名字指向 `response_v2::StreamEvent`
pub use crate::llm::types::response_v2::StreamEvent;
impl StreamEvent {
fn error(message: impl Into<String>) -> Self {
Self::Error {
message: message.into(),
}
}
}
/// 将原始 OpenaiChatChunk 流解析为 StreamEvent 流。
/// 将原始 OpenaiChatChunk 流解析为新高精度 IR StreamEvent 流。
///
/// ponytail: 每个产出事件都用 `Result<_, LlmError>` 包装,让上层 `chat_stream`
/// trait 方法直接消费并保持错误传播链。当前 `LegacyToIrEventStream` 内部
/// 不会产生错误,所有结果都是 `Ok`;后续 Phase 1 重写 Provider 时,
/// 真实 IR 流转换可在此层注入 error 事件。
pub fn parse_chunk_stream(
chunks: Pin<Box<dyn futures_core::Stream<Item = Result<OpenaiChatChunk, LlmError>> + Send>>,
) -> Pin<Box<dyn futures_core::Stream<Item = StreamEvent> + Send>> {
Box::pin(ChunkToEventStream { chunks })
) -> Pin<Box<dyn futures_core::Stream<Item = Result<StreamEvent, LlmError>> + Send>> {
let legacy = parse_chunk_stream_legacy(chunks);
Box::pin(LegacyToIrEventStream { inner: legacy })
}
struct ChunkToEventStream {
// --- 内部:chunk → LegacyStreamEvent ---
fn parse_chunk_stream_legacy(
chunks: Pin<Box<dyn futures_core::Stream<Item = Result<OpenaiChatChunk, LlmError>> + Send>>,
) -> Pin<Box<dyn futures_core::Stream<Item = LegacyStreamEvent> + Send>> {
Box::pin(ChunkToLegacyEventStream { chunks })
}
struct ChunkToLegacyEventStream {
chunks: Pin<Box<dyn futures_core::Stream<Item = Result<OpenaiChatChunk, LlmError>> + Send>>,
}
impl Stream for ChunkToEventStream {
type Item = StreamEvent;
impl Stream for ChunkToLegacyEventStream {
type Item = LegacyStreamEvent;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = &mut *self;
@@ -66,7 +69,7 @@ impl Stream for ChunkToEventStream {
let delta = &choice.delta;
if let Some(content) = &delta.content {
return Poll::Ready(Some(StreamEvent::AssistantTextDelta {
return Poll::Ready(Some(LegacyStreamEvent::AssistantTextDelta {
text: content.clone(),
}));
}
@@ -77,7 +80,7 @@ impl Stream for ChunkToEventStream {
let OpenaiToolCall::Function { id, function } = tc;
let args: Value =
serde_json::from_str(&function.arguments).unwrap_or(Value::Null);
return Poll::Ready(Some(StreamEvent::ToolExecutionStarted {
return Poll::Ready(Some(LegacyStreamEvent::ToolExecutionStarted {
tool_name: function.name.clone(),
input: args,
tool_call_id: id.clone(),
@@ -85,24 +88,117 @@ impl Stream for ChunkToEventStream {
}
if let Some(finish_reason) = &choice.finish_reason {
return Poll::Ready(Some(StreamEvent::TurnComplete {
return Poll::Ready(Some(LegacyStreamEvent::TurnComplete {
reason: *finish_reason,
}));
}
}
if let Some(usage) = &chunk.usage {
return Poll::Ready(Some(StreamEvent::CostUpdate {
return Poll::Ready(Some(LegacyStreamEvent::CostUpdate {
usage: *usage,
}));
}
Poll::Ready(None)
}
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(StreamEvent::error(e.to_string()))),
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(LegacyStreamEvent::error(e.to_string()))),
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
})
.poll_unpin(cx)
}
}
// --- 内部:LegacyStreamEvent → 新 StreamEvent ---
struct LegacyToIrEventStream {
inner: Pin<Box<dyn futures_core::Stream<Item = LegacyStreamEvent> + Send>>,
}
impl Stream for LegacyToIrEventStream {
type Item = Result<StreamEvent, LlmError>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = &mut *self;
match Pin::new(&mut this.inner).poll_next(cx) {
Poll::Ready(Some(legacy)) => Poll::Ready(Some(Ok(map_legacy_to_ir(legacy)))),
Poll::Ready(None) => {
// 旧流结束 → 主动补一个 MessageCompletefull_response 为兜底空快照)。
// ponytail: Phase 0 中 OpenaiProvider 桥接层负责产出真实 MessageResponse
// 此处仅防止消费方无限等待。若 Provider 层已正确发出 MessageComplete
// LlmCycle 不会走到这里 —— 因为桥接层 inline 处理。
Poll::Ready(Some(Ok(StreamEvent::MessageComplete {
full_response: empty_message_response(),
})))
}
Poll::Pending => Poll::Pending,
}
}
}
fn empty_message_response() -> MessageResponse {
use crate::llm::types::message::Message;
use std::collections::HashMap;
MessageResponse {
id: String::new(),
model: String::new(),
message: Message::Assistant {
content: vec![],
},
usage: Usage::default(),
stop_reason: StopReason::Stop,
extra: HashMap::new(),
}
}
/// 把旧 LegacyStreamEvent 映射到新高精度 IR StreamEvent。
///
/// Phase 1 重写 Provider 后可直接删除此映射函数。当前映射语义:
/// - `AssistantTextDelta` → `TextDelta`
/// - `ToolExecutionStarted` → `ToolCallArgumentsDelta`OpenAI 单 chunk 模式下整段 arguments 一次性下发)
/// - `CostUpdate` → `CostUpdate`Usage → PartialUsage 全字段)
/// - `TurnComplete` → `MessageComplete`Phase 1 重写 Provider 后正确产出)
/// - `Error` → `Error`
///
/// ponytail: 这是一个"目前能跑通未来会被删除"的适配层。当前实现为单事件映射,
/// 旧 `ToolExecutionStarted` 携带的 (id, name) 暂未填入 IR 事件(消费方
/// Phase 2 中通过 MessageComplete.full_response.tool_use 提取)。Phase 1 重写时
/// 由 OpenAI Provider 直接产出 IR 流,整体删除此映射。
fn map_legacy_to_ir(legacy: LegacyStreamEvent) -> StreamEvent {
use crate::llm::types::response_v2::PartialUsage;
match legacy {
LegacyStreamEvent::AssistantTextDelta { text } => StreamEvent::TextDelta { text },
LegacyStreamEvent::ToolExecutionStarted { input, .. } => {
let arguments = serde_json::to_string(&input).unwrap_or_default();
StreamEvent::ToolCallArgumentsDelta { index: 0, arguments }
}
LegacyStreamEvent::ToolExecutionCompleted { .. } => {
// 旧 ToolExecutionCompleted 不在 IR 流协议中——工具执行是消费方职责。
// Phase 1 重写时此处整体删除。当前给一个无副作用的占位事件。
StreamEvent::CostUpdate {
usage: PartialUsage::default(),
}
}
LegacyStreamEvent::CostUpdate { usage } => StreamEvent::CostUpdate {
usage: PartialUsage {
prompt_tokens: Some(usage.prompt_tokens),
completion_tokens: Some(usage.completion_tokens),
total_tokens: Some(usage.total_tokens),
completion_tokens_details: usage.completion_tokens_details,
prompt_tokens_details: usage.prompt_tokens_details,
},
},
LegacyStreamEvent::TurnComplete { reason } => {
// 旧 TurnComplete 不直接对应 IR;映射为带 StopReason 的 MessageComplete。
// ponytail: Phase 1 重写 Provider 后此适配整体删除,
// OpenAI Provider 直接产出带正确 stop_reason 的 MessageComplete。
let _ = reason;
StreamEvent::MessageComplete {
full_response: empty_message_response(),
}
}
LegacyStreamEvent::Error { message } => StreamEvent::Error { message },
}
}
+384 -125
View File
@@ -1,165 +1,424 @@
use crate::llm::types::shared::{AudioFormat, ImageDetail};
use crate::llm::types::tool::OpenaiToolCall;
//! IR 层 Message 类型 —— 跨 Provider 统一的消息模型。
//!
//! 设计目标见 `docs/10-llm-provider-refinement.md` §2.1 Decision-01。
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone)]
pub enum ContentField {
String(String),
Array(Vec<OpenaiContentPart>),
use crate::llm::types::shared::AudioFormat;
use crate::llm::types::shared::ImageDetail;
/// 跨 Provider 统一的消息类型(扁平大枚举)。
///
/// 每个变体直接承载完整语义,消费方 match 即可获得所有信息,
/// 无需在嵌套的 `Vec` 中搜索。
///
/// 设计要点:
/// - `Thinking` / `ToolUse` 不作为独立 Message 变体,保留在 `Assistant.content` 中,
/// 因为 text ↔ tool_use 的**交错顺序**是 Assistant 响应的语义组成部分。
/// - `UserImage` 是快捷变体,减少"图片只有 base64 字符串"的 boilerplate
/// 消费方 match 可直接区分文本和图片输入。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Message {
/// 系统提示(User & Assistant 之外的引导指令)。
System {
content: Vec<ContentBlock>,
},
/// 用户输入。
User {
content: Vec<ContentBlock>,
},
/// 用户的图片输入(快捷构造,免去构造 ContentBlock 的 boilerplate)。
UserImage {
data: String,
mime_type: String,
detail: ImageDetail,
},
/// Assistant 回复内容块(可能包含 text、thinking、tool_use 等多种 block 的混合)。
Assistant {
content: Vec<ContentBlock>,
},
/// 工具调用结果。
ToolResult {
tool_call_id: String,
content: Vec<ContentBlock>,
is_error: bool,
},
}
impl<'de> Deserialize<'de> for ContentField {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
match value {
Value::String(s) => Ok(ContentField::String(s)),
Value::Array(arr) => {
let parts: Result<Vec<OpenaiContentPart>, _> =
serde_json::from_value(Value::Array(arr));
match parts {
Ok(parts) => Ok(ContentField::Array(parts)),
Err(e) => Err(serde::de::Error::custom(e)),
}
}
_ => Err(serde::de::Error::custom("content must be string or array")),
impl Message {
/// 构造纯文本 User 消息。
pub fn user_text(text: impl Into<String>) -> Self {
Message::User {
content: vec![ContentBlock::Text { text: text.into() }],
}
}
/// 构造 UserImage 快捷消息(直接传 base64 / URL + MIME)。
pub fn user_image(
data: impl Into<String>,
mime_type: impl Into<String>,
detail: ImageDetail,
) -> Self {
Message::UserImage {
data: data.into(),
mime_type: mime_type.into(),
detail,
}
}
/// 构造纯文本 Assistant 消息。
pub fn assistant(text: impl Into<String>) -> Self {
Message::Assistant {
content: vec![ContentBlock::Text { text: text.into() }],
}
}
/// 构造纯文本 System 消息。
pub fn system(text: impl Into<String>) -> Self {
Message::System {
content: vec![ContentBlock::Text { text: text.into() }],
}
}
/// 构造纯文本 ToolResult 消息。
pub fn tool_result(
tool_call_id: impl Into<String>,
text: impl Into<String>,
is_error: bool,
) -> Self {
Message::ToolResult {
tool_call_id: tool_call_id.into(),
content: vec![ContentBlock::Text { text: text.into() }],
is_error,
}
}
}
impl Serialize for ContentField {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
ContentField::String(s) => s.serialize(serializer),
ContentField::Array(arr) => arr.serialize(serializer),
}
}
/// 内容块 —— 组成消息的最小语义单元。
///
/// 与 9b §3.1 设计保持一致:`Text` / `Image` / `Audio` / `File` / `ToolUse` /
/// `ToolResult` / `Thinking` / `Extension`,其中 `Extension` 作为 Provider 特定
/// block 的逃生舱。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContentBlock {
/// 纯文本。
Text { text: String },
/// 图片(含 URL 或 base64)。
Image { source: ImageSource },
/// 音频。
Audio { source: AudioSource },
/// 通用文件。
File { source: FileSource },
/// 工具调用。
ToolUse {
id: String,
name: String,
input: Value,
},
/// 工具结果(在消息历史中由 Assistant 携带,如 Anthropic 内容回显)。
ToolResult {
tool_use_id: String,
content: Vec<ContentBlock>,
is_error: bool,
},
/// 推理 / 思考内容(可携带 signature 用于多轮续推)。
Thinking {
text: String,
signature: Option<String>,
},
/// 逃生舱:Provider 特定 block 透传(OpenAI Response 内置工具等)。
Extension {
kind: String,
data: Value,
},
}
/// 内容块类型标签 —— 用于 `StreamEvent::ContentBlockStart.block_type`。
///
/// 用途:在流式场景中,Provider 先下发 block 类型,再下发 block 内容增量。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageURL {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<ImageDetail>,
#[serde(rename_all = "snake_case")]
pub enum ContentBlockType {
/// 文本块。
Text,
/// 思考 / 推理块。
Thinking,
/// 拒绝 / 政策阻断块(OpenAI refusal)。
Refusal,
/// 工具调用块(携带 id 和 name)。
ToolUse { id: String, name: String },
}
/// 图片来源(URL 或 base64)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InputAudio {
pub struct ImageSource {
/// URL 或 base64 字符串。
pub data: String,
/// MIME 类型(如 `image/png` / `image/jpeg` / `image/webp`)。
pub mime_type: String,
/// `true` = URL`false` = base64。
pub is_url: bool,
}
/// 音频来源。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioSource {
/// base64 字符串。
pub data: String,
/// 音频格式。
pub format: AudioFormat,
}
/// 文件来源。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileData {
pub file_data: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_id: Option<String>,
pub struct FileSource {
/// 文件内容(base64)或 URL。
pub data: String,
/// 原始文件名。
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
/// MIME 类型。
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum OpenaiContentPart {
Text {
text: String,
},
Image {
image_url: ImageURL,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<ImageDetail>,
},
InputAudio {
input_audio: InputAudio,
},
File {
file: FileData,
},
Refusal {
refusal: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::types::shared::AudioFormat;
use serde_json::json;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "role")]
pub enum OpenaiChatMessage {
Developer {
content: ContentField,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
System {
content: ContentField,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
User {
content: ContentField,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
Assistant {
content: ContentField,
#[serde(skip_serializing_if = "Option::is_none")]
refusal: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_calls: Option<Vec<OpenaiToolCall>>,
},
Tool {
content: ContentField,
tool_call_id: String,
},
Function {
content: ContentField,
name: String,
},
}
impl OpenaiChatMessage {
pub fn user_text<S: Into<String>>(text: S) -> Self {
OpenaiChatMessage::User {
content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]),
name: None,
#[test]
fn message_user_text_produces_text_block() {
let m = Message::user_text("hello");
match m {
Message::User { content } => {
assert_eq!(content.len(), 1);
match &content[0] {
ContentBlock::Text { text } => assert_eq!(text, "hello"),
_ => panic!("expected Text block"),
}
}
_ => panic!("expected User variant"),
}
}
pub fn assistant_text<S: Into<String>>(text: S) -> Self {
OpenaiChatMessage::Assistant {
content: ContentField::String(text.into()),
refusal: None,
name: None,
tool_calls: None,
#[test]
fn message_assistant_text_produces_text_block() {
let m = Message::assistant("hi");
match m {
Message::Assistant { content } => {
assert_eq!(content.len(), 1);
assert!(matches!(&content[0], ContentBlock::Text { text } if text == "hi"));
}
_ => panic!("expected Assistant variant"),
}
}
pub fn system_text<S: Into<String>>(text: S) -> Self {
OpenaiChatMessage::System {
content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]),
name: None,
#[test]
fn message_system_text_produces_text_block() {
let m = Message::system("sys");
match m {
Message::System { content } => {
assert_eq!(content.len(), 1);
assert!(matches!(&content[0], ContentBlock::Text { text } if text == "sys"));
}
_ => panic!("expected System variant"),
}
}
pub fn developer_text<S: Into<String>>(text: S) -> Self {
OpenaiChatMessage::Developer {
content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]),
name: None,
#[test]
fn message_user_image_carries_fields() {
let m = Message::user_image("base64data", "image/png", ImageDetail::High);
match m {
Message::UserImage {
data,
mime_type,
detail,
} => {
assert_eq!(data, "base64data");
assert_eq!(mime_type, "image/png");
assert_eq!(detail, ImageDetail::High);
}
_ => panic!("expected UserImage variant"),
}
}
pub fn tool_result<S: Into<String>>(tool_call_id: String, content: S) -> Self {
OpenaiChatMessage::Tool {
content: ContentField::Array(vec![OpenaiContentPart::Text {
text: content.into(),
}]),
tool_call_id,
#[test]
fn message_tool_result_text_produces_block() {
let m = Message::tool_result("call_1", "result text", false);
match m {
Message::ToolResult {
tool_call_id,
content,
is_error,
} => {
assert_eq!(tool_call_id, "call_1");
assert!(!is_error);
assert_eq!(content.len(), 1);
match &content[0] {
ContentBlock::Text { text } => assert_eq!(text, "result text"),
_ => panic!("expected Text block"),
}
}
_ => panic!("expected ToolResult variant"),
}
}
#[test]
fn message_exhaustive_match() {
// 编译器保证穷举;这里写一个 match 验证所有变体可被访问。
let msgs = vec![
Message::user_text("a"),
Message::user_image("d", "image/png", ImageDetail::Auto),
Message::assistant("b"),
Message::system("c"),
Message::tool_result("id", "r", false),
];
for m in &msgs {
let s = match m {
Message::System { .. } => "sys",
Message::User { .. } => "user",
Message::UserImage { .. } => "image",
Message::Assistant { .. } => "asst",
Message::ToolResult { .. } => "tool",
};
assert!(!s.is_empty());
}
}
#[test]
fn content_block_roundtrip_each_variant() {
let blocks = vec![
ContentBlock::Text {
text: "hello".into(),
},
ContentBlock::Image {
source: ImageSource {
data: "b64".into(),
mime_type: "image/png".into(),
is_url: false,
},
},
ContentBlock::Audio {
source: AudioSource {
data: "audio_b64".into(),
format: AudioFormat::Mp3,
},
},
ContentBlock::File {
source: FileSource {
data: "file_data".into(),
filename: Some("doc.pdf".into()),
mime_type: Some("application/pdf".into()),
},
},
ContentBlock::ToolUse {
id: "call_1".into(),
name: "search".into(),
input: json!({"q": "rust"}),
},
ContentBlock::ToolResult {
tool_use_id: "call_1".into(),
content: vec![ContentBlock::Text {
text: "found".into(),
}],
is_error: false,
},
ContentBlock::Thinking {
text: "thinking...".into(),
signature: Some("sig".into()),
},
ContentBlock::Extension {
kind: "openai.response.web_search_call".into(),
data: json!({"query": "rust"}),
},
];
for original in blocks {
let json = serde_json::to_string(&original).expect("serialize");
let decoded: ContentBlock = serde_json::from_str(&json).expect("deserialize");
assert_eq!(format!("{:?}", original), format!("{:?}", decoded));
}
}
#[test]
fn message_roundtrip_each_variant() {
let msgs = vec![
Message::System {
content: vec![ContentBlock::Text {
text: "sys".into(),
}],
},
Message::User {
content: vec![ContentBlock::Text {
text: "hello".into(),
}],
},
Message::UserImage {
data: "b64".into(),
mime_type: "image/png".into(),
detail: ImageDetail::High,
},
Message::Assistant {
content: vec![
ContentBlock::Thinking {
text: "reasoning".into(),
signature: None,
},
ContentBlock::Text {
text: "answer".into(),
},
],
},
Message::ToolResult {
tool_call_id: "call_1".into(),
content: vec![ContentBlock::Text {
text: "ok".into(),
}],
is_error: true,
},
];
for original in msgs {
let json = serde_json::to_string(&original).expect("serialize");
let decoded: Message = serde_json::from_str(&json).expect("deserialize");
assert_eq!(format!("{:?}", original), format!("{:?}", decoded));
}
}
#[test]
fn content_block_tool_result_nested_roundtrip() {
let block = ContentBlock::ToolResult {
tool_use_id: "call_1".into(),
content: vec![ContentBlock::Text {
text: "result".into(),
}],
is_error: false,
};
let json = serde_json::to_string(&block).unwrap();
let decoded: ContentBlock = serde_json::from_str(&json).unwrap();
assert_eq!(format!("{:?}", block), format!("{:?}", decoded));
}
#[test]
fn content_block_type_roundtrip() {
let types = vec![
ContentBlockType::Text,
ContentBlockType::Thinking,
ContentBlockType::Refusal,
ContentBlockType::ToolUse {
id: "call_x".into(),
name: "fn".into(),
},
];
for original in types {
let json = serde_json::to_string(&original).unwrap();
let decoded: ContentBlockType = serde_json::from_str(&json).unwrap();
assert_eq!(format!("{:?}", original), format!("{:?}", decoded));
}
}
}
+29 -9
View File
@@ -1,18 +1,27 @@
pub mod message;
pub mod old_stream;
pub mod openai_message;
pub mod request;
pub mod request_v2;
pub mod response;
pub mod response_v2;
pub mod shared;
pub mod tool;
pub mod usage;
pub use message::{
pub use openai_message::{
ContentField, FileData, ImageURL, InputAudio, OpenaiChatMessage, OpenaiContentPart,
};
pub use request::{OpenaiChatRequest, OpenaiTool, StreamOptions, ToolChoice};
pub use request_v2::{ExtraError, MessageRequest, ThinkingConfig};
pub use response::{
Annotation, Choice, ChunkChoice, Delta, Logprobs, OpenaiAudio, OpenaiChatChunk,
OpenaiChatResponse, TokenLogprob, TopLogprob, URLCitation,
};
pub use response_v2::{
ContentBlockBuilder, MessageResponse, PartialMessageResponse, PartialUsage, StopReason,
StreamEvent,
};
pub use shared::{
AudioFormat, FinishReason, ImageDetail, Modality, ResponseFormat, Role, ServiceTier,
StopSequence,
@@ -20,6 +29,17 @@ pub use shared::{
pub use tool::{FunctionCall, OpenaiToolCall, OpenaiToolDefinition};
pub use usage::{CompletionTokensDetails, CostTracker, PromptTokensDetails, Usage};
// Re-export IR 内容块 / 消息类型供 `types::ContentBlock` 等历史路径消费。
//
// 注意:以下别名 *故意不暴露* `pub type Message = message::Message`、
// `pub type ContentBlock = message::ContentBlock` —— 新 `Message` / `ContentBlock` /
// `StopReason` 是独立类型,由 `Message` / `ContentBlock` / `StopReason` 直接路径访问,
// 旧别名(指 `OpenaiChatMessage` / `OpenaiContentPart` / `FinishReason`)已移除,
// 避免新类型阴影。Phase 2 完成后再统一收敛。
//
// Phase 1 起移除 `ChatRequest` 别名 —— 新代码统一使用 `MessageRequest`v2 IR)。
// `ChatResponse` 结构体仍存在,作为 OpenAI `chat_inner()` 内部 wire-format 转换目标。
/// 旧 wire-format 响应结构(保留用于 OpenAI 内部转换层)。
#[derive(Debug, Clone)]
pub struct ChatResponse {
pub message: OpenaiChatMessage,
@@ -54,10 +74,13 @@ impl From<ChatResponse> for OpenaiChatChunk {
};
OpenaiChatChunk {
id: format!("chunk-{}", std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)),
id: format!(
"chunk-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
),
object: "chat.completion.chunk".to_string(),
created: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -71,8 +94,5 @@ impl From<ChatResponse> for OpenaiChatChunk {
}
}
pub type ChatRequest = OpenaiChatRequest;
pub type Message = OpenaiChatMessage;
pub type ContentBlock = OpenaiContentPart;
/// 工具定义别名(无新类型冲突,保留)。
pub type ToolDefinition = OpenaiToolDefinition;
pub type StopReason = FinishReason;
+45
View File
@@ -0,0 +1,45 @@
//! 旧版流式事件 —— Phase 0 临时保留,仅供 `stream.rs` 中 `parse_chunk_stream` 内部使用。
//!
//! Phase 0 中:高精度 `StreamEvent`(定义在 `response_v2.rs`)是唯一的对外
//! `StreamEvent`,旧变体迁移至此模块改名为 `LegacyStreamEvent`
//! 由 `parse_chunk_stream()` 内部消费 `LegacyStreamEvent`,对外返回值已被
//! 重映射为新 `StreamEvent`。
//!
//! Phase 1 重写 Provider 时,`parse_chunk_stream` 可直接消费新事件流后整体删除此文件。
use crate::llm::types::shared::FinishReason;
use crate::llm::types::usage::Usage;
use serde_json::Value;
/// 旧 `StreamEvent` 变体迁移后的别名 —— 仅供 `stream.rs` 内部使用。
#[derive(Debug, Clone)]
pub enum LegacyStreamEvent {
/// 助手回复文本增量。
AssistantTextDelta { text: String },
/// 工具调用开始。
ToolExecutionStarted {
tool_name: String,
input: Value,
tool_call_id: String,
},
/// 工具调用完成。
ToolExecutionCompleted {
tool_name: String,
output: Value,
is_error: bool,
},
/// Token 用量更新。
CostUpdate { usage: Usage },
/// 一轮会话完成。
TurnComplete { reason: FinishReason },
/// 错误事件。
Error { message: String },
}
impl LegacyStreamEvent {
pub(crate) fn error(message: impl Into<String>) -> Self {
Self::Error {
message: message.into(),
}
}
}
+165
View File
@@ -0,0 +1,165 @@
use crate::llm::types::shared::{AudioFormat, ImageDetail};
use crate::llm::types::tool::OpenaiToolCall;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone)]
pub enum ContentField {
String(String),
Array(Vec<OpenaiContentPart>),
}
impl<'de> Deserialize<'de> for ContentField {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
match value {
Value::String(s) => Ok(ContentField::String(s)),
Value::Array(arr) => {
let parts: Result<Vec<OpenaiContentPart>, _> =
serde_json::from_value(Value::Array(arr));
match parts {
Ok(parts) => Ok(ContentField::Array(parts)),
Err(e) => Err(serde::de::Error::custom(e)),
}
}
_ => Err(serde::de::Error::custom("content must be string or array")),
}
}
}
impl Serialize for ContentField {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
ContentField::String(s) => s.serialize(serializer),
ContentField::Array(arr) => arr.serialize(serializer),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageURL {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<ImageDetail>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InputAudio {
pub data: String,
pub format: AudioFormat,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileData {
pub file_data: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum OpenaiContentPart {
Text {
text: String,
},
Image {
image_url: ImageURL,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<ImageDetail>,
},
InputAudio {
input_audio: InputAudio,
},
File {
file: FileData,
},
Refusal {
refusal: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "role")]
pub enum OpenaiChatMessage {
Developer {
content: ContentField,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
System {
content: ContentField,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
User {
content: ContentField,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
Assistant {
content: ContentField,
#[serde(skip_serializing_if = "Option::is_none")]
refusal: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_calls: Option<Vec<OpenaiToolCall>>,
},
Tool {
content: ContentField,
tool_call_id: String,
},
Function {
content: ContentField,
name: String,
},
}
impl OpenaiChatMessage {
pub fn user_text<S: Into<String>>(text: S) -> Self {
OpenaiChatMessage::User {
content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]),
name: None,
}
}
pub fn assistant_text<S: Into<String>>(text: S) -> Self {
OpenaiChatMessage::Assistant {
content: ContentField::String(text.into()),
refusal: None,
name: None,
tool_calls: None,
}
}
pub fn system_text<S: Into<String>>(text: S) -> Self {
OpenaiChatMessage::System {
content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]),
name: None,
}
}
pub fn developer_text<S: Into<String>>(text: S) -> Self {
OpenaiChatMessage::Developer {
content: ContentField::Array(vec![OpenaiContentPart::Text { text: text.into() }]),
name: None,
}
}
pub fn tool_result<S: Into<String>>(tool_call_id: String, content: S) -> Self {
OpenaiChatMessage::Tool {
content: ContentField::Array(vec![OpenaiContentPart::Text {
text: content.into(),
}]),
tool_call_id,
}
}
}
+8 -1
View File
@@ -20,6 +20,13 @@ pub enum ToolChoice {
AllowedTools { tool_names: Vec<String> },
}
impl Default for ToolChoice {
fn default() -> Self {
// Default 行为由调用方显式选择;此处选择不暴露任何工具 —— 不暴露比错暴露安全。
ToolChoice::None
}
}
impl Serialize for ToolChoice {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
@@ -139,7 +146,7 @@ pub struct WebSearchOptions {
#[serde(rename_all = "snake_case")]
pub struct OpenaiChatRequest {
pub model: String,
pub messages: Vec<crate::llm::types::message::OpenaiChatMessage>,
pub messages: Vec<crate::llm::types::openai_message::OpenaiChatMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frequency_penalty: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
+209
View File
@@ -0,0 +1,209 @@
//! IR 层 MessageRequest 类型 —— 跨 Provider 统一的请求模型。
//!
//! 设计目标见 `docs/10-llm-provider-refinement.md` §2.2 Decision-02 + `docs/9b-ir-type-system.md` §3.6。
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
use crate::llm::types::message::Message;
use crate::llm::types::request::ToolChoice;
use crate::llm::types::tool::OpenaiToolDefinition;
/// Provider 无关的请求类型。
///
/// 设计要点:
/// - `system` 字段不存在;system 提示由调用方通过 `Message::System` 在 `messages` 中表达。
/// - `tools` / `tool_choice` 直接复用现有 `OpenaiToolDefinition` / `ToolChoice`
/// (10a §251 决策:先复用旧类型,Phase 2 切换为新 `ToolDefinition` 后再调整)。
/// - `extra` 作为逃生舱:Provider 特定字段(`web_search_options`、`previous_response_id` 等)
/// 通过 `extra.set_extra / get_extra` 传递,避免持续膨胀本结构体。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MessageRequest {
/// 模型名称。
pub model: String,
/// 消息列表(包含 system / user / assistant / tool_result 等所有变体)。
pub messages: Vec<Message>,
/// 工具定义列表。
pub tools: Vec<OpenaiToolDefinition>,
/// 工具选择策略。
pub tool_choice: ToolChoice,
/// 最大输出 token 数。
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
/// 采样温度。
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
/// nucleus 采样 top-p。
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
/// 终止序列。
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stop_sequences: Vec<String>,
/// 是否流式响应(Provider 实现层读取)。
#[serde(default)]
pub stream: bool,
/// 思考 / 推理配置(如 Anthropic thinking budget)。
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking: Option<ThinkingConfig>,
/// Provider 特定扩展字段。
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub extra: HashMap<String, Value>,
}
/// 思考 / 推理配置。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThinkingConfig {
/// 思考预算 token 数。
pub budget_tokens: u32,
}
/// `extra` 字段访问错误。
#[derive(Error, Debug)]
pub enum ExtraError {
/// 反序列化到目标类型失败。
#[error("extra 字段 `{key}` 反序列化失败: {details}")]
TypeMismatch { key: String, details: String },
/// 字段值反序列化为 JSON 失败。
#[error("extra 反序列化失败: {0}")]
Deserialize(String),
}
impl MessageRequest {
/// 读取一个 `extra` 字段,缺失返回 `Ok(None)`,类型不匹配返回 `Err(ExtraError::TypeMismatch)`。
pub fn get_extra<T: for<'de> Deserialize<'de>>(
&self,
key: &str,
) -> Result<Option<T>, ExtraError> {
match self.extra.get(key) {
None => Ok(None),
Some(value) => serde_json::from_value(value.clone())
.map(Some)
.map_err(|e| ExtraError::TypeMismatch {
key: key.to_string(),
details: e.to_string(),
}),
}
}
/// 读取 `extra` 字段但用 `Option<T>`,跳过错误(缺失或反序列化失败均返回 `None`)。
pub fn get_extra_opt<T: for<'de> Deserialize<'de>>(&self, key: &str) -> Option<T> {
self.get_extra::<T>(key).ok().flatten()
}
/// 把整个 `extra` 整体反序列化为类型 `T`(用于"我有一个完整 options 结构"的场景)。
pub fn get_extra_as<T: for<'de> Deserialize<'de>>(&self) -> Result<T, ExtraError> {
let value = Value::Object(
self.extra
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect::<serde_json::Map<String, Value>>(),
);
serde_json::from_value(value).map_err(|e| ExtraError::Deserialize(e.to_string()))
}
/// 设置一个 `extra` 字段。
pub fn set_extra(&mut self, key: impl Into<String>, value: impl Into<Value>) {
self.extra.insert(key.into(), value.into());
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn message_request_default_is_constructible() {
let req = MessageRequest::default();
assert_eq!(req.model, "");
assert!(req.messages.is_empty());
assert!(!req.stream);
assert!(req.max_tokens.is_none());
}
#[test]
fn extra_set_and_get_roundtrip() {
let mut req = MessageRequest::default();
req.set_extra(
"previous_response_id",
"resp_abc123",
);
let v: Option<String> = req
.get_extra("previous_response_id")
.expect("get_extra ok");
assert_eq!(v.as_deref(), Some("resp_abc123"));
let missing: Option<String> = req.get_extra("missing").expect("missing ok");
assert!(missing.is_none());
}
#[test]
fn extra_type_mismatch_returns_err() {
let mut req = MessageRequest::default();
req.set_extra("count", "not a number");
let result: Result<Option<u32>, ExtraError> = req.get_extra("count");
assert!(matches!(result, Err(ExtraError::TypeMismatch { .. })));
}
#[test]
fn extra_opt_swallows_errors() {
let mut req = MessageRequest::default();
req.set_extra("count", "not a number");
let v: Option<u32> = req.get_extra_opt("count");
assert!(v.is_none());
}
#[test]
fn get_extra_as_deserializes_whole_extra() {
let mut req = MessageRequest::default();
req.set_extra("web_search_options", json!({"search_context_size": "high"}));
req.set_extra("user", json!("u_123"));
#[derive(Deserialize, Debug, PartialEq)]
struct Options {
web_search_options: WebSearch,
user: Option<String>,
}
#[derive(Deserialize, Debug, PartialEq)]
struct WebSearch {
search_context_size: String,
}
let opts: Options = req.get_extra_as().expect("get_extra_as ok");
assert_eq!(
opts.web_search_options.search_context_size,
"high"
);
assert_eq!(opts.user.as_deref(), Some("u_123"));
}
#[test]
fn message_request_json_roundtrip() {
let mut req = MessageRequest {
model: "gpt-4o".into(),
messages: vec![Message::user_text("hello"), Message::assistant("hi")],
tools: vec![],
tool_choice: ToolChoice::Auto,
max_tokens: Some(1024),
temperature: Some(0.7),
top_p: Some(0.9),
stop_sequences: vec!["STOP".into()],
stream: true,
thinking: None,
extra: HashMap::new(),
};
req.set_extra("trace_id", json!("t-1"));
let json = serde_json::to_string(&req).expect("serialize");
let decoded: MessageRequest = serde_json::from_str(&json).expect("deserialize");
assert_eq!(decoded.model, req.model);
assert_eq!(decoded.messages.len(), req.messages.len());
assert_eq!(decoded.max_tokens, req.max_tokens);
assert_eq!(decoded.stream, req.stream);
assert_eq!(decoded.extra.get("trace_id"), Some(&json!("t-1")));
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
use crate::llm::types::message::OpenaiChatMessage;
use crate::llm::types::openai_message::OpenaiChatMessage;
use crate::llm::types::shared::{FinishReason, ServiceTier};
use crate::llm::types::tool::OpenaiToolCall;
use crate::llm::types::usage::Usage;
+869
View File
@@ -0,0 +1,869 @@
//! IR 层 MessageResponse / StreamEvent / PartialMessageResponse —— 流式响应模型。
//!
//! 设计目标见 `docs/10-llm-provider-refinement.md` §2.3 Decision-03 +
//! `docs/9c-llm-provider-trait.md` §4.3 / §4.4。
use std::collections::{BTreeMap, HashMap, HashSet};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::llm::error::LlmError;
use crate::llm::types::message::{ContentBlock, ContentBlockType, Message};
use crate::llm::types::shared::FinishReason;
use crate::llm::types::usage::{CompletionTokensDetails, PromptTokensDetails, Usage};
/// Provider 终止原因(统一枚举,对应各 Provider 的 finish_reason)。
///
/// 注意:与现有 `FinishReason` 在 `openai_message::shared` 模块中并存,
/// Phase 2 完成时统一收敛。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StopReason {
/// 自然停止。
Stop,
/// 达到长度上限。
Length,
/// 触发了工具调用。
ToolUse,
/// 内容安全过滤。
ContentFilter,
/// 达到 max_tokens 限制。
MaxTokens,
/// 命中停止序列。
StopSequence,
/// 其他 / 未知。
Other,
}
impl From<FinishReason> for StopReason {
fn from(r: FinishReason) -> Self {
match r {
FinishReason::Stop => StopReason::Stop,
FinishReason::Length => StopReason::Length,
FinishReason::ToolCalls => StopReason::ToolUse,
FinishReason::ContentFilter => StopReason::ContentFilter,
FinishReason::FunctionCall => StopReason::ToolUse,
FinishReason::Other => StopReason::Other,
}
}
}
/// Provider 完整响应。
///
/// `extra` 作为 Provider 特定字段(`system_fingerprint`、`service_tier` 等)逃生舱。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageResponse {
/// 响应唯一 ID(如 OpenAI 的 `chatcmpl-xxx`)。
pub id: String,
/// 实际使用的模型名。
pub model: String,
/// Assistant 回复的消息。
pub message: Message,
/// Token 用量。
pub usage: Usage,
/// 终止原因。
pub stop_reason: StopReason,
/// Provider 特定扩展字段。
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub extra: HashMap<String, Value>,
}
impl MessageResponse {
/// 提取 Assistant 回复中的全部纯文本(按出现顺序拼接)。
///
/// 仅在 `message` 是 `Assistant` 时有意义,其它变体返回空串。
pub fn text(&self) -> String {
match &self.message {
Message::Assistant { content } => content
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect(),
_ => String::new(),
}
}
}
/// 流式用量 —— 字段为 `Option` 是因为流式场景中各字段可能分多次到达
///(如 Anthropic 的 `message_delta` 事件可多次下发 usage 增量)。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PartialUsage {
/// 输入 token 数。
pub prompt_tokens: Option<u32>,
/// 输出 token 数。
pub completion_tokens: Option<u32>,
/// 总 token 数。
pub total_tokens: Option<u32>,
/// 输出 token 明细(含 reasoning_tokens 等)。
pub completion_tokens_details: Option<CompletionTokensDetails>,
/// 输入 token 明细(含 cached_tokens 等)。
pub prompt_tokens_details: Option<PromptTokensDetails>,
}
impl PartialUsage {
/// 字段级合并 `other` 到 `self``other` 中 `Some` 字段覆盖 `self`。
pub fn merge_from(&mut self, other: &PartialUsage) {
if let Some(v) = other.prompt_tokens {
self.prompt_tokens = Some(v);
}
if let Some(v) = other.completion_tokens {
self.completion_tokens = Some(v);
}
if let Some(v) = other.total_tokens {
self.total_tokens = Some(v);
}
if let Some(v) = other.completion_tokens_details {
self.completion_tokens_details = Some(v);
}
if let Some(v) = other.prompt_tokens_details {
self.prompt_tokens_details = Some(v);
}
}
/// 转换为完整的 `Usage`,缺失字段默认为 0。
pub fn into_usage(self) -> Usage {
Usage {
prompt_tokens: self.prompt_tokens.unwrap_or(0),
completion_tokens: self.completion_tokens.unwrap_or(0),
total_tokens: self.total_tokens.unwrap_or(0),
completion_tokens_details: self.completion_tokens_details,
prompt_tokens_details: self.prompt_tokens_details,
}
}
}
/// 内容块构建器 —— 在流式累积阶段持有单 block 的原始拼接状态。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContentBlockBuilder {
/// 文本块(按增量拼接)。
Text(String),
/// 思考块(拼接中可携带 signature)。
Thinking {
buffer: String,
signature: Option<String>,
},
/// 拒绝 / 政策阻断(OpenAI refusal)。
Refusal(String),
/// 工具调用块(id / name 来自 `ContentBlockStart`arguments 按增量拼接)。
ToolUse {
id: String,
name: String,
arguments: String,
},
}
/// 流式事件 —— LLM 流式响应的语义化事件。
///
/// 设计变更(参考 `docs/10-llm-provider-refinement.md` §2.3):
/// `MessageComplete` 仅携带 `full_response: MessageResponse``stop_reason` 和
/// `thinking_signature` 不再作为顶层冗余字段。Provider 在内部维护
/// `thinking_signature`,最终通过 `finalize()` 回填到 `full_response` 的 `Thinking` block 中。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StreamEvent {
/// 消息开始(元信息)。
MessageStart { id: String, model: String },
/// 内容块开始(告知块类型,携带 id/name for ToolUse)。
ContentBlockStart { index: u32, block_type: ContentBlockType },
/// 内容块结束标记。
ContentBlockEnd { index: u32 },
/// 文本增量。
TextDelta { text: String },
/// 思考 / 推理增量。
ThinkingDelta { text: String },
/// 拒绝 / 政策阻断增量。
RefusalDelta { text: String },
/// 工具调用参数增量(按 index 区分并行调用)。
ToolCallArgumentsDelta { index: u32, arguments: String },
/// 工具调用结束标记。
ToolCallEnd { index: u32 },
/// 用量更新(字段级合并)。
CostUpdate { usage: PartialUsage },
/// 消息完成 —— 唯一可靠的完整响应来源。
MessageComplete { full_response: MessageResponse },
/// 错误事件。
Error { message: String },
}
/// 流式响应累积状态。
///
/// Provider 的流处理循环逐个消费 `StreamEvent`、调用 `apply_to` 更新此结构,
/// 在收到 `MessageComplete` 后调用 `finalize()` 得到完整 `MessageResponse`。
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct PartialMessageResponse {
/// 响应 ID(来自 `MessageStart`)。
pub id: Option<String>,
/// 模型名(来自 `MessageStart`)。
pub model: Option<String>,
/// 按 index 索引的内容块构建器。
pub blocks: BTreeMap<u32, ContentBlockBuilder>,
/// 已标记"完成"(收到 `ContentBlockEnd` 或 `ToolCallEnd`)的 block index。
pub block_completion: HashSet<u32>,
/// 当前最后一个未结束的 block index(用于 TextDelta / ThinkingDelta 等定位目标 block)。
pub last_open_index: Option<u32>,
/// 流式累积用量。
pub usage: PartialUsage,
/// 终止原因(从 Provider 的终止事件推断)。
pub stop_reason: Option<StopReason>,
/// 思考签名(Anthropic provider 直接通过 `set_thinking_signature` 设置,不经过事件层)。
pub thinking_signature: Option<String>,
/// 是否遇到错误事件。
pub is_errored: bool,
/// 是否收到完成事件。
pub is_complete: bool,
}
// ponytail: Provider 可能在 `finalize()` 时希望保留 partial 快照(例如 chat_stream
// 流关闭时需要把最终构造结果暴露为 `MessageComplete.full_response`,但同时 partial
// 仍要为消费方后续 apply 提供访问)。当前所有内部字段派生 `Clone` —— 单独 derive 即可。
impl Clone for PartialMessageResponse {
fn clone(&self) -> Self {
Self {
id: self.id.clone(),
model: self.model.clone(),
blocks: self.blocks.clone(),
block_completion: self.block_completion.clone(),
last_open_index: self.last_open_index,
usage: self.usage.clone(),
stop_reason: self.stop_reason,
thinking_signature: self.thinking_signature.clone(),
is_errored: self.is_errored,
is_complete: self.is_complete,
}
}
}
impl PartialMessageResponse {
/// 创建一个新的空累积状态。
pub fn new() -> Self {
Self::default()
}
/// 由 Provider 直接写入 Anthropic 的 thinking signature。
///
/// 详见 `docs/10-llm-provider-refinement.md` §2.3 — `thinking_signature`
/// 不经过事件层,由 Provider 流循环直接写入此内部状态。
pub fn set_thinking_signature(&mut self, signature: impl Into<String>) {
self.thinking_signature = Some(signature.into());
}
/// 应用一个流事件到累积状态。
///
/// 返回 `false` 表示遇到 `Error` 事件,调用方应终止流处理。
pub fn apply_to(&mut self, event: &StreamEvent) -> bool {
match event {
StreamEvent::MessageStart { id, model } => {
self.id = Some(id.clone());
self.model = Some(model.clone());
true
}
StreamEvent::ContentBlockStart { index, block_type } => {
let builder = match block_type {
ContentBlockType::Text => ContentBlockBuilder::Text(String::new()),
ContentBlockType::Thinking => ContentBlockBuilder::Thinking {
buffer: String::new(),
signature: None,
},
ContentBlockType::Refusal => ContentBlockBuilder::Refusal(String::new()),
ContentBlockType::ToolUse { id, name } => ContentBlockBuilder::ToolUse {
id: id.clone(),
name: name.clone(),
arguments: String::new(),
},
};
self.blocks.insert(*index, builder);
self.last_open_index = Some(*index);
true
}
StreamEvent::ContentBlockEnd { index } => {
self.block_completion.insert(*index);
if self.last_open_index == Some(*index) {
self.last_open_index = None;
}
true
}
StreamEvent::TextDelta { text } => {
if let Some(idx) = self.last_open_index
&& let Some(builder) = self.blocks.get_mut(&idx)
{
match builder {
ContentBlockBuilder::Text(buf) => buf.push_str(text),
ContentBlockBuilder::Thinking { buffer, .. } => buffer.push_str(text),
ContentBlockBuilder::Refusal(buf) => buf.push_str(text),
ContentBlockBuilder::ToolUse { arguments, .. } => {
arguments.push_str(text);
}
}
}
true
}
StreamEvent::ThinkingDelta { text } => {
if let Some(idx) = self.last_open_index
&& let Some(builder) = self.blocks.get_mut(&idx)
&& let ContentBlockBuilder::Thinking { buffer, .. } = builder
{
buffer.push_str(text);
}
true
}
StreamEvent::RefusalDelta { text } => {
if let Some(idx) = self.last_open_index
&& let Some(builder) = self.blocks.get_mut(&idx)
&& let ContentBlockBuilder::Refusal(buf) = builder
{
buf.push_str(text);
}
true
}
StreamEvent::ToolCallArgumentsDelta { index, arguments } => {
if let Some(ContentBlockBuilder::ToolUse {
arguments: buf, ..
}) = self.blocks.get_mut(index)
{
buf.push_str(arguments);
}
true
}
StreamEvent::ToolCallEnd { index } => {
self.block_completion.insert(*index);
if self.last_open_index == Some(*index) {
self.last_open_index = None;
}
true
}
StreamEvent::CostUpdate { usage } => {
self.usage.merge_from(usage);
true
}
StreamEvent::MessageComplete { .. } => {
self.is_complete = true;
true
}
StreamEvent::Error { message } => {
tracing::warn!(error = %message, "partial response received Error event");
self.is_errored = true;
false
}
}
}
/// 完成累积,构造完整 `MessageResponse`。
///
/// - 按 index 升序遍历 `blocks` 转换为 `ContentBlock`
/// - 缺失字段用 `self.thinking_signature` 回填
/// - 终止原因默认 `Stop`,除非上层显式设置
pub fn finalize(self) -> Result<MessageResponse, LlmError> {
let mut content_blocks = Vec::with_capacity(self.blocks.len());
for (idx, builder) in self.blocks {
let block = Self::builder_to_block(idx, builder, self.thinking_signature.as_deref())
.map_err(|e| LlmError::Other(format!(
"partial 块 #{idx} finalize 失败: {e}"
)))?;
content_blocks.push(block);
}
let usage = self.usage.into_usage();
let stop_reason = self.stop_reason.unwrap_or(StopReason::Stop);
let message = Message::Assistant {
content: content_blocks,
};
Ok(MessageResponse {
id: self.id.unwrap_or_default(),
model: self.model.unwrap_or_default(),
message,
usage,
stop_reason,
extra: HashMap::new(),
})
}
fn builder_to_block(
_idx: u32,
builder: ContentBlockBuilder,
fallback_signature: Option<&str>,
) -> Result<ContentBlock, String> {
match builder {
ContentBlockBuilder::Text(buf) => Ok(ContentBlock::Text { text: buf }),
ContentBlockBuilder::Thinking { buffer, signature } => {
let signature = signature.or_else(|| fallback_signature.map(str::to_string));
Ok(ContentBlock::Thinking {
text: buffer,
signature,
})
}
ContentBlockBuilder::Refusal(buf) => Ok(ContentBlock::Text { text: buf }),
ContentBlockBuilder::ToolUse {
id,
name,
arguments,
} => {
let input: Value = serde_json::from_str(&arguments).unwrap_or(Value::Null);
Ok(ContentBlock::ToolUse { id, name, input })
}
}
.map_err(|_: std::convert::Infallible| unreachable!("builder_to_block is total"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn empty_response() -> MessageResponse {
MessageResponse {
id: "resp-1".into(),
model: "m".into(),
message: Message::Assistant { content: vec![] },
usage: Usage::default(),
stop_reason: StopReason::Stop,
extra: HashMap::new(),
}
}
#[test]
fn stop_reason_from_finish_reason_mapping() {
assert_eq!(StopReason::from(FinishReason::Stop), StopReason::Stop);
assert_eq!(
StopReason::from(FinishReason::ToolCalls),
StopReason::ToolUse
);
assert_eq!(
StopReason::from(FinishReason::FunctionCall),
StopReason::ToolUse
);
assert_eq!(StopReason::from(FinishReason::Length), StopReason::Length);
assert_eq!(StopReason::from(FinishReason::Other), StopReason::Other);
}
#[test]
fn message_response_text_extracts_assistant_text() {
let resp = MessageResponse {
id: "r".into(),
model: "m".into(),
message: Message::Assistant {
content: vec![
ContentBlock::Thinking {
text: "reasoning".into(),
signature: None,
},
ContentBlock::Text {
text: "hello ".into(),
},
ContentBlock::Text {
text: "world".into(),
},
ContentBlock::ToolUse {
id: "c".into(),
name: "search".into(),
input: json!({}),
},
],
},
usage: Usage::default(),
stop_reason: StopReason::ToolUse,
extra: HashMap::new(),
};
assert_eq!(resp.text(), "hello world");
}
#[test]
fn message_response_text_empty_when_not_assistant() {
let resp = MessageResponse {
id: "r".into(),
model: "m".into(),
message: Message::user_text("hi"),
usage: Usage::default(),
stop_reason: StopReason::Stop,
extra: HashMap::new(),
};
assert_eq!(resp.text(), "");
}
#[test]
fn partial_usage_merge_picks_some_fields() {
let mut a = PartialUsage {
prompt_tokens: Some(10),
completion_tokens: None,
..Default::default()
};
let b = PartialUsage {
prompt_tokens: Some(99),
completion_tokens: Some(20),
total_tokens: Some(30),
..Default::default()
};
a.merge_from(&b);
assert_eq!(a.prompt_tokens, Some(99));
assert_eq!(a.completion_tokens, Some(20));
assert_eq!(a.total_tokens, Some(30));
}
#[test]
fn partial_usage_into_usage_defaults_missing() {
let u = PartialUsage {
completion_tokens: Some(7),
..Default::default()
}
.into_usage();
assert_eq!(u.prompt_tokens, 0);
assert_eq!(u.completion_tokens, 7);
assert_eq!(u.total_tokens, 0);
}
// ===== apply_to / finalize 整合测试 =====
fn apply_seq(state: &mut PartialMessageResponse, events: &[StreamEvent]) -> bool {
let mut cont = true;
for ev in events {
cont = state.apply_to(ev);
if !cont {
break;
}
}
cont
}
#[test]
fn apply_to_message_start_then_finalize() {
let mut state = PartialMessageResponse::new();
assert!(apply_seq(
&mut state,
&[StreamEvent::MessageStart {
id: "r1".into(),
model: "gpt".into(),
}],
));
let resp = state.finalize().expect("ok");
assert_eq!(resp.id, "r1");
assert_eq!(resp.model, "gpt");
}
#[test]
fn apply_to_text_block_then_finalize() {
let mut state = PartialMessageResponse::new();
assert!(apply_seq(
&mut state,
&[
StreamEvent::MessageStart {
id: "r".into(),
model: "m".into(),
},
StreamEvent::ContentBlockStart {
index: 0,
block_type: ContentBlockType::Text,
},
StreamEvent::TextDelta {
text: "hello".into(),
},
StreamEvent::TextDelta {
text: " world".into(),
},
StreamEvent::ContentBlockEnd { index: 0 },
StreamEvent::MessageComplete {
full_response: empty_response(),
},
],
));
assert!(state.is_complete);
let resp = state.finalize().expect("ok");
match &resp.message {
Message::Assistant { content } => {
assert_eq!(content.len(), 1);
match &content[0] {
ContentBlock::Text { text } => assert_eq!(text, "hello world"),
_ => panic!("expected Text block"),
}
}
_ => panic!("expected Assistant"),
}
}
#[test]
fn apply_to_tool_use_block_then_finalize() {
let mut state = PartialMessageResponse::new();
assert!(apply_seq(
&mut state,
&[
StreamEvent::MessageStart {
id: "r".into(),
model: "m".into(),
},
StreamEvent::ContentBlockStart {
index: 0,
block_type: ContentBlockType::ToolUse {
id: "call_x".into(),
name: "search".into(),
},
},
StreamEvent::ToolCallArgumentsDelta {
index: 0,
arguments: r#"{"q":"rust"}"#.into(),
},
StreamEvent::ToolCallEnd { index: 0 },
StreamEvent::MessageComplete {
full_response: empty_response(),
},
],
));
let resp = state.finalize().expect("ok");
match &resp.message {
Message::Assistant { content } => match &content[0] {
ContentBlock::ToolUse { id, name, input } => {
assert_eq!(id, "call_x");
assert_eq!(name, "search");
assert_eq!(input, &json!({"q": "rust"}));
}
_ => panic!("expected ToolUse"),
},
_ => panic!("expected Assistant"),
}
}
#[test]
fn apply_to_thinking_block_uses_fallback_signature() {
let mut state = PartialMessageResponse::new();
state.set_thinking_signature("global-sig");
assert!(apply_seq(
&mut state,
&[
StreamEvent::MessageStart {
id: "r".into(),
model: "m".into(),
},
StreamEvent::ContentBlockStart {
index: 0,
block_type: ContentBlockType::Thinking,
},
StreamEvent::ThinkingDelta {
text: "thinking...".into(),
},
StreamEvent::ContentBlockEnd { index: 0 },
StreamEvent::MessageComplete {
full_response: empty_response(),
},
],
));
let resp = state.finalize().unwrap();
match &resp.message {
Message::Assistant { content } => match &content[0] {
ContentBlock::Thinking { text, signature } => {
assert_eq!(text, "thinking...");
assert_eq!(signature.as_deref(), Some("global-sig"));
}
_ => panic!("expected Thinking"),
},
_ => panic!("expected Assistant"),
}
}
#[test]
fn apply_to_cost_update_merges_across_events() {
let mut state = PartialMessageResponse::new();
assert!(apply_seq(
&mut state,
&[
StreamEvent::MessageStart {
id: "r".into(),
model: "m".into(),
},
StreamEvent::CostUpdate {
usage: PartialUsage {
prompt_tokens: Some(100),
..Default::default()
},
},
StreamEvent::CostUpdate {
usage: PartialUsage {
completion_tokens: Some(50),
total_tokens: Some(150),
..Default::default()
},
},
StreamEvent::MessageComplete {
full_response: empty_response(),
},
],
));
assert_eq!(state.usage.prompt_tokens, Some(100));
assert_eq!(state.usage.completion_tokens, Some(50));
assert_eq!(state.usage.total_tokens, Some(150));
}
#[test]
fn apply_to_error_event_returns_false_and_marks_errored() {
let mut state = PartialMessageResponse::new();
let cont = state.apply_to(&StreamEvent::Error {
message: "boom".into(),
});
assert!(!cont);
assert!(state.is_errored);
// finalize 仍可调用,错误信息透传由 Provider 流循环处理
let resp = state.finalize().expect("ok even after error");
assert_eq!(resp.stop_reason, StopReason::Stop);
}
#[test]
fn multi_block_ordering_by_index() {
let mut state = PartialMessageResponse::new();
// 故意以 index 3 优先于 index 0 提供,验证 finalize 按 index 升序输出
assert!(apply_seq(
&mut state,
&[
StreamEvent::MessageStart {
id: "r".into(),
model: "m".into(),
},
StreamEvent::ContentBlockStart {
index: 3,
block_type: ContentBlockType::Text,
},
StreamEvent::TextDelta {
text: "second".into(),
},
StreamEvent::ContentBlockEnd { index: 3 },
StreamEvent::ContentBlockStart {
index: 0,
block_type: ContentBlockType::Text,
},
StreamEvent::TextDelta {
text: "first".into(),
},
StreamEvent::ContentBlockEnd { index: 0 },
],
));
let resp = state.finalize().unwrap();
match &resp.message {
Message::Assistant { content } => {
assert_eq!(content.len(), 2);
match (&content[0], &content[1]) {
(
ContentBlock::Text { text: t1 },
ContentBlock::Text { text: t2 },
) => {
assert_eq!(t1, "first");
assert_eq!(t2, "second");
}
_ => panic!("expected Text blocks"),
}
}
_ => panic!("expected Assistant"),
}
}
#[test]
fn message_complete_does_not_double_emit_in_finalize() {
// MessageComplete 仅作完成标记,不修改 blocks
let mut state = PartialMessageResponse::new();
assert!(apply_seq(
&mut state,
&[
StreamEvent::MessageStart {
id: "r".into(),
model: "m".into(),
},
StreamEvent::ContentBlockStart {
index: 0,
block_type: ContentBlockType::Text,
},
StreamEvent::TextDelta {
text: "x".into(),
},
StreamEvent::ContentBlockEnd { index: 0 },
StreamEvent::MessageComplete {
full_response: empty_response(),
},
],
));
assert!(state.is_complete);
let resp = state.finalize().unwrap();
match &resp.message {
Message::Assistant { content } => assert_eq!(content.len(), 1),
_ => panic!("expected Assistant"),
}
}
// ===== JSON roundtrip 测试 =====
#[test]
fn message_response_json_roundtrip() {
let resp = MessageResponse {
id: "resp-99".into(),
model: "gpt-4o".into(),
message: Message::Assistant {
content: vec![
ContentBlock::Text {
text: "hello".into(),
},
ContentBlock::ToolUse {
id: "c".into(),
name: "f".into(),
input: json!({"a": 1}),
},
],
},
usage: Usage::from_input_output(10, 20),
stop_reason: StopReason::ToolUse,
extra: HashMap::from([("trace".to_string(), json!("t-1"))]),
};
let json = serde_json::to_string(&resp).unwrap();
let decoded: MessageResponse = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.id, resp.id);
assert_eq!(decoded.stop_reason, resp.stop_reason);
assert_eq!(decoded.usage.prompt_tokens, 10);
assert_eq!(decoded.usage.completion_tokens, 20);
}
#[test]
fn stream_event_json_roundtrip_each_variant() {
let events = vec![
StreamEvent::MessageStart {
id: "r".into(),
model: "m".into(),
},
StreamEvent::ContentBlockStart {
index: 0,
block_type: ContentBlockType::Text,
},
StreamEvent::ContentBlockEnd { index: 0 },
StreamEvent::TextDelta {
text: "t".into(),
},
StreamEvent::ThinkingDelta {
text: "p".into(),
},
StreamEvent::RefusalDelta {
text: "r".into(),
},
StreamEvent::ToolCallArgumentsDelta {
index: 1,
arguments: "{\"x\":1}".into(),
},
StreamEvent::ToolCallEnd { index: 1 },
StreamEvent::CostUpdate {
usage: PartialUsage {
prompt_tokens: Some(1),
completion_tokens: Some(2),
..Default::default()
},
},
StreamEvent::MessageComplete {
full_response: empty_response(),
},
StreamEvent::Error {
message: "boom".into(),
},
];
for original in events {
let json = serde_json::to_string(&original).unwrap();
let decoded: StreamEvent = serde_json::from_str(&json).unwrap();
assert_eq!(format!("{:?}", original), format!("{:?}", decoded));
}
}
}
+2 -1
View File
@@ -1,11 +1,12 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OpenaiToolDefinition {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub parameters: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub strict: Option<bool>,
+52 -39
View File
@@ -2,17 +2,16 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::llm::compact::{CompactConfig, CompactState, microcompact, should_compact};
use crate::llm::types::OpenaiChatMessage;
use crate::llm::types::message::Message;
use crate::memory::error::MemoryError;
use crate::memory::store::MemoryStore;
use crate::memory::types::MemoryItem;
/// 对话消息管理策略。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum MemoryStrategy {
/// 滑动窗口:达到上限时删除最旧消息。
SlidingWindow,
@@ -40,22 +39,30 @@ impl Default for ConversationMemoryConfig {
/// 对话记忆 —— 按 session 管理多轮对话消息历史。
///
/// 内部维护 `Vec<OpenaiChatMessage>` 热缓存(供 `llm::compact` 直接操作)
/// 内部维护 `Vec<Message>`Phase 2 IR 类型)作为热缓存
/// `MemoryStore` 用作冷持久化层。
///
/// ponytail: Phase 2 切换消息存储从 `OpenaiChatMessage` 到 `Message`。
/// `Message` 已实现 `Serialize` / `Deserialize`Phase 0 FIX-B 引入),
/// 序列化格式采用 `#[serde(tag = "type", rename_all = "snake_case")]`
/// 例如:
/// ```json
/// {"type": "user", "content": [{"type": "text", "text": "hi"}]}
/// {"type": "tool_result", "tool_call_id": "c1", "content": [...], "is_error": false}
/// ```
pub struct ConversationMemory {
store: Arc<dyn MemoryStore>,
session_id: String,
config: ConversationMemoryConfig,
/// 热缓存:消息列表,供 `llm::compact` 直接操作。
messages: Vec<OpenaiChatMessage>,
/// 与 `messages` 一一对应的存储 ID(保持稳定以便淘汰时精准删除)
messages: Vec<Message>,
/// 与 `messages` 一一对应的存储 ID。
message_ids: Vec<String>,
/// 压缩断路器状态。
compact_state: CompactState,
}
impl ConversationMemory {
/// 创建一个新的 ConversationMemory。
pub fn new(
store: Arc<dyn MemoryStore>,
session_id: impl Into<String>,
@@ -71,26 +78,23 @@ impl ConversationMemory {
}
}
/// 获取 session id。
pub fn session_id(&self) -> &str {
&self.session_id
}
/// 获取配置。
pub fn config(&self) -> &ConversationMemoryConfig {
&self.config
}
/// 从 MemoryStore 加载历史消息到热缓存。
pub async fn load(&mut self) -> Result<(), MemoryError> {
let filter = crate::memory::types::MemoryFilter {
prefix: Some(self.session_prefix()),
..Default::default()
};
let items = self.store.list(&filter).await?;
let mut pairs: Vec<(String, OpenaiChatMessage, OffsetDateTime)> = Vec::with_capacity(items.len());
let mut pairs: Vec<(String, Message, OffsetDateTime)> = Vec::with_capacity(items.len());
for item in items {
match serde_json::from_str::<OpenaiChatMessage>(&item.content) {
match serde_json::from_str::<Message>(&item.content) {
Ok(msg) => pairs.push((item.id, msg, item.created_at)),
Err(e) => {
return Err(MemoryError::Serialization(format!(
@@ -100,17 +104,13 @@ impl ConversationMemory {
}
}
}
// 按 created_at 升序排列
pairs.sort_by_key(|p| p.2);
self.message_ids = pairs.iter().map(|p| p.0.clone()).collect();
self.messages = pairs.into_iter().map(|p| p.1).collect();
Ok(())
}
/// 添加一条消息。
///
/// 写入热缓存并通过 `MemoryStore` 持久化。如有需要,触发淘汰和压缩。
pub async fn add_message(&mut self, msg: OpenaiChatMessage) -> Result<(), MemoryError> {
pub async fn add_message(&mut self, msg: Message) -> Result<(), MemoryError> {
let now = OffsetDateTime::now_utc();
let index = self.messages.len();
let id = self.make_message_id(index, &now);
@@ -119,7 +119,7 @@ impl ConversationMemory {
self.messages.push(msg);
self.message_ids.push(id.clone());
// 同步到冷存储
// ponytail: 通过 `Message` 的 Serialize 派生实现持久化
let item = MemoryItem {
id: id.clone(),
content: serde_json::to_string(self.messages.last().unwrap())
@@ -129,17 +129,14 @@ impl ConversationMemory {
};
self.store.save(item).await?;
// 触发淘汰和压缩
self.maybe_evict_and_compact().await;
Ok(())
}
/// 获取完整消息历史。
pub fn get_history(&self) -> &[OpenaiChatMessage] {
pub fn get_history(&self) -> &[Message] {
&self.messages
}
/// 清空所有消息。
pub async fn clear(&mut self) -> Result<(), MemoryError> {
let to_delete = std::mem::take(&mut self.message_ids);
self.messages.clear();
@@ -150,18 +147,16 @@ impl ConversationMemory {
Ok(())
}
/// 当前消息数量。
pub fn len(&self) -> usize {
self.messages.len()
}
/// 是否为空。
pub fn is_empty(&self) -> bool {
self.messages.is_empty()
}
fn session_prefix(&self) -> String {
format!("conv:{self}:", self = self.session_id)
format!("conv:{}:", self.session_id)
}
fn make_message_id(&self, index: usize, now: &OffsetDateTime) -> String {
@@ -169,7 +164,6 @@ impl ConversationMemory {
}
async fn maybe_evict_and_compact(&mut self) {
// 1. Sliding window 淘汰:删除最旧消息
if self.config.strategy == MemoryStrategy::SlidingWindow {
while self.messages.len() > self.config.max_turns {
if let Some(removed_id) = self.message_ids.first().cloned() {
@@ -180,7 +174,6 @@ impl ConversationMemory {
}
}
// 2. 压缩(复用 llm::compact
if let Some(ref compact_config) = self.config.compact_config {
if should_compact(&self.messages, compact_config, &self.compact_state) {
let keep_recent = compact_config.keep_recent;
@@ -188,7 +181,6 @@ impl ConversationMemory {
if freed > 0 {
self.compact_state.record_success();
} else {
// 没有 token 被释放(可能没找到可压缩的 tool result
let _ = self.compact_state.record_failure();
}
}
@@ -199,24 +191,42 @@ impl ConversationMemory {
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::types::OpenaiChatMessage;
use crate::memory::InMemoryStore;
use crate::memory::MemoryStore;
fn user_text(s: &str) -> OpenaiChatMessage {
OpenaiChatMessage::user_text(s)
}
#[tokio::test]
async fn add_and_get_history() {
let store = Arc::new(InMemoryStore::new()) as Arc<dyn MemoryStore>;
let mut conv = ConversationMemory::new(store, "session1", ConversationMemoryConfig::default());
conv.add_message(user_text("hello")).await.unwrap();
conv.add_message(user_text("world")).await.unwrap();
conv.add_message(Message::user_text("hello")).await.unwrap();
conv.add_message(Message::user_text("world")).await.unwrap();
assert_eq!(conv.len(), 2);
assert_eq!(conv.get_history().len(), 2);
}
/// 验证 Message → JSON → Message 往返(包含 ToolResult 等完整信息)。
#[tokio::test]
async fn json_roundtrip_preserves_tool_result() {
let store = Arc::new(InMemoryStore::new()) as Arc<dyn MemoryStore>;
let mut conv = ConversationMemory::new(store, "s1", ConversationMemoryConfig::default());
conv.add_message(Message::tool_result("call_1", "ok", false))
.await
.unwrap();
conv.add_message(Message::assistant("done"))
.await
.unwrap();
let original = conv.get_history().to_vec();
assert_eq!(original.len(), 2);
// 各变体可序列化 + 反序列化
for msg in &original {
let json = serde_json::to_string(msg).unwrap();
let decoded: Message = serde_json::from_str(&json).unwrap();
assert_eq!(format!("{:?}", decoded), format!("{:?}", msg));
}
}
#[tokio::test]
async fn sliding_window_evicts_oldest() {
let store = Arc::new(InMemoryStore::new()) as Arc<dyn MemoryStore>;
@@ -227,7 +237,9 @@ mod tests {
};
let mut conv = ConversationMemory::new(store, "s1", config);
for i in 0..5 {
conv.add_message(user_text(&format!("msg-{i}"))).await.unwrap();
conv.add_message(Message::user_text(&format!("msg-{i}")))
.await
.unwrap();
}
assert_eq!(conv.len(), 3);
}
@@ -242,9 +254,10 @@ mod tests {
};
let mut conv = ConversationMemory::new(store, "s1", config);
for i in 0..5 {
conv.add_message(user_text(&format!("msg-{i}"))).await.unwrap();
conv.add_message(Message::user_text(&format!("msg-{i}")))
.await
.unwrap();
}
// Full 策略不删除消息
assert_eq!(conv.len(), 5);
}
@@ -252,7 +265,7 @@ mod tests {
async fn clear_empties_messages() {
let store = Arc::new(InMemoryStore::new()) as Arc<dyn MemoryStore>;
let mut conv = ConversationMemory::new(store.clone(), "s1", ConversationMemoryConfig::default());
conv.add_message(user_text("hello")).await.unwrap();
conv.add_message(Message::user_text("hello")).await.unwrap();
assert!(!conv.is_empty());
conv.clear().await.unwrap();
assert!(conv.is_empty());
+1 -1
View File
@@ -1,4 +1,4 @@
use crate::llm::types::message::{ContentField, OpenaiChatMessage, OpenaiContentPart};
use crate::llm::types::openai_message::{ContentField, OpenaiChatMessage, OpenaiContentPart};
use crate::llm::types::request::OpenaiChatRequest;
use crate::prompt::error::PromptError;
use crate::prompt::template::{PromptTemplate, TemplateContext};
+55 -16
View File
@@ -15,6 +15,12 @@ use crate::tools::permission::PermissionChecker;
/// 工具调用记录 —— 用于追踪和调试。
#[derive(Debug, Clone)]
pub struct ToolInvocation {
/// LLM 返回的 tool_call_id —— 用于回传 `Message::ToolResult` 时关联原始调用。
///
/// ponytail: Phase 2 引入。老的循环用 `tool_name` 冒充 tool_call_id
/// 对 OpenAI 碰巧可用,对 Anthropic 必然失败。Anthropic 协议要求
/// `tool_result.tool_use_id` 与上一轮 `tool_use.id` 严格一致。
pub tool_call_id: String,
/// 被调用的工具名。
pub tool_name: String,
/// 工具的入参。
@@ -25,8 +31,14 @@ pub struct ToolInvocation {
impl ToolInvocation {
/// 创建一个新的工具调用记录。
pub fn new(tool_name: String, input: Value, output: Result<Value, ToolError>) -> Self {
pub fn new(
tool_call_id: String,
tool_name: String,
input: Value,
output: Result<Value, ToolError>,
) -> Self {
Self {
tool_call_id,
tool_name,
input,
output,
@@ -128,7 +140,15 @@ impl ToolRegistry {
}
/// 调用单个工具(含权限检查)。
pub async fn invoke(&self, name: &str, args: Value) -> Result<ToolInvocation, ToolError> {
///
/// `tool_call_id` 来源于 LLM 流式响应中的 `tool_calls[i].id`,用于回传
/// 工具结果时与原始 `tool_use` block 关联。
pub async fn invoke(
&self,
tool_call_id: &str,
name: &str,
args: Value,
) -> Result<ToolInvocation, ToolError> {
let tool = self
.get(name)
.ok_or_else(|| ToolError::NotFound(name.to_string()))?;
@@ -139,35 +159,48 @@ impl ToolRegistry {
let ctx = ToolContext::new(name, "");
let output = tool.execute(args.clone(), &ctx).await;
Ok(ToolInvocation::new(name.to_string(), args, output))
Ok(ToolInvocation::new(
tool_call_id.to_string(),
name.to_string(),
args,
output,
))
}
/// 并行执行多个工具调用(互不依赖的工具)。
///
/// 每个工具独立超时(`timeout_per_call_secs`0 表示不超时)。
/// 单个工具超时不会影响其他工具的返回。
///
/// 入参元组为 `(tool_call_id, tool_name, args)` —— `tool_call_id` 来自 LLM 响应。
pub async fn invoke_all(
&self,
calls: Vec<(String, Value)>,
calls: Vec<(String, String, Value)>,
timeout_per_call_secs: u64,
) -> Vec<ToolInvocation> {
let this = self.clone();
let futures = calls.into_iter().map(|(name, args)| {
let futures = calls.into_iter().map(|(tool_call_id, name, args)| {
let this = this.clone();
async move {
match if timeout_per_call_secs == 0 {
Ok(this.invoke(&name, args.clone()).await)
Ok(this.invoke(&tool_call_id, &name, args.clone()).await)
} else {
tokio::time::timeout(
Duration::from_secs(timeout_per_call_secs),
this.invoke(&name, args.clone()),
this.invoke(&tool_call_id, &name, args.clone()),
)
.await
} {
Ok(result) => result.unwrap_or_else(|e| {
ToolInvocation::new(name.clone(), args.clone(), Err(e))
ToolInvocation::new(
tool_call_id.clone(),
name.clone(),
args.clone(),
Err(e),
)
}),
Err(_) => ToolInvocation::new(
tool_call_id,
name,
args,
Err(ToolError::McpTimeout("timeout".into())),
@@ -313,15 +346,17 @@ mod tests {
async fn test_invoke_success() {
let mut reg = ToolRegistry::new();
reg.register(Arc::new(AddTool { base: 100 })).unwrap();
let result = reg.invoke("add", json!({ "n": 5 })).await.unwrap();
let result = reg.invoke("call_1", "add", json!({ "n": 5 })).await.unwrap();
let value = result.output.unwrap();
assert_eq!(value["result"], 105);
assert_eq!(result.tool_call_id, "call_1");
assert_eq!(result.tool_name, "add");
}
#[tokio::test]
async fn test_invoke_not_found() {
let reg = ToolRegistry::new();
let result = reg.invoke("nope", json!({})).await;
let result = reg.invoke("call_x", "nope", json!({})).await;
assert!(matches!(result, Err(ToolError::NotFound(_))));
}
@@ -329,7 +364,7 @@ mod tests {
async fn test_invoke_execution_error() {
let mut reg = ToolRegistry::new();
reg.register(Arc::new(FailTool)).unwrap();
let result = reg.invoke("fail", json!({})).await.unwrap();
let result = reg.invoke("call_y", "fail", json!({})).await.unwrap();
assert!(result.output.is_err());
}
@@ -338,7 +373,7 @@ mod tests {
let mut reg = ToolRegistry::new()
.with_permission_checker(PermissionChecker::new(Default::default()));
reg.register(Arc::new(ShellTool)).unwrap();
let result = reg.invoke("shell", json!({})).await;
let result = reg.invoke("call_z", "shell", json!({})).await;
assert!(matches!(result, Err(ToolError::PermissionDenied(_, _))));
}
@@ -348,12 +383,15 @@ mod tests {
reg.register(Arc::new(AddTool { base: 1 })).unwrap();
reg.register(Arc::new(FailTool)).unwrap();
let calls = vec![
("add".into(), json!({ "n": 1 })),
("add".into(), json!({ "n": 2 })),
("fail".into(), json!({})),
("c1".into(), "add".into(), json!({ "n": 1 })),
("c2".into(), "add".into(), json!({ "n": 2 })),
("c3".into(), "fail".into(), json!({})),
];
let results = reg.invoke_all(calls, 0).await;
assert_eq!(results.len(), 3);
assert_eq!(results[0].tool_call_id, "c1");
assert_eq!(results[1].tool_call_id, "c2");
assert_eq!(results[2].tool_call_id, "c3");
assert!(results[0].output.is_ok());
assert!(results[1].output.is_ok());
assert!(results[2].output.is_err());
@@ -363,9 +401,10 @@ mod tests {
async fn test_invoke_all_with_timeout() {
let mut reg = ToolRegistry::new();
reg.register(Arc::new(AddTool { base: 0 })).unwrap();
let calls = vec![("add".into(), json!({ "n": 1 }))];
let calls = vec![("c1".into(), "add".into(), json!({ "n": 1 }))];
let results = reg.invoke_all(calls, 5).await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].tool_call_id, "c1");
assert!(results[0].output.is_ok());
}
}