# llmctl 基于 genai 的架构优化方案

## 1. 最终决策

本项目后续采用 **genai 单运行时架构**。

核心决策：

1. 主 SDK 选用 `genai`，不再继续维护多套 provider engine。
2. llmctl 只负责配置解析、provider profile 选择、genai 参数映射、输出格式化、错误展示和配置迁移。
3. 所有聊天请求必须通过 `genai::Client` 发起，不再新增 OpenAI 专用 HTTP client。
4. 不使用 OpenAI 官方 SDK。项目必须支持 Anthropic、Google/Gemini、Ollama 等多平台，OpenAI 官方 SDK 不适合作为主依赖。
5. 当前自定义 `openai_reasoning.rs` 属于历史兼容代码。迁移到 genai 后，应删除或停止扩展，`reasoning_content` 交给 genai 的 reasoning stream/normalization 能力处理。
6. OpenAI 的 `responses` 与 `chat_completions` 作为配置项保留，并通过 model namespace 强制路由（`openai_resp::` / `openai::`）；不在 llmctl 内手写第二套 OpenAI 实现。

一句话目标：**llmctl 是 genai 上的一层 CLI 探针，不是一个自研多 provider SDK。**

## 1.1 当前实现进展（2026-05-02）

已落地（代码已实现）：

1. v2 配置加载与解析链路已接入主流程：`load_app_config -> resolve_runtime_config -> validate_resolved_config`。
2. `--dry-run`、`--doctor-config` 已可用，且包含 key 来源脱敏输出（不输出 key 内容）。
3. OpenAI `openai_api` 已支持 **可执行的 enforce**：
   - `responses` -> `effective_model=openai_resp::<model>`
   - `chat_completions` -> `effective_model=openai::<model>`
4. `genai` 已升级到 `0.6.0-beta.18`，并启用 `aliyun` 原生 adapter（`dashscope/aliyun -> adapter: aliyun`）。
5. 新增 genai runtime 主路径（`src/runtime/genai_runtime.rs`）：
   - 非流式 chat（`exec_chat`）
   - 流式 chat（`exec_chat_stream`，支持 reasoning chunk 输出）
   - list models（`all_model_names`）
6. 主流程已支持自动选路：
   - `genai` 可支持时，优先走 genai。
   - 不满足条件时，自动回退 legacy llm backend。
   - `--dry-run` 输出 `runtime_backend` 与 `runtime_fallback_reason`。
7. `LegacyReasoningContent` 不再触发 `provider_for_legacy_backend=openai-compatible` 的特殊重写；legacy provider 名保持与 adapter 一致，避免误走历史分支。
8. legacy fallback 的 provider client 已收敛到单一路径（`LLMBackendEnum::Standard`），不再在 client 层走 `OpenAIWithReasoning` 分支。

### 1.3 `openai_reasoning.rs` 下线清单（执行版）

为避免迁移后反复“回头加逻辑”，`src/backends/openai_reasoning.rs` 按以下顺序下线。
当前状态（2026-05-02）：前 4 项已完成，第 5 项已执行。

1. 保持不编译：`src/backends/mod.rs` 不再声明 `pub mod openai_reasoning`。
2. 保持不引用：`builder`/`provider client` 禁止出现 `OpenAIWithReasoning` 和 `build_openai_compatible` 调用。
3. 行为对齐门禁：
   - `cargo test` 全绿；
   - 至少一条真实 provider 请求（如 aliyun）成功；
   - `--dry-run` 显示 `runtime_backend: genai`。
4. 文档对齐门禁：
   - 方案文档明确“genai 主路径 + legacy 单路径 fallback”；
   - `openai_reasoning.rs` 标注为 deprecated migration artifact。
5. 满足 1-4 后执行删除：
   - 已删除 `src/backends/openai_reasoning.rs`
   - 已删除 `src/provider/traits.rs`（该文件未纳入模块树）

当前仍在过渡（明确保留的临时行为）：

1. 非 genai 已覆盖 adapter（例如旧路径中的部分 provider）仍走 legacy。
2. `src/backends/openai_reasoning.rs` 仍存在，仅作为迁移期兼容，不再扩展。
3. `extra_body` 已通过 `genai` 原生 `ChatOptions::with_extra_body`（本仓库 vendor patch）透传，不再因 `extra_body` 回退 legacy。

## 1.2 批判性复核结论

再次复核后，方案中最容易导致实现跑偏的风险有 7 个，必须作为研发门禁处理：

1. **不能把 genai 能力假设成已满足。** 文档中的 genai API 映射必须先由 spike 验证，不能直接按想象写主路径。
2. **不能在配置里声明支持，却运行时静默不生效。** `openai_api` 如果 genai 无法 enforce，必须在 validate 阶段报错或在 `--dry-run` 中标明 `unsupported`；`extra_body` 和 `reasoning_content` 使用 genai 原生能力，不做项目内二次适配。
3. **不能过早删除旧实现。** 旧 `llm` 主路径只有在 genai 覆盖非流式、流式、custom base_url、reasoning、模型列表最低能力后才能删除。
4. **不能泄露密钥。** `--dry-run` 和 `--doctor-config` 只能显示 key 来源，不能显示 key 内容，也不能显示 key 前后缀。
5. **不能让 `extra_body` 成为灰色地带。** genai 已支持 `extra_body`，llmctl 只做字段映射和脱敏，不再自己实现 provider-specific body 适配。
6. **不能把 OpenAI-compatible 当作独立 adapter。** v2 配置中第三方兼容 OpenAI 的服务统一使用 `adapter: openai + base_url`，旧配置中的 `openai-compatible`、`dashscope`、`aliyun` 只在迁移层转换。
7. **不能让 `system` 再退化成 user message。** system 消息必须进入 genai 的 system/instruction 能力；如果 genai 对某 adapter 不支持，必须 warning 或 fail，不能静默改 role。

对应门禁：

