aacode-rs 1.8.15

Rust port of the AACode ReAct coding agent, built on fastshell for mobile AI agents
Documentation
# LLM 客户端设计与兼容性文档(aacode-rs)

> 位置:`fastshell_local/aacode-rs/`
> 相关代码:`src/llm/`(`mod.rs` / `types.rs` / `sse.rs` / `openai.rs` / `anthropic.rs`)
> 相关测试:`src/llm/*.rs` 内联单测 + `tests/llm_compat.rs`(23 离线 + 4 活体)

---

## 1. 核心决策:不用 SDK,直接手写 HTTP + JSON

Python 版 aacode 用官方 SDK(`openai` / `anthropic`)。**Rust 版不使用任何 LLM SDK**,而是用 `ureq`(同步 HTTP 客户端)直接调用各家的 REST API,手写 SSE 流解析和消息格式转换。

### 为什么

| 维度 | 用 SDK(如 async-openai) | 手写(本方案) |
|---|---|---|
| 依赖体积 | 拖入 `tokio` 全家桶 + `reqwest` + 大量传递依赖 | 仅复用 fastshell 已有的 `ureq`(同步、rustls),零新增运行时 |
| 移动端约束 | `.a` 静态库显著膨胀 | 目标 ≤50MB 可控 |
| 可控性 | 封装黑盒,流式/reasoning 字段支持滞后 | 完全掌控每个字段与边界 |
| 兼容面 | 每家一个 SDK | 一套代码适配所有 OpenAI/Anthropic 兼容端点 |

### 本质认识

OpenAI / Anthropic 的官方 SDK 底层协议都是**公开、稳定的 HTTP + JSON 规范**,SDK 只是薄封装(构造请求 JSON + 解析 SSE)。手写这两步不仅可行,反而依赖更少、更透明。DeepSeek / Kimi(Moonshot) / MiniMax 等都声明兼容这两套协议之一,因此同一套代码即可全部接入。

---

## 2. 架构:中立消息模型 + 双网关转换

```
┌─────────────────────────────────────────────────────────┐
│  Agent 层只认识中立类型 ChatMessage / ToolCall / LlmResponse │
└───────────────────────────┬─────────────────────────────┘
                            │  LlmClient trait
          ┌─────────────────┴──────────────────┐
          ▼                                     ▼
┌───────────────────┐                 ┌───────────────────┐
│  OpenAiClient     │                 │  AnthropicClient  │
│  (openai.rs)      │                 │  (anthropic.rs)   │
│  build_messages → │                 │  build_messages → │
│  OpenAI wire JSON │                 │  Anthropic wire   │
│  parse_openai_    │                 │  parse_anthropic_ │
│  stream ← SSE     │                 │  stream ← SSE     │
└─────────┬─────────┘                 └─────────┬─────────┘
          │            共用 sse.rs (SSE 分帧)     │
          └──────────────────┬───────────────────┘
                             ▼
                      ureq POST (rustls)
```

### 2.1 中立类型(`llm/types.rs`)

```rust
pub struct ChatMessage {
    pub role: String,                       // system|user|assistant|tool
    pub content: String,
    pub tool_calls: Option<Vec<ToolCall>>,  // assistant 发起的工具调用
    pub tool_call_id: Option<String>,       // tool 结果对应的调用 id
    pub reasoning_content: Option<String>,  // DeepSeek/Kimi 思考内容
}

pub struct ToolCall { pub id: String, pub name: String, pub arguments: String /* 原始 JSON 串 */ }

pub struct LlmResponse {
    pub text: String,                     // 可见正文
    pub tool_calls: Vec<ToolCall>,        // 组装完成的工具调用
    pub reasoning_content: Option<String>,// 思考内容(与正文分离)
    pub finish_reason: Option<String>,    // "length"/"max_tokens" ⇒ 截断
}
```

Agent 循环(`agent/react_loop.rs`)只与这些中立类型交互,完全不感知底层是哪家 API。

### 2.2 网关分发(`llm/mod.rs`)

```rust
pub fn build_client(model: &ModelConfig) -> Box<dyn LlmClient> {
    match model.gateway {
        Gateway::Anthropic => Box::new(AnthropicClient::new(model.clone())),
        Gateway::Openai    => Box::new(OpenAiClient::new(model.clone())),
    }
}
```

