custom-provider-pi 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +218 -191
  2. package/custom-provider.ts +163 -13
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,136 +1,186 @@
1
1
  # custom-provider
2
2
 
3
- pi 扩展:在 TUI 或 RPC(Telegram 等)环境下统一管理第三方模型 Provider。
3
+ > [!NOTE]
4
+ > 这是一个 [pi](https://github.com/anthropics/claude-code) 的第三方扩展,用于在 TUI 或 RPC(Telegram 等)环境下统一管理模型 Provider。
4
5
 
5
- 一个命令入口 `/custom-provider`,九个子命令覆盖添加、删除、刷新、测试、查看/编辑配置、启用/禁用与模型修剪;既支持交互引导,也支持 `--flags` / JSON 参数非交互添加(脚本、Telegram 可用)。同一 Provider 内可混用 OpenAI 与 Anthropic 协议(按模型覆盖)。
6
+ [![npm version](https://img.shields.io/npm/v/custom-provider-pi)](https://www.npmjs.com/package/custom-provider-pi)
7
+ [![license](https://img.shields.io/npm/l/custom-provider-pi)](LICENSE)
8
+ [![pi package](https://img.shields.io/badge/pi-package-violet)](#installation)
6
9
 
7
- ## 安装
10
+ ## 概述
11
+
12
+ `custom-provider` 为 pi 提供了一个统一的 Provider 管理入口 `/custom-provider`,支持:
13
+
14
+ - **10 个子命令**:`add` / `remove` / `refresh` / `list` / `test` / `config` / `enable` / `disable` / `prune` / `help`
15
+ - **简单/自定义双模式添加**:简单模式只需 URL 即可自动探测协议与模型列表;自定义模式支持代理、多 Key 负载均衡、请求头模板、多模态、高级配置等
16
+ - **非交互式添加**:flags / JSON 参数,适用于脚本、Telegram RPC 等场景
17
+ - **多协议混用**:同一 Provider 内不同模型走不同协议(OpenAI / Anthropic / Google)
18
+ - **自动探测**:输入 URL 自动尝试多种协议路径,检测协议类型与可用模型
19
+ - **模型过滤与修剪**:添加时按关键字过滤,事后随时修剪
20
+ - **负载均衡**:多 Key 轮询 + 429 自动冷却(指数退避)
21
+ - **请求头模板**:一键应用 Claude Code / Codex / OpenCode 等客户端的完整请求头集合
22
+ - **智能规格推断**:OpenRouter 实时目录(24h 缓存)→ 本地预设 → 协议级兜底,上下文窗口与输出上限自动修正退化数据
8
23
 
9
- ### 方式一:直接放扩展目录(已安装本机)
24
+ 零运行时依赖,仅使用 Node.js 内置模块(`fs` / `child_process` / `http` / `https` / `os` / `path`)。
25
+
26
+ ---
27
+
28
+ ## 安装
10
29
 
11
- `custom-provider.ts` 复制到 pi 的全局扩展目录即可热加载:
30
+ ### 方式一:扩展目录(最快)
12
31
 
13
32
  ```bash
14
33
  cp custom-provider.ts ~/.pi/agent/extensions/
15
- # 然后在 pi /reload
34
+ # pi 中执行 /reload
16
35
  ```
17
36
 
18
- ### 方式二:作为 pi 包安装(发布到 npm / git)
19
-
20
- 本目录已带 `pi` 清单(`package.json#pi.extensions`),可作为 pi 包分享:
37
+ ### 方式二:npm
21
38
 
22
39
  ```bash
23
- # 从本地目录或 git 仓库安装
24
- pi install local:E:/custom-provider # 或 git:... / npm:...
40
+ pi install npm:custom-provider-pi@latest
25
41
  ```
26
42
 
27
- 运行时无任何第三方依赖(只用 Node 内置模块),`@earendil-works/pi-coding-agent` 仅做类型引用,由 pi 运行时捆绑,声明在 `peerDependencies`。
28
-
29
- ## 命令总览
43
+ ### 方式三:本地目录
30
44
 
31
- ```
32
- /custom-provider add [名称] [flags] 添加(交互引导或参数化添加)
33
- /custom-provider remove <名称> [--yes] 删除(--yes 跳过确认)
34
- /custom-provider refresh [名称] 重新拉取模型列表
35
- /custom-provider list 列出所有 provider(含启用状态)
36
- /custom-provider test <名称> | --base-url 测试连接(已配置项或临时端点)
37
- /custom-provider config [edit|path|<名称>] 查看 / 编辑配置
38
- /custom-provider enable|disable <名称> 启用 / 禁用 provider
39
- /custom-provider prune <名称> [--keep/--drop 关键词] 修剪模型列表(避免全量保留)
40
- /custom-provider help 显示帮助
45
+ ```bash
46
+ pi install local:/path/to/custom-provider
41
47
  ```
42
48
 
43
- Tab 键可自动补全子命令与 provider 名(大小写不敏感)。
49
+ `@earendil-works/pi-coding-agent` 仅做类型引用,由 pi 运行时捆绑,声明在 `peerDependencies`。
44
50
 
45
- ## add 详解
51
+ ---
46
52
 
47
- ### 交互模式
53
+ ## 快速开始
48
54
 
49
- ```
50
- /custom-provider add
51
- ```
55
+ ### 简单添加(推荐新用户)
52
56
 
53
- 按向导依次输入:名称 端点 URL → API Key → 协议类型 → 是否自动拉取模型 → 自定义请求头 → 高级配置 → 模型列表。自动拉取成功后**会询问如何过滤模型**(按关键字保留/排除),避免把渠道全量模型写入配置。
57
+ pi 中执行 `/custom-provider add`,依次输入:
54
58
 
55
- ### 参数模式(非交互)
59
+ 1. **名称**:`deepseek`(唯一标识,仅字母/数字/中划线/下划线)
60
+ 2. **端点 URL**:`https://api.deepseek.com/v1`
61
+ 3. **选择「简单添加」**:自动探测协议与模型列表
62
+ 4. 完成 — 模型已就绪,用 `/model` 切换
56
63
 
57
- ```
64
+ ### 非交互式添加(脚本 / Telegram)
65
+
66
+ ```bash
58
67
  /custom-provider add deepseek \
59
68
  --base-url https://api.deepseek.com/v1 \
60
69
  --api-key $DEEPSEEK_API_KEY \
61
70
  --models deepseek-chat,deepseek-reasoner
62
71
  ```
63
72
 
73
+ ---
74
+
75
+ ## 命令参考
76
+
77
+ | 命令 | 说明 |
78
+ |---|---|
79
+ | `add [name] [flags]` | 添加 provider(交互引导或参数化) |
80
+ | `remove <name> [--yes]` | 删除 provider |
81
+ | `refresh [name]` | 重新拉取模型列表 |
82
+ | `list` | 列出所有 provider 及状态 |
83
+ | `test <name>` | 测试连通性(延迟 / 模型数 / LB 状态) |
84
+ | `config [edit\|path\|<name>]` | 查看或编辑配置 |
85
+ | `enable\|disable <name>` | 启用 / 禁用 provider |
86
+ | `prune <name> [--keep/--drop]` | 按关键字修剪模型列表 |
87
+ | `help` | 显示帮助 |
88
+
89
+ 所有子命令支持 Tab 自动补全(provider 名大小写不敏感)。
90
+
91
+ ---
92
+
93
+ ## add 参数详解
94
+
95
+ ### flags
96
+
64
97
  | flag | 说明 |
65
98
  |---|---|
66
- | `--name` / 位置参数 | provider 名称(字母/数字/-/_,≤32 字符) |
67
- | `--base-url` / `--url` | API 端点(自动清理 `/v1/models` 尾巴并补全 `/v1`) |
68
- | `--api-key` / `--key` | 支持 `$ENV` / `!命令` / 字面量 / `local`(默认) |
69
- | `--api` | `auto`(默认,按 URL 推断)\| `openai-completions` \| `openai-responses` \| `anthropic-messages` \| `google-generative-ai` |
70
- | `--models` | 逗号分隔的模型 ID 列表 |
71
- | `--model` | 单个模型 ID,可多次累加 |
72
- | `--header "K: V"` | 自定义请求头,可多次 |
73
- | `--headers '{"k":"v"}'` | JSON 形式设置请求头 |
74
- | `--auth-header` | 开启 `Authorization: Bearer <key>`(非标准 API 用) |
75
- | `--compat '{...}'` | 协议兼容选项(如 `supportsDeveloperRole`) |
76
- | `--overrides '{"模型id":{...}}'` | 按模型覆盖:`reasoning` / `input` / `contextWindow` / `maxTokens` / `cost` / `api` / `baseUrl` |
77
- | `--model-api "模型id:协议"` | 让单个模型走另一协议,可多次(见下文双协议) |
78
- | `--model-base-url "模型id:url"` | 让单个模型使用另一端点,可多次 |
79
- | `--force` / `-f` | 已存在时直接覆盖(不加则交互确认 / 报错) |
80
- | `--json '{...}'` | 完整配置 JSON(跳过所有 flag) |
81
- | `--ua <预设\|原始UA>` | 预设 User-Agent(见下),或直接给自定义字符串 |
82
- | `--profile <模板键>` | 应用完整请求头模板(`claude-code` / `codex` / `opencode` / `browser` 等,含 UA + 客户端典型头集合) |
83
- | `--proxy <URL>` | HTTP/SOCKS 代理地址(支持 `$ENV`) |
84
- | `--lb-keys "$K1,$K2"` | Key 负载均衡(轮询 + 429 自动冷却) |
85
- | `--lb-cooldown 60` | 冷却秒数(默认 60) |
86
-
87
- ### JSON 参数
99
+ | `--base-url` / `--url` | API 端点(自动清理 `/v1/models` 尾巴,补全 `/v1`) |
100
+ | `--api-key` / `--key` | API Key(`$ENV` / `!cmd` / 字面量 / `local`) |
101
+ | `--api TYPE` | 协议类型(`auto`/`openai-completions`/`openai-responses`/`anthropic-messages`/`google-generative-ai`) |
102
+ | `--models "a,b"` | 逗号分隔的模型 ID |
103
+ | `--model m` | 单个模型 ID(可多次累加) |
104
+ | `--profile <key>` | 完整请求头模板(`claude-code` / `codex` / `browser`) |
105
+ | `--ua <key\|string>` | 单独设置 User-Agent(预设键或原始字符串) |
106
+ | `--header "K: V"` | 自定义请求头(可多次,支持 `$ENV`) |
107
+ | `--headers '{"k":"v"}'` | JSON 形式设置请求头 |
108
+ | `--proxy <URL>` | HTTP/SOCKS 代理地址 |
109
+ | `--lb-keys "$K1,$K2"` | Key 负载均衡 |
110
+ | `--lb-cooldown N` | 冷却时间(秒,默认 60) |
111
+ | `--model-api "id:协议"` | 按模型覆盖协议(可多次) |
112
+ | `--model-base-url "id:url"` | 按模型覆盖端点(可多次) |
113
+ | `--auth-header` | 开启 Bearer 认证(非标准 API) |
114
+ | `--compat '{...}'` | 协议兼容选项 |
115
+ | `--overrides '{"id":{...}}'` | 按模型覆盖(`reasoning` / `input` / `contextWindow` / `maxTokens` / `cost`) |
116
+ | `--force` / `-f` | 覆盖已存在的 provider |
117
+ | `--json '{...}'` | 完整配置 JSON |
118
+
119
+ ### JSON 模式
88
120
 
89
- ```
121
+ ```bash
90
122
  /custom-provider add --json '{
91
123
  "name": "gw",
92
124
  "baseUrl": "https://gw.example.com/v1",
93
125
  "apiKey": "$MY_KEY",
94
- "api": "openai-completions",
95
- "enabled": true,
96
- "authHeader": false,
97
- "headers": { "X-Custom": "v" },
126
+ "lbKeys": ["$K1", "$K2"],
127
+ "lbCooldown": 30,
128
+ "headers": { "X-Custom": "value" },
98
129
  "models": ["gpt-4o", { "id": "claude-x", "api": "anthropic-messages" }]
99
130
  }'
100
131
  ```
101
132
 
102
- ## 双协议混用(同一 Provider 内 OpenAI + Anthropic)
133
+ ---
103
134
 
104
- 协议决定优先级:**模型 `api` 字段 > provider 级 `api` > 按 URL 自动推断(默认 `openai-completions`)**;模型 `baseUrl` 同理可覆盖端点。
135
+ ## 核心功能
105
136
 
106
- 三种配置方式:
137
+ ### 双协议混用
107
138
 
108
- **① flags:**
109
- ```
110
- /custom-provider add gw --base-url https://gw.example.com/v1 --api-key $K \
139
+ 同一 provider 内不同模型可走不同协议(如 OpenAI 网关同时转发 Anthropic 模型)。
140
+
141
+ **协议决定优先级**:模型 `api` 字段 > provider 级 `api` > 按 URL 自动推断。
142
+
143
+ ```bash
144
+ # 方式 1:--model-api flags
145
+ /custom-provider add gw --base-url https://gw.example.com/v1 \
111
146
  --models gpt-4o,claude-x \
112
147
  --model-api claude-x:anthropic-messages \
113
148
  --model-base-url claude-x:https://api.anthropic.com
149
+
150
+ # 方式 2:JSON
151
+ /custom-provider add --json '{
152
+ "name": "gw",
153
+ "baseUrl": "https://gw.example.com/v1",
154
+ "models": [
155
+ "gpt-4o",
156
+ { "id": "claude-x", "api": "anthropic-messages", "baseUrl": "https://api.anthropic.com" }
157
+ ]
158
+ }'
114
159
  ```
115
160
 
116
- **② 高级配置(交互向导 → 高级配置):**
117
- ```json
118
- {
119
- "authHeader": false,
120
- "compat": {},
121
- "modelOverrides": {
122
- "claude-x": { "api": "anthropic-messages", "baseUrl": "https://api.anthropic.com" }
123
- }
124
- }
161
+ ### 请求头模板
162
+
163
+ 不同客户端(Claude Code / Codex / OpenCode 等)携带的请求头集合不同。`--profile` 可一键应用完整模板:
164
+
165
+ | 模板 | 包含 |
166
+ |---|---|
167
+ | `browser` | 仅 User-Agent(Chrome 131) |
168
+ | `claude-code` | UA + `anthropic-version` + `x-app` + `content-type` + `anthropic-dangerous-direct-browser-access` |
169
+ | `codex` | UA + `accept: application/json` |
170
+ | `opencode` / `cursor` / `windsurf` | 各客户端 UA |
171
+
172
+ ```bash
173
+ /custom-provider add relay --base-url https://gw.example.com \
174
+ --profile claude-code --models claude-sonnet-4
125
175
  ```
126
176
 
127
- **③ JSON 参数:** 见上文 `--json` 示例。
177
+ **优先级**:`--header`/`--headers`(显式)> `--profile`(模板)> `--ua` > 默认浏览器 UA。
128
178
 
129
- ### User-Agent 预设
179
+ 请求头值支持 `$ENV` / `!cmd` 插值,支持 JSON 对象或逐行 `Key: Value` 两种格式。敏感头(`authorization` / `x-api-key` / `anthropic-beta` 等)允许设置但会警告。
130
180
 
131
- 部分中转/反代服务会按 UA 指纹拦截非浏览器/SDK 请求。除浏览器默认外,内置以下预设(反代一般只校验前缀关键字,版本号仅供参考,可自行改源码 `UA_PRESETS`):
181
+ ### User-Agent 预设
132
182
 
133
- | 预设键 | 行为示例 |
183
+ | 预设 | UA |
134
184
  |---|---|
135
185
  | `browser`(默认) | `Mozilla/5.0 ... Chrome/131.0.0.0 Safari/537.36` |
136
186
  | `claude-code` | `claude-code/2.1.237` |
@@ -139,179 +189,156 @@ Tab 键可自动补全子命令与 provider 名(大小写不敏感)。
139
189
  | `cursor` | `Cursor/3.16.0 (Windows; 64bit)` |
140
190
  | `windsurf` | `Windsurf/2.0.0 (Windows)` |
141
191
  | `openwebui` | `OpenWebUI/0.11.0` |
142
- | `chatgpt` | `Mozilla/5.0 ... ChatGPT-Desktop/1.2025.0` |
192
+ | `chatgpt` | `...Chrome/131.0.0.0 ...ChatGPT-Desktop/1.2025.0` |
143
193
 
144
- 使用:
194
+ ### 负载均衡
145
195
 
146
- ```
147
- /custom-provider add my --base-url https://gw.example.com/v1 --api-key $K \
148
- --models gpt-4o --ua claude-code # 预设键
149
- /custom-provider add my --base-url ... --ua "MyApp/1.0" # 直接给原始字符串
196
+ 多 Key 轮询 + 429 自动冷却(指数退避):
197
+
198
+ ```bash
199
+ /custom-provider add relay --base-url https://api.gw.com/v1 \
200
+ --lb-keys "$KEY_A,$KEY_B,sk-plain" --lb-cooldown 60 --models gpt-4o
150
201
  ```
151
202
 
152
- 交互向导在「自定义请求头」后会询问 UA 预设(选「自定义」可自己输入)。若请求头里已显式写入 `User-Agent`,则以你写的为准,不覆盖。
203
+ - 触发 429 时该 Key 自动进入冷却(默认 60s,连续 429 指数退避,上限 10 分钟)
204
+ - 成功请求重置退避计数
205
+ - `list` 显示活跃 Key 数:`3 Key(2 活跃 / 60s 冷却)`
206
+ - 单 key 模式完全向后兼容(`apiKey` 字符串)
153
207
 
154
- ### 请求头模板
208
+ ### 代理
155
209
 
156
- `--ua` 只预设一个 `User-Agent` 头。`--profile` 预设**整组请求头**(UA + 客户端典型头集合),对 Claude Code、Codex 等不同客户端各自携带的头集合不同:
210
+ ```bash
211
+ /custom-provider add my --base-url https://api.deepseek.com/v1 \
212
+ --api-key $KEY --models deepseek-chat --proxy http://127.0.0.1:7890
213
+ ```
157
214
 
158
- | 模板键 | 包含的头 |
159
- |---|---|
160
- | `browser`(默认) | 仅 UA(浏览器) |
161
- | `claude-code` | UA + `anthropic-version` + `x-app` + `content-type` + `anthropic-dangerous-direct-browser-access` |
162
- | `codex` | UA + `accept` |
163
- | `opencode` | UA |
164
- | `cursor` | UA |
215
+ > [!NOTE]
216
+ > Node.js `fetch` 需要 `NODE_USE_ENV_PROXY=1` 才读取代理环境变量;传统 `http.request` 不读取。
165
217
 
166
- ```
167
- /custom-provider add my --base-url ... --api anthropic-messages \
168
- --models claude-sonnet-4 --profile claude-code
169
- ```
218
+ ### 多模态(图片输入)
170
219
 
171
- > 优先级:`--header`/`--headers` 显式头 > `--profile` 模板 > `--ua` > 默认浏览器 UA
220
+ 默认 `input: ["text"]`(纯文本)。pi 会自动识别已知的多模态模型并开启图片输入:
172
221
 
173
- 请求头值支持 `$ENV` / `!cmd` 插值(pi 请求时动态解析),支持 JSON 对象或逐行 `"Key: Value"` 两种格式输入。敏感头(`authorization`、`x-api-key`、`anthropic-beta` 等)允许设置但会给出警告提示。
222
+ 1. **OpenRouter 目录**:`architecture.input_modalities` `image` 时自动标记
223
+ 2. **内置模式匹配**:`gpt-4o` / `claude-*` / `gemini-*` / `grok-4+` / `glm-4v` / `qwen-vl` / `minimax-m*` 及 ID 含 `vision` 的模型
224
+ 3. **显式覆盖**:模型对象写 `input: ["text", "image"]` 或 `["text"]`
174
225
 
175
- ### 代理
226
+ **为什么默认不开**:向不支持图片的模型发图会收到上游 404(如 deepseek-v4-flash-0731 非 vision 版)。未知模型保守处理为纯文本;已知视觉模型自动开启,无需手动配置。
176
227
 
177
- `--proxy` 支持 HTTP/SOCKS 代理地址,配置后注册时会设置 `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` 环境变量:
228
+ **强制指定**:
178
229
 
179
- ```
180
- /custom-provider add my --base-url https://api.deepseek.com/v1 \
181
- --api-key $KEY --models deepseek-chat --proxy http://127.0.0.1:7890
230
+ ```bash
231
+ /custom-provider add my --base-url ... \
232
+ --overrides '{"mymodel":{"input":["text","image"]}}'
182
233
  ```
183
234
 
184
- > ⚠️ Node.js 的 `fetch` 需要 `NODE_USE_ENV_PROXY=1` 才会读取代理环境变量(Vite/Tauri 等桌面应用的内置 fetch 也类似)。传统 `http.request` / `https.request` 不读取这些变量;如需在 Node 底层走代理,请用 `global-agent` 等库或升级到 Node ≥ 22 并设置该环境变量。
235
+ ### 上下文窗口与规格推断
185
236
 
186
- ### 负载均衡(多 Key 轮询 + 自动冷却)
237
+ 模型的 `contextWindow` / `maxTokens` 通过三级策略自动填充:
187
238
 
188
- 应对中转服务的 RPM/TPM/RTM 限制:一个渠道配多个 API Key,轮询使用;某个 Key 触发限流(HTTP 429)后自动冷却,恢复后重新参与轮询。连续 429 会指数退避(×2,上限 10 分钟);成功请求重置退避计数。
239
+ 1. **显式配置**:用户在模型对象中写的值(最高优先)
240
+ 2. **OpenRouter 实时目录**:自动归一化模型 ID(去 `[1m]` 后缀、@版本、路径前缀),24h 磁盘缓存
241
+ 3. **协议级兜底**:Anthropic 200K / OpenAI 258K / Google 1M / 其他 128K
189
242
 
190
- **交互向导:** add 时选「需要多 Key 负载均衡?」→ 输入逗号分隔的 Keys → 设定冷却时间(默认 60s)
243
+ **数据卫生**:社区目录对不公布输出上限的模型常给退化值(`maxTokens == contextWindow`),本扩展会自动钳到 `min(32K, ⌊ctx/4⌋)`,保证输入预算至少留 3/4 窗口。
191
244
 
192
- **flags:**
245
+ ---
193
246
 
194
- ```
195
- /custom-provider add relay --base-url https://api.gw.example.com/v1 \
196
- --lb-keys "$KEY_A,$KEY_B,sk-plain" --lb-cooldown 60 --models gpt-4o
197
- ```
247
+ ## 其他子命令
198
248
 
199
- **JSON 配置:**
249
+ ### prune — 模型修剪
200
250
 
201
- ```json
202
- {
203
- "name": "relay",
204
- "baseUrl": "https://api.gw.example.com/v1",
205
- "lbKeys": ["$KEY_A", "$KEY_B", "sk-plain-c"],
206
- "lbCooldown": 30,
207
- "models": ["gpt-4o"]
208
- }
251
+ ```bash
252
+ /custom-provider prune cpa # 交互式筛选
253
+ /custom-provider prune cpa --keep "deepseek,glm" # 只保留匹配项
254
+ /custom-provider prune cpa --drop "qwen,mini" # 排除匹配项
209
255
  ```
210
256
 
211
- - Key 值支持 `$ENV` / `!cmd` 引用,注册时解析一次存入内存池
212
- - `/list` 显示活跃 Key 数(如 `3 Key(2 活跃 / 60s 冷却)`)
213
- - 单 key 模式不受影响(`apiKey` 字符串仍向后兼容)
214
- ## prune:模型修剪
257
+ 大小写不敏感;`--keep` / `--drop` 可组合;过滤为空时不修改。
215
258
 
216
- 添加时自动拉取后已提供关键字过滤;对已有 provider 可用 `prune` 事后修剪:
259
+ ### enable / disable
217
260
 
218
- ```
219
- /custom-provider prune cpa # 交互:列出并按关键字筛选
220
- /custom-provider prune cpa --keep "deepseek,glm" # 只保留 ID 含任一关键字的模型
221
- /custom-provider prune cpa --drop "qwen,mini" # 排除 ID 含任一关键字的模型
222
- /custom-provider prune cpa --keep "deepseek" --drop "4.1"
261
+ ```bash
262
+ /custom-provider disable cpa # 立即注销,配置保留
263
+ /custom-provider enable cpa # 重新注册
223
264
  ```
224
265
 
225
- - 大小写不敏感子串匹配;`--keep` / `--drop` 可组合
226
- - 过滤结果为空或未命中时不修改并提示;保留原有模型的详细配置(contextWindow 等)
227
- - 支持 Tab 补全 provider 名
266
+ 禁用的 provider 在 `list` / `config` 中仍可见,但在 `/model` 选择器和请求中消失。
228
267
 
229
- ## enable / disable
268
+ ### config
230
269
 
231
- `disable` 写入 `"enabled": false` 并立即注销(`/model` 中消失,配置保留);`enable` 恢复并重新注册。
232
-
233
- ```
234
- /custom-provider disable cpa # 对应配置: "enabled": false
235
- /custom-provider enable cpa
270
+ ```bash
271
+ /custom-provider config # 配置摘要 + 路径
272
+ /custom-provider config cpa # 完整 JSON 详情
273
+ /custom-provider config edit # 编辑器(保存即校验并重注册)
274
+ /custom-provider config path # 仅输出路径
236
275
  ```
237
276
 
238
- 启动注册、`refresh`、`test`、`prune` 都会跳过 / 拦截已禁用项;`add` 覆盖已禁用 provider 时会保留其禁用状态。
239
-
240
- ## config / list / test / refresh
277
+ ### test 连通性测试
241
278
 
242
279
  ```
243
- /custom-provider config # 配置摘要(含文件路径与启用状态)
244
- /custom-provider config cpa # 单个 provider 的完整 JSON
245
- /custom-provider config edit # 编辑器修改,保存即校验并全量重注册
246
- /custom-provider config path # 配置文件路径
247
- /custom-provider list # 全部 provider:状态 / 协议 / 端点 / 模型预览
248
- /custom-provider test cpa # 用配置测试连接(拉取 /models 验证端点与 Key)
249
- /custom-provider test --base-url http://localhost:8080/v1 --api-key local # 测临时端点,不保存
250
- /custom-provider refresh cpa # 重新拉取模型列表(保留已有模型详细配置)
280
+ relay
281
+ 端点: https://api.gw.com/v1
282
+ 协议: openai-completions
283
+ 延迟: 187ms
284
+ 模型: 52
285
+ 示例: gpt-4o, claude-sonnet-4, deepseek-v4-flash, ... (+48)
286
+ LB: 2/3 Key 活跃
251
287
  ```
252
288
 
253
- ## 配置文件
289
+ 支持已配置 provider 或临时端点(`test --base-url URL --api-key KEY`)。
254
290
 
255
- 默认位置(Windows 示例):
291
+ ---
256
292
 
257
- ```
258
- C:\Users\<你>\.pi\agent\custom-providers.json
259
- ```
293
+ ## 配置文件
294
+
295
+ 路径:`~/.pi/agent/custom-providers.json`
260
296
 
261
297
  ```jsonc
262
298
  {
263
299
  "providers": [
264
300
  {
265
- "name": "cpa", // 名称,唯一
266
- "baseUrl": "https://cpa.example.com/v1",
267
- "apiKey": "$CPA_API_KEY", // 见下方取值语法
268
- "api": "openai-completions", // 可选:协议类型
269
- "enabled": true, // 可选:false = 禁用(不注册)
270
- "headers": { "X-Custom": "v" }, // 可选:请求头,值支持 $ENV
271
- "authHeader": false, // 可选:非标准 API 用
272
- "compat": {}, // 可选:协议兼容选项
301
+ "name": "deepseek",
302
+ "baseUrl": "https://api.deepseek.com/v1",
303
+ "apiKey": "$DEEPSEEK_API_KEY", // $ENV / !cmd / 字面量 / local
304
+ "api": "openai-completions",
305
+ "enabled": true,
306
+ "lbKeys": ["$K1", "$K2"], // 可选:多 Key 负载均衡
307
+ "lbCooldown": 60,
308
+ "proxy": "http://127.0.0.1:7890", // 可选:代理
309
+ "headers": { "X-Custom": "value" },
273
310
  "models": [
274
- "gpt-4o", // 字符串 = provider 默认协议
311
+ "deepseek-chat", // 字符串 = provider 默认协议
275
312
  { "id": "claude-x", "api": "anthropic-messages" }, // 对象 = 按模型覆盖
276
- { "id": "vision-1", "input": ["text", "image"], "reasoning": true,
313
+ { "id": "vision-1", "input": ["text", "image"], // 多模态
277
314
  "contextWindow": 200000, "maxTokens": 16384,
278
- "cost": { "input": 0, "output": 0 } }
315
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } }
279
316
  ]
280
317
  }
281
318
  ]
282
319
  }
283
320
  ```
284
321
 
285
- `apiKey` 与 header 值的取值语法(pi 原生支持,请求时动态解析):
286
-
287
- | 写法 | 含义 |
288
- |---|---|
289
- | `sk-xxx` 字面量 | 直接使用 |
290
- | `$ENV_VAR` / `${ENV_VAR}` | 环境变量插值 |
291
- | `!command` | 执行命令,以输出作为值(如 `!cat ~/.key`) |
292
- | `local` | 无认证的本地服务(默认) |
293
-
294
- 模型规格(`contextWindow` / `maxTokens`)未显式配置时,自动按 OpenRouter 公开目录(24h 磁盘缓存)→ 内置已知规格 → 保守默认(128K / 16K)填充。
322
+ ---
295
323
 
296
324
  ## 安全注意事项
297
325
 
298
- - `apiKey` 与 header 值**明文存储**在 `custom-providers.json`,请勿把该文件同步进公开仓库;Linux 下可考虑收紧权限
299
- - `!command` 特性会执行配置中的命令,仅编辑你信任的配置文件
300
- - 自定义请求头建议使用 `$ENV` 引用而非字面量,避免密钥落盘
326
+ - `apiKey` 与 `headers` **明文存储**,请勿将 `custom-providers.json` 同步到公开仓库
327
+ - `!command` 特性会执行 shell 命令,仅编辑可信配置
328
+ - 建议敏感值使用 `$ENV` 引用而非字面量
329
+ - Linux / macOS 下可收紧文件权限:`chmod 600 ~/.pi/agent/custom-providers.json`
330
+
331
+ ---
301
332
 
302
333
  ## 开发
303
334
 
304
335
  ```bash
305
336
  npm install # 安装 devDependencies(typescript / @types/node)
306
- npm run typecheck # tsc --noEmit 类型检查
337
+ npm run typecheck # tsc --noEmit
307
338
  ```
308
339
 
309
- 类型依赖只用于本地检查:运行时零第三方依赖,pi 通过 jiti 直接加载 `.ts`。
310
-
311
- ## 发布为 pi 包
340
+ 运行时零第三方依赖。类型检查需要 `@earendil-works/pi-coding-agent`(peerDependency)。
312
341
 
313
- ```bash
314
- npm pack # 打包(files: custom-provider.ts, README.md, LICENSE)
315
- ```
342
+ ## 许可证
316
343
 
317
- 或推送到 git 仓库后 `pi install git:...`。包已带 `pi-package` 关键字,便于在 pi 包目录被发现。
344
+ [MIT](LICENSE)
@@ -233,7 +233,7 @@ async function refreshRemoteSpecs(): Promise<void> {
233
233
  }
234
234
 
235
235
  // 查询模型规格:远程实时表 → 本地已知预设 → undefined
236
- function lookupModelSpec(modelId: string): { contextWindow: number; maxTokens: number } | undefined {
236
+ function lookupModelSpec(modelId: string): { contextWindow: number; maxTokens: number; vision?: boolean } | undefined {
237
237
  if (remoteSpecStore && remoteSpecStore.size > 0) {
238
238
  // 候选链逐形态命中:deepseek-v4-flash-free → deepseek-v4-flash → deepseek-v4 …
239
239
  for (const candidate of normalizeModelIdCandidates(modelId)) {
@@ -323,6 +323,7 @@ interface IProvider {
323
323
  lbKeys?: string[];
324
324
  /** 负载均衡默认冷却时间(秒),不填默认 60 */
325
325
  lbCooldown?: number;
326
+
326
327
  /** false 表示已禁用(不注册、不出现在 /model);缺失视为启用 */
327
328
  enabled?: boolean;
328
329
  models: (string | IModel)[];
@@ -415,16 +416,17 @@ function stripBasePath(baseUrl: string): string {
415
416
  function normalizeBaseUrl(baseUrl: string, api: string): string {
416
417
  const root = stripBasePath(baseUrl);
417
418
 
419
+ // 已经包含版本号后缀(/v1、/v4、/v1beta 等)或特殊版本路径段 → 不再追加
420
+ // 覆盖智谱 /api/paas/v4、Google /v1beta 等风格
421
+ if (/\/v\d+(?:beta)?(?:\/|$)/i.test(root)) return root;
422
+
418
423
  switch (api) {
419
424
  case "openai-completions":
420
425
  case "openai-responses":
421
426
  case "anthropic-messages":
422
- // OpenAI/Anthropic 兼容协议要求 /v1 前缀;只在"不以 /v1 结尾"时补,
423
- // 避免路径中段含 /v1/ 的历史误判
424
427
  return /\/v1$/i.test(root) ? root : `${root}/v1`;
425
428
 
426
429
  case "google-generative-ai":
427
- // Google Generative AI 需要 /v1beta 或 /v1 前缀
428
430
  if (!/(\/v1|\/v1beta)(\/|$)/i.test(root)) return `${root}/v1`;
429
431
  return root;
430
432
 
@@ -477,7 +479,7 @@ function prepareModel(raw: string | IModel, provider: IProvider): ProviderModelC
477
479
  id: src.id,
478
480
  name: src.name ?? src.id,
479
481
  reasoning: src.reasoning ?? false,
480
- input: src.input ?? ["text"],
482
+ input: src.input ?? ["text", "image"],
481
483
  contextWindow: contextWindow!,
482
484
  maxTokens: maxTokens!,
483
485
  cost: {
@@ -561,12 +563,19 @@ async function fetchModels(
561
563
  headers?: Record<string, string>
562
564
  ): Promise<string[]> {
563
565
  const cleanBase = stripBasePath(baseUrl);
564
- const v1Base = /\/v1$/i.test(cleanBase) ? cleanBase : `${cleanBase}/v1`;
566
+ // 已包含版本段(/v1、/v4、/api/paas/v4)则不追加 /v1
567
+ const hasVersion = /\/v\d+(?:beta)?(?:\/|$)/i.test(cleanBase);
568
+ const v1Base = hasVersion ? cleanBase : `${cleanBase}/v1`;
565
569
 
566
- // 尝试多种端点路径(google / anthropic 也走标准 /models 列表端点)
570
+ // 尝试多种端点路径
567
571
  const endpoints = api === "google-generative-ai"
568
572
  ? [`${v1Base}/models`]
569
- : [ `${v1Base}/models`, `${cleanBase}/models`, `${cleanBase}/api/models` ];
573
+ : [
574
+ `${v1Base}/models`, // 标准 /v1/models
575
+ `${cleanBase}/models`, // 已含版本或原始路径
576
+ `${cleanBase}/v4/models`, // 智谱 /api/paas/v4 后追加
577
+ `${cleanBase}/api/models`,
578
+ ];
570
579
 
571
580
  // 按协议组装认证头:anthropic 用 x-api-key,google 用 x-goog-api-key,其余 Bearer
572
581
  const requestHeaders: Record<string, string> = { ...resolveHeaders(headers) };
@@ -657,6 +666,49 @@ function httpGet(
657
666
  });
658
667
  }
659
668
 
669
+ // ---- 自动探测端点:试多种协议路径,返回成功获取的协议类型+模型列表 ----
670
+ async function probeEndpoint(
671
+ url: string,
672
+ apiKey?: string,
673
+ headers?: Record<string, string>
674
+ ): Promise<{ api: string; models: string[] } | null> {
675
+ const base = url.replace(/\/+$/, "");
676
+ const resolvedKey = apiKey ? resolveValue(apiKey) : "";
677
+
678
+ // 按常见端点路径尝试(OpenAI 兼容最多,其次 Anthropic/Google)
679
+ const attempts = [
680
+ { ep: `${base}/v1/models`, api: "openai-completions" },
681
+ { ep: `${base}/models`, api: "openai-completions" },
682
+ { ep: `${base}/v4/models`, api: "openai-completions" }, // 智谱风格 /api/paas/v4
683
+ { ep: `${base}/v1/models`, api: "anthropic-messages", useXApiKey: true },
684
+ ];
685
+
686
+ for (const { ep, api, useXApiKey } of attempts) {
687
+ try {
688
+ const authHeaders: Record<string, string> = {};
689
+ if (useXApiKey && resolvedKey) {
690
+ authHeaders["x-api-key"] = resolvedKey;
691
+ authHeaders["anthropic-version"] = "2023-06-01";
692
+ }
693
+ const mergedHeaders = { ...resolveHeaders(headers), ...authHeaders };
694
+ const json = await httpGet(ep, useXApiKey ? undefined : resolvedKey, Object.keys(mergedHeaders).length > 0 ? mergedHeaders : undefined, 8000);
695
+ // 解析模型列表
696
+ let models: string[] = [];
697
+ if (json.data && Array.isArray(json.data)) {
698
+ models = json.data.map((m: any) => m.id || m.name).filter(Boolean);
699
+ } else if (Array.isArray(json)) {
700
+ models = json.map((m: any) => m.id || m.name || m).filter(Boolean);
701
+ } else if (json.models && Array.isArray(json.models)) {
702
+ models = json.models.map((m: any) => m.id || m.name || m).filter(Boolean);
703
+ }
704
+ if (models.length > 0) return { api, models };
705
+ } catch {
706
+ continue;
707
+ }
708
+ }
709
+ return null;
710
+ }
711
+
660
712
  // ---- 名称校验 ----
661
713
  function validateProviderName(name: string): string | null {
662
714
  if (!name) return "名称不能为空";
@@ -1112,6 +1164,77 @@ export default function customProviderExtension(pi: ExtensionAPI) {
1112
1164
  }
1113
1165
  }
1114
1166
 
1167
+ // ---- 选择添加方式:简单(自动探测+模型)/ 自定义(完整配置)----
1168
+ const addMode = await ctx.ui.select(
1169
+ "添加方式?",
1170
+ ["简单添加(自动探测协议 + 模型列表)", "自定义配置(代理/LB/请求头模板/高级选项)"]
1171
+ );
1172
+ if (!addMode) return;
1173
+
1174
+ // ================= 简单添加路径(4 步完成)=================
1175
+ if (addMode.startsWith("简单")) {
1176
+ // 1. URL
1177
+ const simpleUrl = await ctx.ui.input(
1178
+ "API 端点 URL(粘贴完整地址,自动探测协议和模型)",
1179
+ "https://api.deepseek.com/v1"
1180
+ );
1181
+ if (!simpleUrl) return;
1182
+
1183
+ // 2. API Key(留空 = local 无认证)
1184
+ const simpleKeyInput = await ctx.ui.input(
1185
+ "API Key(留空 = 无认证本地服务,支持 $ENV 环境变量)",
1186
+ "$DEEPSEEK_API_KEY"
1187
+ );
1188
+ const simpleApiKey = simpleKeyInput?.trim() || "local";
1189
+
1190
+ // 3. 探测
1191
+ ctx.ui.notify("正在探测端点…", "info");
1192
+ const probeResult = await probeEndpoint(simpleUrl, simpleApiKey);
1193
+
1194
+ let simpleModels: (string | IModel)[] = [];
1195
+ if (probeResult) {
1196
+ simpleModels = probeResult.models;
1197
+ ctx.ui.notify(`✅ 检测到 ${probeResult.api} 协议,发现 ${simpleModels.length} 个模型`, "info");
1198
+ // 如果模型过多,提供过滤
1199
+ if (simpleModels.length > 10 && ctx.hasUI) {
1200
+ const filtered = await filterModelsInteractive(ctx, simpleModels);
1201
+ if (filtered) simpleModels = filtered;
1202
+ }
1203
+ } else {
1204
+ // 探测失败:手动输入
1205
+ const fallback = await ctx.ui.input(
1206
+ "自动探测失败,请手动输入模型 ID(逗号分隔,可留空取消)",
1207
+ "deepseek-chat,deepseek-reasoner"
1208
+ );
1209
+ if (fallback && fallback.trim()) {
1210
+ simpleModels = fallback.split(",").map((s: string) => s.trim()).filter(Boolean);
1211
+ }
1212
+ }
1213
+
1214
+ if (simpleModels.length === 0) {
1215
+ ctx.ui.notify("未提供模型,取消添加", "warning");
1216
+ return;
1217
+ }
1218
+
1219
+ // 3. 保存
1220
+ const simpleProvider: IProvider = {
1221
+ name,
1222
+ baseUrl: normalizeBaseUrl(simpleUrl, probeResult?.api ?? inferApi(simpleUrl)),
1223
+ apiKey: simpleApiKey,
1224
+ models: simpleModels,
1225
+ };
1226
+ const simpleConfig = loadConfig();
1227
+ const simpleOk = persistProvider(simpleConfig, simpleProvider, ctx);
1228
+ if (simpleOk) {
1229
+ ctx.ui.notify(
1230
+ `✅ Provider "${name}" 已添加,共 ${simpleModels.length} 个模型。用 /model 选择模型`,
1231
+ "info"
1232
+ );
1233
+ }
1234
+ return;
1235
+ }
1236
+
1237
+ // ================= 自定义配置路径(完整向导 2-10 步)=================
1115
1238
  // ---- 2. 端点 ----
1116
1239
  const baseUrl = await ctx.ui.input(
1117
1240
  "API 端点 URL(完整地址,通常含 /v1;粘贴 /v1/models 也会被自动清理)",
@@ -1445,6 +1568,7 @@ export default function customProviderExtension(pi: ExtensionAPI) {
1445
1568
  newProvider.lbKeys = lbKeys;
1446
1569
  if (lbCooldown) newProvider.lbCooldown = lbCooldown;
1447
1570
  }
1571
+
1448
1572
  if (apiType !== "自动推断" || inferredApi !== "openai-completions") {
1449
1573
  // 显式选择的协议,或自动推断出的非默认协议,需存盘保证幂等
1450
1574
  newProvider.api = inferredApi;
@@ -1852,7 +1976,7 @@ export default function customProviderExtension(pi: ExtensionAPI) {
1852
1976
  ctx.ui.notify(`已配置 ${config.providers.length} 个 provider:\n\n${lines.join("\n\n")}\n\n启用/禁用: /custom-provider enable|disable <名称>` , "info");
1853
1977
  };
1854
1978
 
1855
- // test:测试连接(已配置 provider 或临时端点)
1979
+ // test:测试连通性 + 延迟 + LB 状态 + 模型概览
1856
1980
  const doTest = async (argText: string, ctx: any): Promise<void> => {
1857
1981
  const parsed = parseFlagArgs(argText);
1858
1982
  const name = parsed.positional[0] || getFlag(parsed.flags, "name");
@@ -1863,9 +1987,10 @@ export default function customProviderExtension(pi: ExtensionAPI) {
1863
1987
  let api: string;
1864
1988
  let headers: Record<string, string> | undefined;
1865
1989
  let label: string;
1990
+ let lbKeyCount = 0;
1991
+ let lbActiveCount = 0;
1866
1992
 
1867
1993
  if (tmpBaseUrl) {
1868
- // 测试临时端点(不保存配置)
1869
1994
  baseUrl = tmpBaseUrl;
1870
1995
  apiKey = getFlag(parsed.flags, "api-key", "key") ?? "";
1871
1996
  const apiRaw = getFlag(parsed.flags, "api");
@@ -1892,15 +2017,40 @@ export default function customProviderExtension(pi: ExtensionAPI) {
1892
2017
  api = inferApi(p.baseUrl, p.api);
1893
2018
  headers = p.headers;
1894
2019
  label = p.name;
2020
+ // LB 状态
2021
+ const pool = lbPools.get(p.name);
2022
+ if (pool) {
2023
+ lbKeyCount = pool.keys.length;
2024
+ lbActiveCount = pool.activeCount();
2025
+ }
1895
2026
  }
1896
2027
 
1897
2028
  ctx.ui.notify(`正在测试 ${label}(${baseUrl})…`, "info");
2029
+ const t0 = Date.now();
1898
2030
  try {
1899
2031
  const ids = await fetchModels(baseUrl, apiKey, api, headers);
1900
- ctx.ui.notify(`测试通过:端点可用,检测到 ${ids.length} 个模型`, "info");
1901
- if (!tmpBaseUrl && !ids.length) ctx.ui.notify("提示: 端点可用但未返回模型,可尝试 /custom-provider refresh", "info");
2032
+ const latency = Date.now() - t0;
2033
+ const statusParts = [
2034
+ `✅ ${label}`,
2035
+ ` 端点: ${baseUrl}`,
2036
+ ` 协议: ${api}`,
2037
+ ` 延迟: ${latency}ms`,
2038
+ ` 模型: ${ids.length} 个`,
2039
+ ];
2040
+ if (ids.length > 0) {
2041
+ const preview = ids.slice(0, 5).join(", ");
2042
+ statusParts.push(` 示例: ${preview}${ids.length > 5 ? ` ... (+${ids.length - 5})` : ""}`);
2043
+ }
2044
+ if (lbKeyCount > 0) {
2045
+ statusParts.push(` LB: ${lbActiveCount}/${lbKeyCount} Key 活跃`);
2046
+ }
2047
+ ctx.ui.notify(statusParts.join("\n"), "info");
1902
2048
  } catch (error) {
1903
- ctx.ui.notify(`测试失败: ${error instanceof Error ? error.message : String(error)}`, "error");
2049
+ const latency = Date.now() - t0;
2050
+ ctx.ui.notify(
2051
+ `❌ ${label} 测试失败(${latency}ms)\n ${error instanceof Error ? error.message : String(error)}`,
2052
+ "error"
2053
+ );
1904
2054
  }
1905
2055
  };
1906
2056
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "custom-provider-pi",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "pi 扩展:统一管理第三方模型 Provider。子命令体系 /custom-provider add|remove|refresh|list|test|config|enable|disable|prune,支持交互向导、flags/JSON 非交互添加、双协议混用(OpenAI + Anthropic)、模型关键字过滤与修剪",
5
5
  "keywords": [
6
6
  "pi-package",