```text
Gate A: genai spike 完成前，不改主请求路径。
Gate B: extra_body 映射到 genai 的单元测试未通过前，不删除旧路径。
Gate C: reasoning stream 映射到 genai ReasoningChunk 的测试未通过前，不删除 openai_reasoning.rs。
Gate D: OpenAI API endpoint enforce 未验证前，显式 openai_api 非 auto 默认失败。
Gate E: dry-run/doctor-config 脱敏测试未通过前，不允许合并。
```

如果 Gate A/B/C 任一失败，不能继续任务 6 之后的主路径替换；应先修 genai 上游、调整依赖版本，或回到方案评审。

## 2. 设计边界

### 2.1 llmctl 负责

- 读取 YAML/JSON 配置。
- 支持一个文件内多个 provider profile。
- 解析 `active_provider`。
- 处理 CLI 覆盖项。
- 从环境变量读取 API key。
- 将 llmctl 配置映射为 genai 的 `Client`、`ChatRequest`、`ChatOptions`。
- 将 genai 的普通响应和流式事件格式化为终端输出。
- 提供 `--dry-run` / `--doctor-config`，打印最终运行计划。
- 保留旧 flat config 到 v2 config 的迁移兼容。

### 2.2 llmctl 不负责

- 不再维护自定义 OpenAI Responses HTTP 实现。
- 不再维护自定义 Chat Completions HTTP 实现。
- 不再维护自定义 SSE parser，除非是非常小的临时 fallback，且必须有删除计划。
- 不再新增 `OpenAIResponsesEngine` / `OpenAIChatEngine` / `OpenAIChatLegacyReasoningEngine`。
- 不再把第三方 `reasoning_content` 扩展成项目内部独立 engine。

## 3. 目标配置模型

推荐配置版本：`version: 2`。

```yaml
version: 2
active_provider: openai_main

defaults:
  stream: true
  timeout_seconds: 60
  capture_usage: true
  capture_reasoning_content: true
  normalize_reasoning_content: true
  openai_api: auto
  reasoning_mode: auto

providers:
  openai_main:
    adapter: openai
    model: gpt-5
    api_key_env: OPENAI_API_KEY
    openai_api: auto          # auto | responses | chat_completions
    reasoning_effort: medium

  openai_chat_compat:
    adapter: openai
    model: gpt-4.1
    api_key_env: OPENAI_API_KEY
    openai_api: chat_completions

  dashscope_qwen:
    adapter: openai
    model: qwen3-max
    base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
    api_key_env: DASHSCOPE_API_KEY
    openai_api: chat_completions
    reasoning_mode: legacy_reasoning_content
    extra_body:
      enable_thinking: true

  anthropic_main:
    adapter: anthropic
    model: claude-sonnet-4-5
    api_key_env: ANTHROPIC_API_KEY
    reasoning_effort: medium

  gemini_main:
    adapter: gemini
    model: gemini-2.5-pro
    api_key_env: GEMINI_API_KEY
    reasoning_effort: medium

  local_ollama:
    adapter: ollama
    model: llama3.1
    base_url: http://localhost:11434
    api_key: ""

context:
  - role: system
    content: You are a helpful assistant.
  - role: user
    content: Hello.
```

### 3.1 字段说明

| 字段 | 说明 |
|---|---|
| `active_provider` | 默认使用的 provider profile 名称 |
| `providers.<name>.adapter` | genai adapter/provider 名称，例如 `openai`、`anthropic`、`gemini`、`ollama` |
| `base_url` | 自定义 endpoint，映射到 genai target resolver/custom endpoint |
| `api_key_env` | API key 环境变量名 |
| `api_key` | 直接写入配置的 key，不推荐，但保留 |
| `openai_api` | OpenAI API 偏好：`auto`、`responses`、`chat_completions` |
| `reasoning_mode` | reasoning 兼容策略：`auto`、`standard`、`legacy_reasoning_content` |
| `extra_body` | provider 额外请求字段；通过 genai 原生能力透传，llmctl 不做 provider-specific body 适配 |

默认 API key 环境变量：

| adapter | 默认环境变量 | 备注 |
|---|---|---|
| `openai` | `OPENAI_API_KEY` | 如果配置了 `api_key_env`，以配置为准 |
| `anthropic` | `ANTHROPIC_API_KEY` | 如果配置了 `api_key_env`，以配置为准 |
| `gemini` / `google` | `GEMINI_API_KEY` | 兼容 `GOOGLE_API_KEY` 可作为后续增强 |
| `ollama` | 无 | 默认允许空 key |

旧 provider alias 迁移：

| v1 provider | v2 adapter | v2 附加规则 |
|---|---|---|
| `openai` | `openai` | 无 |
| `openai-compatible` / `openai_compatible` | `openai` | 保留 `base_url`，`openai_api=chat_completions` |
| `dashscope` / `aliyun` | `aliyun` | 保留 `base_url`，`openai_api=auto`，如存在 thinking 配置则 `reasoning_mode=legacy_reasoning_content` |
| `gemini` / `google` | `gemini` | 无 |
| `anthropic` / `claude` | `anthropic` | 无 |
| `ollama` | `ollama` | 允许空 key |

### 3.2 CLI 覆盖规则

```text
--provider <name>       覆盖 active_provider
--model <model>         覆盖当前 provider.model
--url <base_url>        覆盖当前 provider.base_url
--secret / --key        覆盖当前 provider.api_key / api_key_env
--stream                覆盖 stream=true
--endpoint <mode>       覆盖 openai_api
--reasoning-mode <mode> 覆盖 reasoning_mode
--message <text>        追加到 context
--dry-run               打印解析后的运行计划，不发请求
```

配置优先级：

```text
CLI 参数 > 环境变量 > provider profile > defaults > 内置默认值
```

## 4. OpenAI API 选择策略

配置保留：

```rust
enum OpenAiApiMode {
    Auto,
    Responses,
    ChatCompletions,
}
```