`gateway` + 模型名自动推断 base_url(deepseek/kimi/minimax/claude/gpt → 对应端点),见 `config.rs::ModelConfig::resolved_base_url`。

---

## 3. 请求侧:ChatMessage → 各家 wire 格式

### 3.1 OpenAI 格式(`openai.rs::build_messages`)

- assistant 带工具调用:`{role, content, tool_calls:[{id, type:"function", function:{name, arguments}}]}`
- 工具结果:`{role:"tool", tool_call_id, content}`
- `reasoning_content` 原样透传(Kimi/DeepSeek 要求历史一致)
- 请求体:`{model, messages, temperature, max_tokens, stream:true, tools, tool_choice:"auto"}`
- Kimi/Moonshot 强制 `temperature=1`(模型限制)

### 3.2 Anthropic 格式(`anthropic.rs::build_messages`)—— 差异较大

| 内容 | OpenAI | Anthropic |
|---|---|---|
| system | messages 数组内一条 | **提到顶层 `system` 字段** |
| 多条 system | 各自一条 | **拼接成一个 system** |
| assistant 工具调用 | `tool_calls[]` | `content:[{type:"text"}, {type:"tool_use", id, name, input}]` |
| 工具结果 | `role:"tool"` | **`role:"user"` + `content:[{type:"tool_result", tool_use_id, content}]`** |
| 工具入参 | `arguments` 字符串 | `input` **已解析对象** |
| 端点 | `/v1/chat/completions` | `/v1/messages`(`/v1`→`/anthropic` 自动适配 minimax/deepseek/moonshot)|
| 鉴权头 | `Authorization: Bearer` | `x-api-key` + `anthropic-version` |

---

## 4. 响应侧:SSE 流解析

### 4.1 通用分帧(`llm/sse.rs`)

`SseReader` 逐行读底层字节流,抽取每个 `data:` 载荷:
- 忽略注释行(`:` 开头,如 OpenRouter/keep-alive)、`event:`/`id:`/`retry:` 字段、空行(事件边界)
- 兼容 `\r\n` 与 `data:` 后无空格
- `data: [DONE]` ⇒ 结束
- **EOF 无 `[DONE]` 也能优雅结束**(连接中断场景)

### 4.2 OpenAI 流解析(`openai.rs::parse_openai_stream`)

每个 chunk 的 `choices[0].delta`:
- `reasoning_content` → 累积到 `reasoning`,标记 thinking 段开启
- `content` → 累积到 `text`;首次出现时先 flush thinking 段(emit `seg:"thinking"`)
- `tool_calls[i]` → **按 `index` 累积** `id` / `function.name` / `function.arguments`(分片可能跨几十个 chunk);首次拿到 name 时 emit `tool_progress:"building"`,arguments 每增长 ~500 字符再 emit 一次
- `finish_reason=="length"` → 记录截断,正文追加告警

流结束:flush thinking → emit `seg:"thought"` → 按 index 组装 tool_calls,每个 emit `tool_progress:"done"` + `🛠️ Action:` 行。

### 4.3 Anthropic 流解析(`anthropic.rs::parse_anthropic_stream`)

事件驱动(`type` 字段):
- `content_block_start` → 按 index 建块(text / thinking / tool_use);tool_use 记录 id+name
- `content_block_delta` → `text_delta`→text、`thinking_delta`→reasoning、`input_json_delta`→拼接 tool_use 的 `partial_json`
- `message_delta.stop_reason` → 记录(`max_tokens` ⇒ 截断告警)
- 忽略 `message_start` / `ping` / `content_block_stop` / `message_stop`

组装:thinking/text 分离返回;tool_use 块的 partial_json 组装为 arguments(空则 `"{}"`)。

---

## 5. 兼容性如何保证(关键)

因为不依赖 SDK 帮忙校验,兼容性靠**三层测试**锁定,逐字对齐两家规范。

### 5.1 离线深度兼容测试(`tests/llm_compat.rs`,19 个)

**用真实厂商观察到的 wire 格式做 fixture**,而非自造简化数据。覆盖:

OpenAI 系(GPT-4o / DeepSeek / Kimi):
- `openai_gpt4o_realistic_text` — 首 chunk 带 `role`、`finish_reason:null` 中间态、usage-only 尾 chunk
- `openai_multibyte_content` — 中文 + emoji 正确重组
- `openai_deepseek_reasoner_split` — reasoning 多段先于 content,分离正确
- `openai_kimi_tool_call_heavy_fragmentation` — 参数跨 5 chunk、name/id 仅首片
- `openai_parallel_tool_calls` — 一次响应多个工具(index 0/1)
- `openai_text_then_tool_call` — 先叙述后调用
- `openai_keepalive_and_blank_lines` — 注释行 / 空行忽略
- `openai_malformed_chunk_skipped` — 中间坏 JSON 跳过不中断
- `openai_length_truncation_warning` — 截断告警
- `openai_eof_without_done` — 无 `[DONE]` 断流仍返回
- `openai_empty_stream` — 空流不 panic

Anthropic 系(Claude / MiniMax):
- `anthropic_claude_realistic_text` — message_start/ping/content_block_*/message_delta 全流程 + 中文
- `anthropic_extended_thinking` — thinking 块 + text 块分离
- `anthropic_tool_use_fragmented` — tool_use + input_json_delta 分片组装
- `anthropic_text_and_tool_use` — 叙述块 + 工具块并存
- `anthropic_tool_use_empty_input` — 无入参 ⇒ `"{}"`
- `anthropic_max_tokens_truncation` — 截断告警
- `anthropic_minimax_compatible` — MiniMax 同构验证
- `anthropic_empty_stream`

### 5.2 请求格式循徯测试(内联单测)

验证发往 API 的 JSON wire shape(多轮工具对话最易藏格式 bug):
- `openai.rs::build_messages_full_tool_roundtrip_wire_shape` — system/user/assistant(tool_calls)/tool/user 五段逐字段断言
- `openai.rs::build_messages_carries_reasoning_content`
- `anthropic.rs::build_messages_full_tool_roundtrip_wire_shape` — system 提顶层、tool_use input 为**对象**、tool_result 为 user 消息
- `anthropic.rs::build_messages_concatenates_system`

### 5.3 真实 API 活体测试(`tests/llm_compat.rs`,`#[ignore]`,需 key)

对照**真实厂商响应**验证(不是我造的数据):
- `live_openai_streams_text` — 真实文本流
- `live_openai_tool_call` — 真实工具调用(校验 name + arguments 可解析)
- `live_anthropic_streams_text` — Anthropic 兼容端点

运行方式:
```bash
LLM_API_KEY=sk-xxx LLM_MODEL_NAME=deepseek-chat \
LLM_API_URL=https://api.deepseek.com/v1 \
cargo test -p aacode-rs --test llm_compat -- --ignored --nocapture
```

**已用真实 DeepSeek API 验证通过**:
```
LIVE text = "pong"
LIVE tool_calls = [ToolCall { id: "call_00_zcfMxwW3ZpzV1vwI9d3k7072",
                              name: "run_shell", arguments: "{\"command\": \"ls\"}" }]
```

### 5.4 端到端 live agent 验证

用真实 DeepSeek 跑完整任务"写 hello.py 并用 python3 运行",4 轮迭代完成,且验证了**模型自纠错能力**:
1. 模型先用 heredoc `cat <<EOF`(fastshell 不支持)→ 工具返回错误
2. 模型读到错误,改用 `echo '...' > hello.py` → 成功
3. `python3 hello.py` → 输出 `Hello from aacode-rs`
4. 输出最终总结 → 循环完成

证明了 LLM 调用 → 工具执行 → 观察 → 再决策 → 完成判定的完整闭环、JSONL 事件协议、Python 执行路由全部与真实模型协同工作。

---

## 6. 已修复的关键缺陷

**多字节字符 panic(生产级 bug)**:`truncate` 原用 `&s[..n]` 按字节切片,遇到中文/emoji 边界会 panic。而 API 错误消息经常含中文——真机必炸。已改为 `chars().take(n)` 按字符安全截断,并加回归测试 `truncate_is_char_boundary_safe`。

**Anthropic 并行工具结果合并(端到端 live 测试发现)**:当模型一次发起多个并行工具调用(assistant 消息含多个 `tool_use` 块)时,Anthropic 严格要求紧随其后的**单个** user 消息内包含**全部** `tool_result` 块。原实现为每个工具结果各发一条 user 消息,导致真实 DeepSeek `/anthropic` 端点返回:

```
HTTP 400: `tool_use` ids were found without `tool_result` blocks
immediately after: call_xxx. Each `tool_use` block must have a
corresponding `tool_result` block in the next message.
```

已修复 `build_messages`:连续的 tool 结果合并进一个 user 消息的 content 数组(`build_messages_coalesces_parallel_tool_results` 回归测试)。修复后同一端到端任务(deepseek-v4-flash @ /anthropic)16 轮迭代完整完成。

> 说明:OpenAI 网关无此问题(tool 结果本就是独立的 `role:"tool"` 消息,规范允许多条)。这是两家协议的实质差异,只有 Anthropic 侧需要合并。

---

## 7. 错误处理与健壮性

| 场景 | 处理 |
|---|---|
| HTTP 4xx/5xx | `ureq::Error::Status` → `AacodeError::Api("HTTP {code}: ...")`,正文截断 |
| 连接/超时 | `AacodeError::Network`,标记可重试 |
| 401/认证 | react_loop `classify_api_error` → "authentication failed" 友好提示 |
| 配额/限流 | → "quota/rate limit reached" |
| 瞬时错误 | react_loop `chat_with_retry` 指数退避重试(1s/2s/4s,次数按 `limits.max_retries`)|
| 坏 JSON chunk | 跳过继续(keep-alive/半包容错)|
| 流中途取消 | 检查 `AtomicBool` → `AacodeError::Cancelled` |
| max_tokens 截断 | `finish_reason`/`stop_reason` 检测 + 正文追加告警 |

---

## 8. 测试统计

| 类别 | 数量 | 说明 |
|---|---|---|
| LLM 内联单测 | ~20 | build_messages / build_body / 流解析基础 / truncate |
| 离线兼容测试 | 19 | `tests/llm_compat.rs` 真实厂商 SSE fixture |
| 活体测试 | 3 | `#[ignore]`,已用 DeepSeek 真实验证通过 |
| 端到端 | 1 | 真实 API 完整 agent 任务 |
| **aacode-rs 总计** | **147**(离线自动)+ live | 全部通过 |

---

## 9. 支持矩阵

| 提供商 | 网关 | 状态 |
|---|---|---|
| DeepSeek (chat / reasoner) | openai | ✅ 真实验证 |
| DeepSeek (deepseek-v4-flash) | anthropic (`/anthropic` 端点) | ✅ 真实验证(文本流 + 工具调用 + 端到端任务)|
| OpenAI (GPT-4o 等) | openai | ✅ 格式兼容 |
| Kimi / Moonshot | openai | ✅ 格式兼容(temp=1 特判)|
| Claude | anthropic | ✅ 格式兼容 |
| MiniMax | anthropic | ✅ 格式兼容 |
| 其它 OpenAI/Anthropic 兼容端点 | 二选一 | ✅ 通用 |

> DeepSeek 同一 API key,切换端点即切换协议:`/v1` → OpenAI 网关,`/anthropic` → Anthropic 网关(`base_url` 自动适配为 `/anthropic/v1/messages`)。两种形式均已用真实 API 端到端验证通过。

---

## 10. GitHub 生态调研与借鉴(rust-genai / async-openai)

为进一步加强,调研了 crates.io / GitHub 上的相关 Rust 库,对照方案选型与实现细节。

### 10.1 主要参考库对比

| 库 | Star | 定位 | 依赖策略 | 与本方案关系 |
|---|---|---|---|---|
| **rust-genai** | 837 | 多厂商统一 API(26+ provider) | **不用 per-service SDK**,adapter 层实现各家原生协议;`reqwest`(0.13)+`tokio`+`eventsource-stream`+`futures` | **同一思路**(最有参考价值)|
| **async-openai** | 大 | OpenAI 官方 API 完整封装 | `reqwest`+`tokio`+`tokio-stream`+`eventsource-stream`+`secrecy`+`tower`(重试/超时) | 功能全但只对 OpenAI,重 |
| **ollama-rs** | 中 | 专注 Ollama | reqwest+tokio | 单一 provider |

