# pi-relay-switch — 第三方中转站管理插件

为 [pi](https://github.com/earendil-works/pi-coding-agent) 提供**多中转站（API Relay）管理**：切换中转站、拉取模型列表、管理 API Key，并把推理档位（thinking level）真正打通到中转站。

> English docs: [README.md](./README.md)

## 特性

- **一个命令管所有**：`/switch` 覆盖添加 / 删除 / 更换中转站
- **权威模型元数据**：从 `/models` 响应读取 `supports_reasoning` / `supports_vision` / `context_length` / 定价，不靠猜
- **推理档位打通**：支持推理的模型自动带上 `reasoning_effort` 配置，`shift+tab` 循环档位真实生效
- **可视化反馈**：启动时编辑器上方常驻中转站列表 widget（含状态、模型数、当前模型）
- **旧配置迁移**：首次运行自动从 `models.json` 迁移 providers，不丢历史配置
- 兼容 OpenAI 兼容（`/v1`）与 Anthropic 原生（`/v1/messages`）两类接口

## 安装

### npm 安装（推荐）

```bash
pi install npm:pi-relay-switch
```

### git 安装

```bash
pi install git:github.com/<你的用户名>/pi-relay-switch@v0.1.0
```

### 手动拷贝（开发调试）

插件即目录，直接放在 pi 的扩展目录：

```text
~/.pi/agent/extensions/pi-relay-switch/
├── index.ts    # 命令入口 / UI
├── config.ts   # 配置读写 / 迁移
├── detect.ts   # 连通性检测
└── models.ts   # 模型元数据提取 / 配置推断
```

修改代码后**重启 pi**（或 `/reload`）生效。

## 快速上手

```text
/switch              # 操作菜单：更换 / 更新模型列表 / 添加 / 删除
/switch add          # 直接添加中转站（交互式：类型 → Base URL → API Key）
/switch remove <id>  # 直接删除中转站
/switch refresh [id] # 重新拉取中转站的模型列表（多站无 id 时弹选择器）
/switch my-relay     # 直接更换到该中转站（随后弹出模型列表确认模型）
```

## 命令参考

### `/switch` — 更换 / 更新 / 添加 / 删除

| 输入 | 行为 |
| --- | --- |
| `/switch` | 打开操作菜单：**更换中转站** / **更新模型列表** / **添加中转站** / **删除中转站**，选中后进入对应流程 |
| `/switch <id>` | 直接更换到该中转站（如 `/switch my-relay`），随后弹模型列表确认/换模型（🧠 推理标记 + 上下文窗口） |
| `/switch refresh [id]` | 重新拉取中转站的 `/models` 列表并刷新缓存元数据；展示新模型列表（enter 切换、不二次弹窗）。不写 id 且多站时弹选择器 |
| `/switch add` | 交互式添加中转站（类型 / Base URL / API Key / 显示名），添加后检测并可选立即更换 |
| `/switch remove <id>` | 删除中转站（交互式确认，删除当前站会清空默认设置）；不写 id 则弹选择器 |

**接口类型选择**（添加时第一步）：

| 类型 | 协议 | 请求头 | 适用场景 |
| --- | --- | --- | --- |
| OpenAI 兼容 (`/v1`) | Chat Completions | `Authorization: Bearer` | 绝大多数中转站/聚合站（默认选这个） |
| OpenAI Responses (`/v1/responses`) | Responses API | `Authorization: Bearer` | 中转站支持 Responses 协议、需要原生能力（web search 等）时 |
| Anthropic 原生 (`/v1/messages`) | Anthropic Messages API | `x-api-key` + `anthropic-version` | 中转站原生转发 Claude 原始接口时 |

> 选错类型会在检测阶段暴露（401 认证头不对 / 404 端点不存在），删掉重加即可。

> 参数补全：第一参数同时补全**子命令**和**中转站 id**（如输入 `/switch my` 直接补全 `my-relay`）。
> **选择器支持打字即时过滤**：打开选择器后直接输入字符即可前缀过滤（如输入 `deep` 只剩 deepseek 模型），`backspace` 删除过滤词，`esc` 先清空过滤再关闭。

### 快捷键

| 快捷键 | 行为 |
|---|---|
| `ctrl+shift+r` | 快速切换中转站（打开选择器，单站时直接切换） |

### 小便利

- 只有一个中转站时，`/switch` / `ctrl+shift+r` 直接更换，不弹选择器
- **模型切换自动同步**：用 pi 的 `/model` / `ctrl+l` 切换模型后，插件自动更新对应中转站的 `lastModel`（当前站跟随切换到的模型所属中转站），列表 widget 实时刷新

## 交互流程

**切换**：选站 → （模型缓存为空时自动检测+拉取）→ 注册 provider → 弹模型列表（预选中上次用的模型，`enter` 确认 / `esc` 保持默认）→ 写入 `relays.json` + `settings.json` → 通知当前推理档位。

**刷新**：拉取 `/models` → 更新缓存元数据 → 弹模型列表 → 选中即切站（不重复弹模型选择）。

**删除当前站**：自动注销 provider 并清除 `settings.json` 的默认 provider/model。

## 推理档位（thinking level）

pi 内置 `shift+tab` 循环推理档位：`off → minimal → low → medium → high → xhigh → max`。

插件做的事：

1. **标记推理模型**：优先读取 `/models` 响应里的 `supports_reasoning` 字段（权威）；中继不提供该字段时，按模型 id 启发式兜底（deepseek / kimi-k2 / glm-4.5+ / qwen3 / minimax-m2 / gemini-2.5+ / claude-4+ / o1-o4 / reasoner·thinking 等）
2. **打通发送**：推理模型（OpenAI 兼容类型）自动附加：

   ```ts
   compat: { supportsReasoningEffort: true },
   thinkingLevelMap: {
     minimal: "minimal", low: "low", medium: "medium", high: "high",
     xhigh: "high",   // 收敛到 high，避免中继拒绝
     max: "high",
   }
   ```

   —— 没有这层配置，pi 的 OpenAI 兼容适配器**不会**把 `reasoning_effort` 发给中转站。
3. **可视化**：模型选择列表里带能力标记——🧠 = 支持推理档位（shift+tab），右侧显示上下文窗口（如 `1M ctx`）；切换成功通知带上当前档位（如 `推理档位: high`）。

### 手动覆盖

若某模型被误判（中继拒绝 `reasoning_effort` 报 400），在 `relays.json` 里对该模型条目设置**手动覆盖** `reasoning`：

```json
{ "id": "some-model", "reasoning": false }
```

手动覆盖**优先于** `/models` 权威数据，刷新模型列表时会被保留、不会被冲掉。

> 字段分工：`reasoning` = 手动覆盖（你写的）；`supportsReasoning` = `/models` 拉取的权威数据（刷新时自动更新）。

## 模型元数据

添加/更换中转站时，会从 `/models` 响应提取并缓存每个模型的：

| 字段 | 来源 | 用途 |
| --- | --- | --- |
| `supportsReasoning` | `supports_reasoning` | 是否支持推理档位（shift+tab）；`reasoning` 手动覆盖优先于它 |
| `vision` | `supports_vision` | 是否支持图片输入 |
| `contextWindow` | `context_length` | pi 的上下文窗口统计 |
| `maxTokens` | `max_completion_tokens` | 最大输出 token |
| `cost` | `*_price_per_million` | pi 的成本统计（元/百万 token） |

中继不提供这些字段时回退到保守默认值（推理按 id 启发式，价格记 0，上下文 128k）。

## 配置文件 `relays.json`

位于 `~/.pi/agent/relays.json`，结构示例：

```json
{
  "version": 1,
  "currentRelay": "my-relay",
  "relays": [
    {
      "id": "my-relay",
      "name": "my-relay",
      "type": "openai",
      "baseUrl": "https://api.example.com/v1",
      "apiKey": "sk_xxx",
      "headers": { "User-Agent": "MyClient/1.0" },
      "models": [
        {
          "id": "gpt-4o-mini",
          "supportsReasoning": true,
          "contextWindow": 1000000,
          "cost": { "input": 1, "output": 2, "cacheRead": 0.2, "cacheWrite": 0 }
        }
      ],
      "status": "ok",
      "lastModel": "gpt-4o-mini",
      "latencyMs": 142,
      "lastChecked": 1786700250424
    }
  ]
}
```

- `type`：`openai`（OpenAI 兼容 `/v1`）、`openai-responses` 或 `anthropic`（Anthropic 原生 `/v1/messages`）
- `headers`：可选，覆盖默认请求头（如某些站要求特定 User-Agent）
- `models[].supportsReasoning`：来自 `/models` 的权威数据，刷新时自动更新
- `models[].reasoning`：可选**手动覆盖**推理能力判断（优先于 `supportsReasoning`，刷新保留）
- `lastModel`：该站上次使用的模型，切换时优先恢复

## 从 `models.json` 迁移

首次运行（`relays.json` 不存在或为空）且 `models.json` 里有 providers 时，自动迁移：

- 每个 provider 转成一个 relay（按 `api` 字段判断类型）
- `settings.json` 的 `defaultModel` 匹配到则记为 `lastModel`
- 迁移完成后清空 `models.json` 的 providers，避免重复注册

## 安全注意事项

> **⚠️ API Key 以明文存储在** `~/.pi/agent/relays.json`（新文件按 `0600` 权限创建）。**切勿提交该文件**——本仓库的 `.gitignore` 已默认忽略。

> **⚠️ pi 扩展拥有完整系统权限、可执行任意代码。** 只安装你信任来源的包，安装第三方扩展前请审阅源码。

## 注意事项 / FAQ

- **改代码要重启 pi**：扩展是运行时加载的
- **启动自动恢复**：插件在加载阶段（早于会话开始）就注册当前中转站，配合 `settings.json` 的 `defaultProvider`/`defaultModel` 自动恢复上次的模型，不会出现 `No models available` 警告（若注册提前到工厂阶段之前还没生效，说明扩展缓存未刷新，重启一次即可）
- **reserved 关键字冲突**：若中转站 id 恰好叫 `add` / `remove` 等子命令关键字，优先走管理子命令（id 由域名生成，实际几乎不会撞上）
- **检测超时**：默认 8s，超时按「不可达」处理（可 esc 取消）
- **API Key 失效**：检测到 401/403 标记为「API Key 无效」，不中断其他站
- **模型列表为空**：更换时若拉不到模型，会用 `lastModel` 或第一个模型；都没有则提示稍后重新 `/switch` 更换

## License

[MIT](./LICENSE)