落地约束：

1. 第一阶段不在 llmctl 内手写 OpenAI HTTP 逻辑。
2. 通过 model namespace 显式指定 OpenAI API：
   - `openai_api: responses` -> `openai_resp::<model>`
   - `openai_api: chat_completions` -> `openai::<model>`
3. `openai_api: auto` 使用 `openai::<model>`（SDK 默认 chat/completions 路径）。
4. 后续优先给 genai 增加上游能力，而不是在 llmctl 中另起一套实现。

这样可以避免项目重新滑回“多 engine + 自研 OpenAI SDK”的方向。

实现细则：

```text
openai_api=auto:
  允许运行，effective_model=openai::<model>。

openai_api=responses:
  effective_model=openai_resp::<model>，按 Responses Adapter 执行。

openai_api=chat_completions:
  effective_model=openai::<model>，按 Chat Completions Adapter 执行。
```

`--dry-run` 必须显示：

```text
openai_api_requested: responses
openai_api_enforced: true
openai_api_runtime: enforced_by_model_namespace
```

## 5. Reasoning 与旧式 reasoning_content

配置保留：

```rust
enum ReasoningMode {
    Auto,
    Standard,
    LegacyReasoningContent,
}
```

设计原则：

- `legacy_reasoning_content` 是兼容模式，不是独立 API。
- 优先使用 genai 的 reasoning capture/normalization 能力。
- 终端输出只关心标准化结果：Thinking 与 Response。
- 不再维护项目内自定义 SSE parser。

期望 genai 映射：

```rust
ChatOptions::default()
    .with_capture_usage(true)
    .with_capture_reasoning_content(true)
    .with_normalize_reasoning_content(true)
```

流式事件映射：

```text
genai ReasoningChunk -> Thinking 输出
genai Chunk          -> Response 输出
genai End            -> usage / captured content / duration 输出
```

如果某个 provider 的 `reasoning_content` genai 尚未正确归一化：

1. 先记录为 genai adapter bug。
2. 优先向 genai 提 patch/issue。
3. llmctl 不新增 parser 或适配层；应升级 genai 或修复 genai adapter。

## 6. 代码结构

推荐目录：

```text
src/
  config/
    schema.rs          # AppConfigV2, ProviderProfile, Defaults
    legacy.rs          # 旧 flat config -> v2 config
    resolver.rs        # active_provider + CLI + env 合并
    validate.rs        # 配置校验与 unsupported capability 检查
    mod.rs

  runtime/
    genai_runtime.rs   # 唯一调用入口，封装 genai::Client
    mapper.rs          # llmctl config -> genai ChatRequest/ChatOptions
    target.rs          # base_url/api_key/model -> genai ServiceTargetResolver
    output.rs          # genai response/event -> llmctl 输出模型
    mod.rs

  output/
    formatter.rs
    stream.rs

  error/
    types.rs
```

不再新增：

```text
src/engine/
src/protocol/sse_*.rs
src/backends/openai_reasoning.rs 的后续扩展
```

迁移完成后删除：

```text
src/builder.rs
src/backends/openai.rs
src/backends/openai_reasoning.rs
src/provider/client.rs 中直接依赖 llm crate 的实现
```

## 7. Runtime 主流程

```text
load config file
  -> if v1 flat config: migrate to v2
  -> resolve active provider
  -> apply CLI overrides
  -> resolve api key from env
  -> validate unsupported capability
  -> build genai Client
  -> build ChatRequest
  -> build ChatOptions
  -> execute chat or stream
  -> format output
```

`GenaiRuntime` 建议接口：

```rust
pub struct GenaiRuntime {
    client: genai::Client,
    resolved: ResolvedProvider,
}

impl GenaiRuntime {
    pub async fn chat(&self, request: RuntimeChatRequest) -> Result<RuntimeChatResponse, LlmProbeError>;
    pub async fn stream_chat(&self, request: RuntimeChatRequest) -> Result<(), LlmProbeError>;
    pub async fn list_models(&self) -> Result<Vec<ModelInfo>, LlmProbeError>;
}
```

这里的 `GenaiRuntime` 不是 engine 抽象，只是把 genai 调用封装成当前 CLI 需要的入口。

## 8. genai 映射细节

### 8.1 ProviderProfile -> genai target

映射规则：

| llmctl 字段 | genai 目标 |
|---|---|
| `adapter` | genai adapter/provider |
| `model` | genai model name |
| `api_key` / `api_key_env` | genai auth resolver |
| `base_url` | genai service target resolver/custom endpoint |

### 8.2 context -> ChatRequest

规则：

- 顶层 `context` 作为默认消息。
- `--message` 追加为 user message。
- `role: system` 优先映射为 genai 支持的 system/instruction 方式；如果 genai 的 ChatRequest 只接受 user/assistant，则把 system 合并到 request/system instruction 字段，不能当 user message 发送。

### 8.3 generation options -> ChatOptions

字段映射：

| llmctl 字段 | genai ChatOptions |
|---|---|
| `temperature` | temperature |
| `max_tokens` | max_tokens / max_output_tokens |
| `top_p` | top_p |
| `reasoning_effort` | reasoning effort |
| `capture_usage` | capture usage |
| `capture_reasoning_content` | capture reasoning content |
| `normalize_reasoning_content` | normalize reasoning content |

### 8.4 extra_body

`extra_body` 使用 genai 原生能力。处理策略：

- llmctl 只负责从配置读取 `extra_body`，并映射到 genai 的 request options。
- llmctl 不按 provider 名称改写、补全或解释 `extra_body`。
- `extra_body` 中的敏感字段必须在日志、dry-run、doctor-config 中脱敏。

`extra_body` 门禁：

```text
extra_body 为空:
  可以运行。

extra_body 非空:
  必须通过 genai 原生 extra_body 能力透传。
  dry-run 显示 extra_body_supported=true。
  不允许本项目自定义 provider-specific body adapter。
```