**关键印证**:rust-genai 明确写道 —— "Native implementation without per-service SDK dependencies... Managing these protocol differences at the adapter layer is simpler and more scalable than dealing with multiple SDKs."(在 adapter 层处理协议差异,比对付多个 SDK 更简单、更可扩展)。这正是本方案的核心决策,得到成熟库(837 star)的验证。

### 10.2 依赖策略对比(回答"其他库依赖怎么解决")

- **rust-genai / async-openai 都用 `reqwest` + `tokio` 异步栈**,靠 **feature gate** 控制体积:
  - TLS 可选:`rustls-tls`(默认,rustls + aws-lc-rs + OS 信任库)vs `native-tls`,二选一互斥。
  - `reqwest` 用 `default-features = false` + 精选 features(`json`/`stream`/`gzip`/`http2`)避免拉入不需要的东西。
  - async-openai 把**类型**与 **API 实现**拆成不同 feature(`chat-completion-types` vs `_api`),只要类型时不拉入 reqwest/tokio。
  - 重依赖(AWS Bedrock 的 sigv4、realtime 的 websocket)全部 optional feature,默认不编译。
- **本方案(aacode-rs)走得更极致**:**完全不用 reqwest/tokio 异步栈**,直接用 fastshell 已有的 **`ureq`(同步 + rustls)** + 手写 SSE。理由:
  - 移动端 `.a` 体积敏感,`tokio` 多线程运行时 + `reqwest` + `h2`/`tower` 传递依赖较大;
  - agent 循环本就是串行的(一次一个 LLM 调用 + 工具执行),不需要异步并发,同步阻塞模型更简单;
  - fastshell 已用 ureq/rustls,复用零新增依赖树。
- **代价与权衡**:失去异步并发(多请求并行)。当前 agent 场景不需要;若未来要并发多子 agent,可评估切 `reqwest`(设计文档 §14 风险项已记录)。

### 10.3 从 rust-genai streamer 借鉴并已修补的真实边界

阅读 `rust-genai` 的 `openai/streamer.rs`(生产级、覆盖 20+ provider 的实测经验),发现并修补了本实现遗漏的边界:

1. **流内错误对象**:provider 可能 HTTP 200 但在流中途发 `{"error": {...}}`(OpenAI 侧)或 `event: error`(Anthropic 侧)。原实现会把它当普通 chunk **静默跳过** → 已改为立即返回 `AacodeError::Api("stream error: ...")`。测试:`openai_in_stream_error` / `anthropic_in_stream_error`。
2. **reasoning 字段双名**:Ollama 等用 `delta.reasoning` 而非 `delta.reasoning_content`。已同时接受两者。测试:`openai_reasoning_alt_field`。
3. **content 与 finish_reason 同 chunk**(Mistral 风格):本实现读到 finish_reason 后**不 early-continue**,继续处理同 chunk 的 content/tool_calls,天然正确。补测试固化:`openai_content_with_finish_reason_same_chunk`。

> genai 因为其流式架构在 finish_reason 分支里 early-continue,反而需要额外代码把同 chunk 的 content/tool_call 捞回来;本实现结构上更简单、无此坑。

### 10.4 已采纳的通用健壮性做法

- **tool_calls 按 index 累积、`id`/`name` 仅首片出现**:与 genai 的 `capture_tool_call` 一致(本实现已有)。
- **arguments 结束时才 parse JSON、失败保留原串**:与 genai 一致(本实现 `ToolCall::parsed_args` 走 `unwrap_or({})`,容错)。
- **未来可选借鉴**:usage(token 统计)字段捕获(genai 有,本实现暂未采集,agent 场景非必需);HTTP record/replay 测试(genai 用 yakbak,本实现用录制 SSE fixture + mock server 已达等效目的)。

### 10.5 结论

- 方案选型(不用 SDK、手写协议、双网关转换)与成熟库 rust-genai **同路**,且在移动端约束下用同步 ureq 做了更激进的依赖精简。
- 借鉴 genai 的实测经验,补齐了 3 类真实生产边界(流内错误、reasoning 双名、同 chunk finish_reason),并全部加了回归测试。
- 测试规模升级为 **23 离线兼容 + 4 活体(DeepSeek /v1 与 /anthropic 双端点真实验证)+ 完整单测**。