# pi-provider-manager

> 给 [pi 编程助手](https://pi.dev) 加上可视化配置面板、智能轮询故障转移引擎与渠道健康统计——一个扩展读懂、调优你所有大模型渠道。

一个 pi 扩展，三项核心能力：

- **可视化配置面板**：`/providers` 打开本地网页面板，图形化编辑 `models.json` 与轮询配置，拖拽排序、在线发现上游模型、一键保存即热重载。
- **轮询故障转移引擎**：把多个真实渠道/模型注册成一个虚拟 `roundrobin` 模型，请求时自动故障转移——首个候选超时或出错就切下一个，全程你无感。可选开启**测速排序**，让最快的渠道永远排在前面。
- **渠道健康统计**：7 天滚动成功率、首字延迟（TTFT）、总延迟，被动采集、跨 CLI 共享，面板一目了然。

---

## 特性一览

### 🎛 可视化配置面板

`/providers` 启动本地网页服务器（仅 `127.0.0.1`），浏览器打开两个标签页：

- **模型配置**：逐字段编辑 `~/.pi/agent/models.json`——provider、模型、API Key、请求头、compat 字段、thinking level map 全覆盖。支持从上游端点拉取可用模型、与已配置模型 diff、一键增删。
- **轮询配置**：编辑 `~/.pi/agent/roundrobin/config.json`，配置虚拟模型元数据、候选池、超时/冷却、测速排序策略，保存即热重载。支持预设组（多套候选组合一键切换）。

面板走**固定端口 17890 + 持久化 token**：多终端共用同一实例——第二个 CLI 跑 `/providers` 检测到端口已占用，直接打开已有面板而非重启。面板闲置 5 分钟自动关闭；前台打开时每 3 秒轮询健康数据顺带保活，活跃面板永不超时；由本进程启动的 server 在 pi 会话退出时一并关闭。

### 🔄 轮询故障转移引擎

把多个真实渠道注册成一个虚拟模型 `roundrobin/<组名>`，用 `/model` 选中它，之后所有请求走故障转移引擎：

1. 请求按顺序试候选，**首个成功的就粘住**（sticky 策略）。
2. 当前候选在**首响应阶段**超时或出错 → 自动切下一个候选。
3. 单候选原地重试 `maxRetriesPerCandidate` 次（指数退避）后才换渠道；耗尽则进冷却。
4. 整轮全炸：测速关闭时清冷却重试 + 等最早冷却结束；测速开启时**重测排序再战**。
5. 流中途出错（内容已吐出）直接返回不重放——避免内容/工具调用乱序；该候选仍记一次失败 + 进冷却（让 7 天统计与 smart 排序能看到“常吐一半断”的渠道）。

真正发生故障转移时，TUI 弹一次 toast：`↔ 轮询故障转移到 XXX`——纯提示不进对话历史，不污染 LLM 上下文。

> **请求隔离**：请求 `glm` 组绝不会测速/影响到 `deepseek` 组——按组名严格隔离。

### ⚡ 测速排序（可选，强烈推荐）

开启后，引擎不再只会被动重试——它会**主动测量每个候选的真实速度**并重新排队。

**怎么测**：给每个候选发一个极简真实对话（普通候选 `maxTokens=16`，reasoning 候选抬到 2048——Anthropic 类 API 开 thinking 时要求 `max_tokens > thinking budget` 最小 1024，16 会被 400 拒绝导致误杀），默认 prompt `欧拉函数的意义？`，复用面板模型测试的同一套 `streamSimple` 内核——**看起来就是正常聊天流量，不会被当成探活封号**。测量首字延迟（TTFT）与总延迟。

> **TTFT 兑底**（v0.4.2）：TTFT 优先认首个 `text_delta`（真正首字）；reasoning 模型小 maxTokens 可能全花在 thinking 上、永不产 `text_delta`，此时退而认首个 `thinking_delta`（模型开始产出的信号）作为 TTFT 兑底，避免该候选被当“拿不到首字”直接垫底。
>
> **测速失败自动重试**：单次测速失败且非 abort/鉴权问题 → 退避 1.5s 重试，最多共 3 次尝试（首试 + 重试 2 次）。三连败才判 `✗` + recordFailure 进冷却。这是为了不把“基本可用但暂时抖动”的渠道一次判死——一个 86% 成功率的渠道，三连败概率仅 ≈0.3%，抖动几乎必能救回；真挂的渠道三次都败判死正确，多花的只是注定失败的请求（不耗 token）。另：只要测速最终 ok=true，该候选就排在所有失败候选之前（“能用”本身就是兑底）；ttft/latency 缺失时用组内中位数参与排序（v0.4.2 起）。真实请求路径的 `maxRetriesPerCandidate` 原地重试哲学同样适用于测速路径。

**四种排序键**：

| 排序键 | 算法 | 适用场景 |
|--------|------|----------|
| `ttft` | 首字延迟（默认） | 追求交互体感，首字快=响应快 |
| `latency` | 总延迟 | 追求整轮最快 |
| `hybrid` | `0.7×ttft + 0.3×latency` 加权和 | 兼顾首字与总延迟 |
| `smart` | `0.5×ttft_norm + 0.3×(1−reliability) + 0.2×latency_norm` 三维加权 | **又快又稳**，可靠性差的候选被压下去 |

> **smart 的可靠性兜底**：reliability 用**贝叶斯平滑**从 7 天历史算：`(success + 2.5) / (total + 5)`（先验 = 5 次 50% 成功率）。
> - 全新候选（0 次）→ 0.5 中性，给机会但不越过高可靠老候选
> - 单次成功（1/0）→ 0.583，往中性拉回，不被单次结果带偏
> - 10 次全成 → 0.833，高但留余地；10 次全败 → 0.167 沉底，新候选能排它前面
>
> 速度维度做组内 min-max 归一化（0=最快），加权后分数越小越好，升序排序。测速失败的候选进冷却排末尾。
>
> **中位数兑底**（v0.4.2）：ttft/latency 缺失（null）的可用候选，用**组内实测中位数**参与排序（语义：该维度未知 → 假设中等水平），而非直接垫底 Infinity。不污染持久化字段——health.jsonl 仍存真实 null，面板仍显示“无首字”，仅排序时用中位数代理。全组都没测出该维度才退回 Infinity。

**测速后**：成功候选清冷却上前、失败候选进冷却沉底、`currentIndex` 强制归 0（放弃旧 sticky 位置——实测速度是更强的实时信号）。排序结果保持到下次测速。

**何时触发**：

1. **首次请求某组**（新增，懒触发）——本会话内首次请求一个开启了测速的轮询组时，**同步**跑一次测速排序：等排完再发首请求（首次就享受排序，代价是首请求多等几秒到几十秒）。不同组独立判定“首次”（`lastSpeedTestAt===0`），面板保存配置不再重置这个状态。**v0.4.1 改动**：取代了原先“session_start / 面板保存就狂测所有组”的行为——现在启动后不测，用到哪个组才测哪个。
2. **请求整轮全炸**——所有候选试过 + 重试耗尽 + 全冷却，触发重测重排。受 `minIntervalMs`（默认 60s）节流，避免持续故障时疯狂烧 token。
3. **手动**——`/rr-speedtest [组名]` 命令，或面板 ⚡ 按钮，**绕过节流立即重测**。`/rr-speedtest` 还会在终端上方打印详细结果表（逐候选 TTFT/延迟/排序/失败原因，见下文「手动测速」），30s 后自动消失。

### ⏱ 动态请求超时（测速开启时自动启用）

测速关闭时，单候选首响应超时 = 静态 `timeoutMs`（默认 30s）。**测速开启后，超时变动态**：

```
动态超时 = max(timeoutMs, min(120000, round(实测 ttft × 2.0)))     // 下限 = 你配的 timeoutMs(面板可调); 上限 120s 防病态样本(曾测出 ttft 327s)把超时抬到很大。中转站波动大就调大 timeoutMs 兼容临时劣化
```

这个动态值同时守护两处：

1. **首响应**——首个流事件到达前的等待上限（替代静态 `timeoutMs`）。
2. **流中空闲**——start 之后任意两个 chunk 之间的停顿上限。**v0.4.0 新增**：以前流一旦 start 就再无超时保护，候选首字几秒到达后慢慢吐几十秒，pi 一直干等——这就是"卡死"的根因。现在流中卡顿超过动态超时立即 abort。

**没测出首字的候选**（ttft=null，测速时就没拿到首字）回退静态 `timeoutMs`（默认 30s），null 回退双保险，不会被动态超时误杀。

**超时后**：当作普通流前失败处理——**消耗一次 `maxRetriesPerCandidate`**（不是立即换渠道），退避后重试同一候选，耗尽才进冷却换渠道。想"超时即换"就把 `maxRetriesPerCandidate` 设 `0`。内容已转发后的流中空闲超时属 `terminal`（不能重放，直接终止）；该候选仍记一次失败 + 进冷却（与前述流中途出错一致，让 health/smart 看到质量问题）。

面板轮询 tab 每个候选显示计算出的动态超时值（"超时 26s"），一眼看清每个候选当前的有效超时。

### 📊 渠道健康统计

每个渠道的 7 天滚动统计显示在面板（历史均值，非仅当前进程）：

- 成功/失败次数与成功率
- 平均首字延迟（TTFT）
- 平均总延迟

每次请求追加一行到 `~/.pi/agent/roundrobin/health.jsonl`，多 CLI 共享（无文件锁，best-effort）。启动时自动剪除 7 天外旧事件。TTFT 取首个 `text_delta` 到达时刻，Latency = `message.timestamp` 到 `message_end`——不依赖队列配对，中断/取消不污染延迟统计。

> **轮询候选不再双计数**（第四轮审计修复）：早期版本里轮询获胜候选会在 `health.jsonl` 被双写（引擎 `recordSuccess/Failure` 一次 + 全局 `message_end` 被动采集又一次），总数×2。现已修复：成功转发的 done 事件改写为虚拟模型 provider，`message_end` 钩子不再重复记录，每候选只落一条。

### 🔧 手动测速

- **在 pi 里**：`/rr-speedtest`（所有开启测速的组）或 `/rr-speedtest <组名>`（指定组，**支持 Tab 补全组名**）。测完在终端编辑器上方打印逐候选详细表：每个候选一行，按排序顺序显示 `#排名 TTFT 延迟`（可用）或 `✗ 失败原因`（不可用），30s 后自动消失，同时弹 toast 摘要。
- **在面板里**：轮询 tab "测速排序" 行的 ⚡ 按钮，调用 `POST /api/rr/manual-speedtest`。

两者都绕过 `minIntervalMs` 节流，但尊重 `speedTestRunning` 锁（不会对正在测速的组重复测）。

---

## 安装

```bash
pi install npm:@arcaneorion/pi-provider-manager
```

## 使用

1. `/providers` 启动面板，在轮询 tab 添加候选（从已配置模型里选）、保存配置。
2. `/model` 选择 `roundrobin/<组名>`。
3. 之后所有请求走故障转移引擎。

保存即热重载——轮询引擎立即 pickup 新候选，无需重启。

> **跨 CLI 局限**：热重载只对**当前面板所属的 CLI 进程**即时生效。若你有多个 pi 终端在跑，其他终端的轮询组不会自动重载（显示的候选/配置仍是旧的），需重启该终端或在其内重新触发加载。

---

## 配置

### models.json

标准 pi `models.json`，面板支持完整 schema 编辑。

### roundrobin/config.json

```json
{
  "virtualModel": {
    "id": "roundrobin",
    "name": "Model Round Robin",
    "reasoning": true,
    "input": ["text", "image"],
    "contextWindow": 200000
  },
  "candidates": [
    { "provider": "my-openai", "model": "gpt-4o" },
    { "provider": "my-anthropic", "model": "claude-sonnet-4-20250514" }
  ],
  "log": true,
  "timeoutMs": 30000,
  "cooldownMs": 60000,
  "strategy": "sticky",
  "maxRetriesPerCandidate": 2,
  "speedTest": {
    "enabled": false,
    "sortKey": "smart",
    "prompt": "欧拉函数的意义？",
    "timeoutMs": 60000,
    "concurrency": 5,
    "minIntervalMs": 60000
  }
}
```

#### 字段说明

| 字段 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `virtualModel` | object | — | 虚拟模型元数据（id/name/reasoning/input/contextWindow/maxTokens/thinkingLevelMap/compat）。`id` 在加载时强制为组名；`maxTokens` 未设默认 `16384`，`contextWindow` 未设默认 `200000`。 |
| `candidates` | array | `[]` | `[{ "provider": "...", "model": "..." }, ...]`。未知组合会被跳过；解析后为空则禁用该组。 |
| `log` | boolean | `true` | 追加到 `~/.pi/agent/roundrobin/roundrobin.log`。 |
| `timeoutMs` | number | `30000` | 单候选首响应超时；测速开启且拿到 ttft 时作为**动态超时下限** `max(timeoutMs, min(120000, ttft×2.0))`，没有 ttft 时回退它本身。中转站波动大就调大(如 45000=45s)，给临时劣化更多恢复时间。 |
| `cooldownMs` | number | `60000` | 候选失败（重试耗尽）后的冷却窗口。 |
| `strategy` | `"sticky"` | `"sticky"` | 成功后候选推进策略：`sticky`=黏住当前候选直到失败，冷却后回首选；`round-robin`=成功后指向下一个候选(均分流量)；`primary`=恒回首选(0)，仅首选冷却/失败时用备选。 |
| `maxRetriesPerCandidate` | number | `2` | 单候选原地重试次数（不含首试），指数退避。设 `0` = 超时/失败即换渠道。 |
| `speedTest.enabled` | boolean | `false` | 开启测速排序（见上方测速章节）。 |
| `speedTest.sortKey` | `ttft`/`latency`/`hybrid`/`smart` | `ttft` | 排序键。`hybrid` = `0.7×ttft+0.3×latency`；`smart` = 三维加权含贝叶斯平滑的 7 日成功率。 |
| `speedTest.prompt` | string | `"欧拉函数的意义？"` | 测速 prompt，空则用默认。复用面板模型测试同一真实对话内核。 |
| `speedTest.timeoutMs` | number | `60000` | 单次测速尝试的超时（≥1000）。失败后退避 1.5s 重试，最多 3 次尝试（`retries` 默认 2）。太短会误判慢但可用的候选。 |
| `speedTest.concurrency` | integer | `5` | 并行测速 worker 数（≥1）。设为候选数 = 全组并行测。 |
| `speedTest.minIntervalMs` | number | `60000` | 自动测速节流间隔（≥0）。手动 `/rr-speedtest` 与 ⚡ 按钮绕过此节流。 |
| `speedTest.retries` | integer | `2` | 单次测速失败后重试次数（不含首试），退避 1.5s。设 `0` = 失败即判 `✗`（快但无抖动容错）。面板可编辑。 |

---

## 安全

- **仅监听 `127.0.0.1`**：本机回环，不暴露到网络。
- **192-bit token 鉴权**：首次启动生成（`randomBytes(24)`）。为支持多 CLI 无缝复用同一面板，token **持久化**到 `~/.pi/agent/roundrobin/.panel-token`，跨会话/跨终端复用，**非每次随机**；所有 `/api/*` 路由必须带 `X-Config-Token` 头。
- **同机威胁模型**：本地 `127.0.0.1` only，同机其他进程理论上能读到 token 文件或访问端口——这是用「固定 token 换多 CLI 复用」的明确取舍。介意可删 `~/.pi/agent/roundrobin/.panel-token` 强制重置。
- **API Key 服务端解析**：支持 `$ENV_VAR`（如 `$OPENAI_API_KEY`，运行时从环境变量取值），浏览器永远拿不到解析后的明文。
- **保存自动备份**：每次写 `models.json` / `config.json` 前先复制一份带时间戳的 `.bak`，误改可回退。

## 开发

仓库含 9 个 vitest 测试套件（`tests/`），覆盖配置解析、健康存储、轮询故障转移、前后端联动等。

## 许可证

MIT