原因：`extra_body` 往往用于启用 provider 特定能力，例如 DashScope/Qwen 的 `enable_thinking`。静默忽略会造成“看似开启 thinking，实际没开启”的错误验证结果。

## 9. list models 策略

目标：`llmctl -l` 仍然可用。

策略：

1. 优先使用 genai 提供的模型列表能力。
2. 如果 genai 只提供静态列表，输出时标记来源：

```text
Available Models (source: genai-static)
```

3. 对 OpenAI-compatible custom endpoint，如果 genai 不支持 live list，则保留一个独立的 `models` HTTP 请求 helper。
4. 该 helper 只允许用于 `list_models`，不能扩展成聊天请求实现。

这条例外是为了保持 CLI 探针能力，不违反“聊天请求全部走 genai”的原则。

## 10. dry-run 与 doctor-config

新增：

```bash
llmctl --dry-run
llmctl --doctor-config
```

`--dry-run` 输出：

```text
active_provider: dashscope_qwen
adapter: aliyun
model: qwen3-max
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
api_key_source: env:DASHSCOPE_API_KEY
stream: true
openai_api: auto
openai_api_enforced: false
reasoning_mode: legacy_reasoning_content
extra_body_supported: true
```

脱敏规则：

```text
允许输出:
  api_key_source: env:DASHSCOPE_API_KEY
  api_key_source: cli
  api_key_source: config

禁止输出:
  api_key: sk-...
  api_key_prefix: sk-...
  api_key_suffix: ...
```

dry-run 是可复制给他人的诊断输出，不能包含任何密钥片段。

日志脱敏规则：

- 默认日志级别不能打印请求体。
- 即使 `RUST_LOG=trace`，也必须对 `api_key`、`Authorization`、`extra_body` 中疑似密钥字段做脱敏。
- 错误对象中不能包含完整 URL query 中的密钥参数。
- 测试必须覆盖 `api_key` 不出现在 dry-run、doctor-config 和错误消息中。

`--doctor-config` 额外检查：

- active provider 是否存在。
- api key 是否可解析。
- base_url 是否为合法 URL。
- `extra_body` 是否已映射到 genai 原生 request options。
- `openai_api` 显式指定时是否可 enforce。
- provider adapter 是否被 genai 支持。

## 11. 开发阶段防跑偏规则

必须遵守：

1. 所有 chat/stream chat 请求必须通过 `genai::Client`。
2. 不新增 OpenAI 专用 HTTP client。
3. 不新增多 engine trait。
4. 不新增自定义 SSE parser。
5. 不继续扩展 `openai_reasoning.rs`。
6. genai 不支持的能力必须显式报 unsupported；genai 已支持的能力禁止自行适配。
7. endpoint/dialect/reasoning 只能作为配置与 genai option 映射，不作为分叉实现入口。

允许的例外：

- `list_models` 可以保留轻量 HTTP helper。
- `--dry-run` / `--doctor-config` 可以做协议能力检测。
- 为 genai 上游 patch 做临时代码实验，但不能进入主路径。

## 11.1 注释规范

本项目后续重构必须写必要注释，目标是让维护者快速理解“为什么这样做”，而不是重复代码在做什么。

必须写注释的位置：

1. **配置迁移规则。** v1 flat config 转 v2 profile 时，每个不直观映射都要说明原因，尤其是 `openai-compatible` 迁移为 `adapter: openai`、`dashscope/aliyun` 迁移为 `adapter: aliyun`。
2. **能力门禁。** `openai_api` 通过 model namespace enforce、`extra_body` 使用 genai 原生能力、`reasoning_content` 使用 genai normalization 的地方，要说明这是为了避免重新实现 SDK。
3. **system message 处理。** 任何处理 `role: system` 的代码都要说明不能退化为 user message。
4. **安全脱敏。** dry-run、doctor-config、日志、错误信息的 key 脱敏逻辑要说明不能输出 key 前缀/后缀。
5. **例外路径。** `list_models` 允许使用轻量 HTTP helper 时，必须注释说明该 helper 禁止被 chat 路径复用。
6. **删除旧实现的桥接代码。** 临时保留旧 `llm` 主路径、旧 provider 兼容层时，要写明删除条件和对应任务编号。

注释风格：

```rust
// v1 used provider names such as "dashscope" as pseudo providers.
// v2 maps dashscope/aliyun to the first-class "aliyun" adapter and keeps
// base_url so the runtime stays aligned with genai's provider model.
```

禁止的注释：

```rust
// Set the model.
config.model = model;

// Loop over messages.
for message in messages {}
```

验收要求：

- 新增 `resolver.rs`、`legacy.rs`、`mapper.rs`、`target.rs` 必须至少包含模块级注释，说明该模块在 genai 单运行时架构中的边界。
- 每个 `UnsupportedCapability` 分支必须有注释说明为什么不能在 llmctl 内自行补实现。
- 代码评审时，如果某段逻辑依赖本文档中的设计门禁，但代码没有注释，应视为未完成。

## 12. 具体研发任务清单

本节是实际开发执行清单。每个任务建议对应一个小提交，禁止跨阶段大改。

### 任务 0：确认 genai 精确 API 与迁移门禁

此任务必须先于所有主路径改造。它不是可选调研，而是 go/no-go 门禁。

文件变更：

```text
docs/genai_spike.md
```

输出结论必须采用以下格式：

```text
Decision: go | blocked | partial

Required capabilities:
- non_stream_chat: supported | unsupported
- stream_chat: supported | unsupported
- custom_base_url: supported | unsupported
- api_key_resolver: supported | unsupported
- reasoning_content_capture: supported | unsupported
- reasoning_stream_event: supported | unsupported
- extra_body_passthrough: genai_native | unsupported

Optional capabilities:
- openai_api_selection: supported | unsupported
- live_model_list: supported | unsupported
- system_instruction: supported | unsupported
```

决策矩阵：

| 结论 | 条件 | 后续动作 |
|---|---|---|
| `go` | required capabilities 全部 supported，`extra_body_passthrough=genai_native` | 进入任务 1-8 |
| `partial` | 只有 optional capabilities 缺失 | 进入任务 1-8，但 validate/dry-run 必须标记 unsupported |
| `blocked` | 任一 required capability 缺失 | 停止迁移，不改主路径，先解决 genai 版本/patch/替代方案 |

required capabilities 解释：

- `extra_body_passthrough` 被列为 required，是因为当前项目明确需要支持 DashScope/Qwen 这类通过 `extra_body.enable_thinking` 开启 thinking 的兼容服务；实现必须使用 genai 原生能力。
- 如果最终决定牺牲这类能力，必须重新修改本文档，并明确这是产品取舍，不允许开发中临时决定。

网络/API-key smoke 测试规则：

- 真实 provider 调用放在 `examples/genai_probe.rs` 或 `#[ignore]` 测试中。
- 常规 `cargo test` 不能依赖外部网络和真实 API key。
- CI 只运行无网络单元测试、fixture 测试和 `cargo check --examples`。

### 任务 1：引入 genai 依赖与 spike

文件变更：

```text
Cargo.toml
examples/genai_probe.rs
docs/genai_spike.md
```

`Cargo.toml`：

```toml
genai = "0.5.3"
```

第一阶段暂时保留 `llm` 依赖，避免一次性替换导致问题定位困难。

`examples/genai_probe.rs` 必须覆盖：

```text
cargo run --example genai_probe -- --provider openai --model <model> --message "hello"
cargo run --example genai_probe -- --provider anthropic --model <model> --message "hello"
cargo run --example genai_probe -- --provider gemini --model <model> --message "hello"
cargo run --example genai_probe -- --provider ollama --model <model> --message "hello"
cargo run --example genai_probe -- --provider openai --base-url <compatible-url> --model <model> --stream
```

`docs/genai_spike.md` 必须记录表格：

| 能力 | OpenAI | Anthropic | Gemini | Ollama | OpenAI-compatible |
|---|---|---|---|---|---|
| 非流式 chat | | | | | |
| 流式 chat | | | | | |
| reasoning chunk | | | | | |
| usage | | | | | |
| custom base_url | | | | | |
| extra_body | | | | | |
| OpenAI API 选择 | | | | | |
| live model list | | | | | |

spike 还必须记录精确 API：

```text
Client 构造 API:
ChatRequest 构造 API:
ChatOptions 字段/API:
stream API:
reasoning event 类型:
usage 字段:
custom base_url API:
auth resolver API:
extra_body 原生映射 API:
OpenAI endpoint 选择 API:
model list API:
```

如果某项 API 不存在，记录为 `unsupported`，不能在主实现中“猜 API”。

验收命令：

```bash
/Users/test/.cargo/bin/cargo check --examples
```

### 任务 2：新增 v2 配置结构

文件变更：

```text
src/config/schema.rs
src/config/legacy.rs
src/config/mod.rs
```

新增数据结构：

```rust
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AppConfigV2 {
    pub version: Option<u32>,
    pub active_provider: Option<String>,
    #[serde(default)]
    pub defaults: DefaultsConfig,
    #[serde(default)]
    pub providers: std::collections::BTreeMap<String, ProviderProfile>,
    #[serde(default)]
    pub context: Vec<Message>,
}

#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct DefaultsConfig {
    pub stream: Option<bool>,
    pub timeout_seconds: Option<u64>,
    pub capture_usage: Option<bool>,
    pub capture_reasoning_content: Option<bool>,
    pub normalize_reasoning_content: Option<bool>,
    pub openai_api: Option<OpenAiApiMode>,
    pub reasoning_mode: Option<ReasoningMode>,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ProviderProfile {
    pub adapter: String,
    pub model: Option<String>,
    pub base_url: Option<String>,
    pub api_key: Option<String>,
    pub api_key_env: Option<String>,
    pub stream: Option<bool>,
    pub timeout_seconds: Option<u64>,
    pub temperature: Option<f32>,
    pub max_tokens: Option<u32>,
    pub top_p: Option<f32>,
    pub reasoning_effort: Option<String>,
    pub openai_api: Option<OpenAiApiMode>,
    pub reasoning_mode: Option<ReasoningMode>,
    #[serde(default)]
    pub extra_body: serde_json::Map<String, serde_json::Value>,
    #[serde(default)]
    pub context: Vec<Message>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OpenAiApiMode {
    Auto,
    Responses,
    ChatCompletions,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReasoningMode {
    Auto,
    Standard,
    LegacyReasoningContent,
}
```

兼容迁移函数：

```rust
pub fn legacy_file_config_to_v2(config: FileConfig) -> AppConfigV2;
pub fn is_v2_config_value(value: &serde_json::Value) -> bool;
```

迁移规则：

- 旧 `provider` -> `providers.default.adapter`
- 旧 `base_url` -> `providers.default.base_url`
- 旧 `api_key` -> `providers.default.api_key`
- 旧 `model` -> `providers.default.model`
- 旧 `stream` -> `providers.default.stream`
- 旧 `context` -> 顶层 `context`
- 旧 `reasoning` / `enable_thinking` -> `providers.default.reasoning_mode = auto`，保留具体字段到 genai options 映射
- 旧 `extra_body` -> `providers.default.extra_body`
- `active_provider = "default"`

v2 加载规则：

```rust
pub fn load_app_config(path: &Path) -> Result<AppConfigV2, LlmProbeError>;
```

实现要求：

1. 先读取为 `serde_json::Value`，YAML 用 `serde_yaml` 转 value。
2. 如果存在 `version: 2` 或 `providers` 字段，按 v2 解析。
3. 否则按旧 `FileConfig` 解析并迁移。
4. v2 中未知字段默认允许，但 `--doctor-config` 输出 warning。
5. unknown field warning 必须基于原始 `Value` key 集合计算，不能依赖 serde 自动忽略后再猜。

测试名：

```rust
config::legacy::tests::migrates_flat_config_to_default_provider_profile
config::legacy::tests::preserves_context_and_extra_body
config::schema::tests::parses_v2_multi_provider_yaml
```

### 任务 3：新增 resolver，产出唯一运行时配置

文件变更：

```text
src/config/resolver.rs
src/config/validate.rs
src/config/mod.rs
src/config/schema.rs
```

新增结构：

```rust
#[derive(Debug, Clone)]
pub struct ResolvedRuntimeConfig {
    pub provider_name: String,
    pub adapter: String,
    pub model: String,
    pub base_url: Option<String>,
    pub api_key: String,
    pub api_key_source: ApiKeySource,
    pub stream: bool,
    pub timeout_seconds: Option<u64>,
    pub options: ResolvedChatOptions,
    pub context: Vec<Message>,
    pub openai_api: OpenAiApiMode,
    pub openai_api_enforced: bool,
    pub reasoning_mode: ReasoningMode,
    pub extra_body: serde_json::Map<String, serde_json::Value>,
    pub extra_body_supported: bool,
    pub unsupported_capabilities: Vec<UnsupportedCapability>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UnsupportedCapability {
    OpenAiApiNotEnforceable(OpenAiApiMode),
    SystemInstruction,
    LiveModelList,
    ToolCalling,
    ImageInput,
    AudioInput,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApiKeySource {
    Cli,
    Env(String),
    Config,
    EmptyAllowed,
}

#[derive(Debug, Clone, Default)]
pub struct ResolvedChatOptions {
    pub temperature: Option<f32>,
    pub max_tokens: Option<u32>,
    pub top_p: Option<f32>,
    pub reasoning_effort: Option<String>,
    pub capture_usage: bool,
    pub capture_reasoning_content: bool,
    pub normalize_reasoning_content: bool,
}
```

函数签名：

```rust
pub fn resolve_runtime_config(
    config: AppConfigV2,
    args: &Args,
) -> Result<ResolvedRuntimeConfig, LlmProbeError>;

pub fn validate_runtime_config(
    runtime: &ResolvedRuntimeConfig,
) -> Result<Vec<ConfigWarning>, LlmProbeError>;
```

解析规则必须固定：

1. `--provider` 优先，否则 `active_provider`，否则如果只有一个 provider 使用它，否则报错。
2. `--model` 覆盖 profile model。
3. `--url` 覆盖 profile base_url。
4. `--secret` / `--key` 覆盖所有 api key 来源。
5. 如果没有 CLI key，优先读 `api_key_env` 指定的环境变量。
6. 如果 env 不存在，再使用 profile `api_key`。
7. Ollama 允许空 key，其它 adapter 默认不允许空 key。
8. 顶层 `context` + provider `context` + CLI `--message` 合并，顺序不能变。
9. profile 未配置 `api_key_env` 时，按 adapter 使用默认环境变量。
10. `adapter` 统一使用 genai 命名；旧 provider alias 只能在 legacy 迁移层出现。

测试名：

```rust
config::resolver::tests::uses_active_provider_by_default
config::resolver::tests::cli_provider_overrides_active_provider
config::resolver::tests::cli_secret_overrides_env_and_config
config::resolver::tests::env_key_overrides_config_key
config::resolver::tests::merges_context_in_stable_order
config::validate::tests::rejects_unknown_provider_profile
config::validate::tests::marks_extra_body_as_genai_native
config::validate::tests::warns_unknown_v2_fields
```

### 任务 4：实现 dry-run 和 doctor-config

文件变更：

```text
src/config/schema.rs
src/main.rs
src/output/formatter.rs
```

CLI 新增字段：

```rust
pub endpoint: Option<OpenAiApiMode>,
pub reasoning_mode: Option<ReasoningMode>,
pub dry_run: bool,
pub doctor_config: bool,
pub allow_sdk_default_api: bool,
```

命名固定：

- CLI 使用 `--endpoint`，配置字段使用 `openai_api`。
- 原因：用户习惯说 endpoint；配置里明确它只影响 OpenAI API 族。
- resolver 中统一落到 `ResolvedRuntimeConfig.openai_api`。

`--dry-run` 输出必须稳定，便于测试：

```text
active_provider: dashscope_qwen
adapter: aliyun
model: qwen3-max
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
api_key_source: env:DASHSCOPE_API_KEY
stream: true
openai_api: auto
openai_api_enforced: false
reasoning_mode: legacy_reasoning_content
extra_body_supported: true
```

`--doctor-config` 行为：

- 不发起模型请求。
- 校验配置完整性。
- 打印 warnings。
- 如果存在 fatal error，退出码为 1。

测试名：

```rust
config::resolver::tests::dry_run_output_is_stable
config::validate::tests::doctor_reports_missing_api_key
```

### 任务 5：实现 genai mapper

文件变更：

```text
src/runtime/mapper.rs
src/runtime/target.rs
src/runtime/output.rs
src/runtime/mod.rs
```

函数签名：

```rust
pub fn build_genai_client(
    runtime: &ResolvedRuntimeConfig,
) -> Result<genai::Client, LlmProbeError>;

pub fn build_chat_request(
    runtime: &ResolvedRuntimeConfig,
) -> Result<genai::chat::ChatRequest, LlmProbeError>;

pub fn build_chat_options(
    runtime: &ResolvedRuntimeConfig,
) -> Result<genai::chat::ChatOptions, LlmProbeError>;

pub fn map_chat_response(
    response: genai::chat::ChatResponse,
    runtime: &ResolvedRuntimeConfig,
    started_at: std::time::Instant,
) -> RuntimeChatResponse;
```

实现要求：

- `system` 消息不能作为 user message 发送。
- 如果 genai 支持 system/instruction 字段，映射到该字段。
- 如果 genai 不支持独立 system 字段，则在 validate 阶段报 warning，并采用 genai 推荐方式。
- `capture_usage`、`capture_reasoning_content`、`normalize_reasoning_content` 默认启用。
- `extra_body` 必须映射到 genai 原生 request options，禁止自行解释字段。
- mapper 文件中禁止出现 `reqwest::Client`。
- mapper 文件中禁止出现字符串 `chat/completions` 或 `responses`。

测试名：

```rust
runtime::mapper::tests::maps_generation_options_to_chat_options
runtime::mapper::tests::does_not_map_system_as_user_message
runtime::mapper::tests::enables_reasoning_capture_by_default
```

### 任务 6：实现 GenaiRuntime 非流式

文件变更：

```text
src/runtime/genai_runtime.rs
src/main.rs
src/provider/mod.rs 或删除 provider 主路径引用
```

代码接口：

```rust
pub struct GenaiRuntime {
    client: genai::Client,
    config: ResolvedRuntimeConfig,
}

impl GenaiRuntime {
    pub fn new(config: ResolvedRuntimeConfig) -> Result<Self, LlmProbeError>;

    pub async fn chat(&self) -> Result<RuntimeChatResponse, LlmProbeError>;
}
```

输出模型固定：

```rust
pub struct RuntimeChatResponse {
    pub provider_name: String,
    pub adapter: String,
    pub model: String,
    pub content: Option<String>,
    pub reasoning_content: Option<String>,
    pub duration_ms: u64,
    pub input_tokens: Option<u32>,
    pub output_tokens: Option<u32>,
}
```

`RuntimeChatResponse` 是 output 层唯一输入，不允许 output 层直接依赖 genai 类型。

`main.rs` 主路径应改成：

```text
load_config_any
resolve_runtime_config
validate_runtime_config
if dry_run/doctor_config return
GenaiRuntime::new
if list -> list_models
else if stream -> stream_chat
else -> chat
```

验收命令：

```bash
/Users/test/.cargo/bin/cargo check
/Users/test/.cargo/bin/cargo test
```

### 任务 7：实现 GenaiRuntime 流式

文件变更：

```text
src/runtime/genai_runtime.rs
src/runtime/output.rs
src/output/stream.rs
```

接口：

```rust
impl GenaiRuntime {
    pub async fn stream_chat(&self) -> Result<(), LlmProbeError>;
}
```

事件映射要求：

| genai event | llmctl 输出 |
|---|---|
| reasoning chunk | `Thinking` 分段 |
| text chunk | `Response` 分段 |
| usage/end | token 和 duration |
| tool event | 暂时输出结构化 debug 或 unsupported warning |

流式状态机固定：

```text
state = Start
ReasoningChunk:
  if state != Thinking: print Thinking header
  print reasoning delta
  state = Thinking
TextChunk:
  if state != Response: print Response header
  print text delta
  state = Response
End:
  print metadata
```

禁止每个 chunk 重复打印 header。

网络流式 smoke 测试只能放在 ignored test 或 example 中：

```rust
#[tokio::test]
#[ignore = "requires provider API key and network"]
async fn smoke_stream_openai_or_compatible() {}
```

测试名：

```rust
runtime::output::tests::formats_reasoning_before_response
runtime::output::tests::does_not_repeat_section_headers
runtime::output::tests::prints_usage_on_end_event
```

### 任务 8：模型列表

文件变更：

```text
src/runtime/models.rs
src/runtime/genai_runtime.rs
```

接口：

```rust
pub enum ModelListSource {
    GenaiStatic,
    ProviderLive,
    Unsupported,
}

pub struct ModelListResult {
    pub source: ModelListSource,
    pub models: Vec<ModelInfo>,
}
```

规则：

1. 优先使用 genai 模型列表能力。
2. 如果 genai 只能给静态列表，输出 `source: genai-static`。
3. 对 OpenAI-compatible custom endpoint，允许保留 list-only HTTP helper 调 `/models`。
4. 这个 helper 只能放在 `runtime/models.rs`，不能被 chat 路径调用。

测试名：

```rust
runtime::models::tests::labels_static_model_list_source
runtime::models::tests::uses_live_models_only_for_custom_openai_compatible
```

### 任务 9：删除旧 llm 主路径

删除或停止主路径引用：

```text
src/builder.rs
src/backends/openai.rs
src/backends/openai_reasoning.rs
src/provider/client.rs
src/provider/traits.rs
```

删除前置条件：

```text
1. cargo test 通过。
2. genai spike 中非流式 chat 至少覆盖 OpenAI、Anthropic、Gemini、Ollama。
3. genai spike 中流式 chat 至少覆盖 OpenAI 或 OpenAI-compatible。
4. reasoning capture/normalization 有明确结论。
5. extra_body 已通过 genai 原生能力映射，并有单元测试覆盖。
6. --dry-run 能标明所有 unsupported capability。
7. `cargo test` 不需要任何 API key 或外部网络。
8. examples 可编译，真实调用文档化为手动 smoke。
```

`Cargo.toml` 移除：

```toml
llm = ...
```

保留 `reqwest` 的条件：

- 只用于 `list_models` live helper。
- 不用于 chat。

验收命令：

```bash
rg "llm::|OpenAIWithReasoning|openai_reasoning|LLMBuilder|chat/completions|responses" src
/Users/test/.cargo/bin/cargo check
/Users/test/.cargo/bin/cargo test
```

允许 `chat/completions`、`responses` 只出现在文档、配置枚举、dry-run 文本或 genai spike 记录中，不允许出现在 llmctl 自己的请求代码里。

### 任务 10：更新文档与模板

文件变更：

```text
README.md
README_CN.md
src/utils/prompt.rs
docs/架构设计文档.md
```

必须更新：

- 默认 init 配置改成 v2 multi-provider 格式。
- README 示例改用 `active_provider`。
- 标明 OpenAI API 选择当前是否 enforce。
- 标明 `extra_body` 使用 genai 原生能力。

验收：

```bash
./target/debug/llmctl --init yaml --init-path /tmp/llmctl-v2.yaml
```

生成文件必须是 v2 配置。

## 13. 分阶段实施计划

### 阶段 0：genai spike

目标：确认 genai 能满足主路径。

任务：

- 新增 `examples/genai_probe.rs`。
- 验证 OpenAI、Anthropic、Gemini、Ollama 最小 chat。
- 验证 OpenAI-compatible custom endpoint。
- 验证 stream 输出。
- 验证 reasoning capture/normalization。
- 验证 `extra_body` 是否通过 genai 原生能力透传。
- 验证是否能指定 OpenAI Responses / Chat Completions。

验收：

- spike 文档记录每个能力：supported / unsupported / workaround。
- unsupported 能力不能阻塞主迁移，但必须进入 validate/dry-run。

### 阶段 1：配置 v2

任务：

- 新增 `AppConfigV2`、`ProviderProfile`、`Defaults`。
- 新增旧 flat config 到 v2 的迁移逻辑。
- 新增 `active_provider` 解析。
- 新增 `--provider`、`--endpoint`、`--reasoning-mode`、`--dry-run`。

验收：

- 旧配置仍可运行。
- 一个 YAML 可配置多个 provider。
- CLI 能覆盖 active provider。

### 阶段 2：GenaiRuntime 非流式

任务：

- 加入 `genai` 依赖。
- 新增 `runtime/genai_runtime.rs`。
- 实现非流式 chat。
- 映射 context、system、model、api_key、base_url、ChatOptions。

验收：

- OpenAI / Anthropic / Gemini / Ollama 至少各有一个 smoke config。
- `cargo test` 覆盖 resolver 和 mapper。

### 阶段 3：GenaiRuntime 流式

任务：

- 接入 genai stream API。
- 映射 reasoning chunk 与 text chunk。
- 输出层保留 Thinking/Response 分段展示。
- 输出 usage、model、duration。

验收：

- stream 模式不再依赖 `openai_reasoning.rs`。
- legacy `reasoning_content` 由 genai 归一化后输出。

### 阶段 4：清理旧 llm 依赖

任务：

- 删除自定义 builder 与 backends。
- 移除 `llm` crate。
- 更新 README / README_CN / init config 模板。
- 删除未使用模块和旧示例。

验收：

- `cargo check`、`cargo test` 通过。
- `rg "llm::|OpenAIWithReasoning|openai_reasoning|LLMBuilder"` 无主路径引用。

### 阶段 5：模型列表与能力检查

任务：

- 优先接 genai model list。
- 对 custom endpoint 保留 list-only HTTP helper。
- 完成 `--doctor-config`。

验收：

- `llmctl -l` 能标明模型来源：`genai-static`、`provider-live`、`unsupported`。

## 14. 测试计划

单元测试：

- v1 flat config -> v2 config。
- provider profile 解析。
- CLI 覆盖优先级。
- api key env 解析。
- ChatOptions 映射。
- unsupported capability 校验。
- dry-run/doctor-config 脱敏。
- unknown field warning。

集成测试：

- `--dry-run` 输出稳定。
- `--provider <name>` 切换 profile。
- stream event 映射 Thinking/Response。
- extra_body 映射到 genai 原生能力。
- explicit `openai_api` 无法 enforce 时失败明确。

Fixture：

```text
tests/fixtures/config_v1.yaml
tests/fixtures/config_v2_multi_provider.yaml
tests/fixtures/dry_run_dashscope.txt
tests/fixtures/dry_run_openai.txt
tests/fixtures/doctor_unknown_field.txt
```

禁止放入常规测试：

- 真实 API key。
- 真实外部 HTTP 调用。
- 依赖本机 Ollama 服务启动状态的非 ignored 测试。

## 15. SDK 选择复核

最终选择：`genai`。

| SDK | 结论 | 原因 |
|---|---|---|
| `genai` | 选择 | 多 provider、custom endpoint、reasoning content、streaming 覆盖最贴近 llmctl |
| `rig` | 不选 | 更偏 agent/RAG 框架，抽象太重，容易牵引项目偏离 CLI 探针 |
| `multi_llm` | 不选 | provider 覆盖不足，缺 Google/Gemini 等关键目标 |
| OpenAI 官方 SDK | 不选 | 只解决 OpenAI，不满足多平台主目标 |

`genai` 仍有风险：

- OpenAI API endpoint 是否可显式指定需要 spike 确认。
- `extra_body` 通过 genai 原生能力使用，不做自定义适配。
- live model list 能力可能不足。

处理原则：

- 能力不足时优先上游 patch。
- 不在 llmctl 内复制一个新 SDK。
- unsupported 显式失败，不静默降级。

## 16. 资料依据

- `genai` docs.rs：0.5.3 文档列出多 provider、custom endpoint、streaming、`reasoning_content` 归一化相关能力。见 <https://docs.rs/crate/genai/0.5.3>。
- `genai` ChatOptions：包含 usage/reasoning capture 与 normalization 相关配置。见 <https://docs.rs/genai/latest/genai/chat/struct.ChatOptions.html>。
- `genai` stream source：包含 reasoning/text stream event。见 <https://docs.rs/genai/latest/src/genai/chat/chat_stream.rs.html>。
- `genai` docs.rs：0.4.x 发布说明提到对 OpenAI Responses API 的透明支持。见 <https://docs.rs/crate/genai/0.4.4>。
- OpenAI 文档：Responses 与 Chat Completions 的定位和迁移说明。见 <https://platform.openai.com/docs/guides/responses-vs-chat-completions> 与 <https://platform.openai.com/docs/guides/migrate-to-responses>。
