pi-cliproxyapi-autoconnect 1.2.0 → 1.2.2
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.
- package/README.md +158 -147
- package/index.ts +68 -68
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -1,170 +1,171 @@
|
|
|
1
1
|
# pi-cliproxyapi-autoconnect
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A **CLIProxyAPI provider for [pi](https://github.com/earendil-works/pi)**.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
After installation, `/login` shows a **CLIProxy** entry under **“Sign in with an API key”**. Select it and:
|
|
6
6
|
|
|
7
|
-
1.
|
|
8
|
-
2.
|
|
9
|
-
3.
|
|
10
|
-
4.
|
|
11
|
-
5.
|
|
7
|
+
1. Enter the CLIProxy **bound domain** (for example `proxy.example.com`);
|
|
8
|
+
2. Enter the **API key**;
|
|
9
|
+
3. The plugin derives every compatible endpoint and tries them automatically in this order: **OpenAI → Gemini → Anthropic** (OpenAI first);
|
|
10
|
+
4. Every discovered model is registered with pi and becomes available in `/model`;
|
|
11
|
+
5. Only when every endpoint fails are you asked to pick an endpoint manually (or enter a custom one) and type model IDs.
|
|
12
12
|
|
|
13
|
-
API
|
|
13
|
+
The API key is stored by pi in `~/.pi/agent/auth.json`. The plugin's own config file only stores the domain, endpoint and model cache; it **never stores the key**.
|
|
14
14
|
|
|
15
|
-
>
|
|
15
|
+
> Verified on pi `1.2.0` and `1.3.0`: provider registration, `/model` listing, model discovery and fallback, and real streaming chat requests over the OpenAI-compatible API.
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
19
|
-
##
|
|
19
|
+
## Install
|
|
20
20
|
|
|
21
|
-
###
|
|
21
|
+
### Option A: install from npm (recommended)
|
|
22
22
|
|
|
23
23
|
```powershell
|
|
24
24
|
pi install npm:pi-cliproxyapi-autoconnect
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
Then restart pi (or run `/reload` inside pi).
|
|
28
28
|
|
|
29
|
-
npm
|
|
29
|
+
The npm package contains only `index.ts`, `README.md` and `LICENSE`; pi's npm installer downloads it. The next two options are only for local development.
|
|
30
30
|
|
|
31
|
-
###
|
|
31
|
+
### Option B: install from a local directory
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
Plugin directory: `F:\Desktop\pi-cliproxyapi-autoconnect`.
|
|
34
34
|
|
|
35
35
|
```powershell
|
|
36
36
|
pi install F:\Desktop\pi-cliproxyapi-autoconnect
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
Then restart pi (or run `/reload` inside pi).
|
|
40
40
|
|
|
41
|
-
###
|
|
41
|
+
### Option C: copy into the extensions directory
|
|
42
42
|
|
|
43
43
|
```powershell
|
|
44
44
|
powershell -ExecutionPolicy Bypass -File F:\Desktop\pi-cliproxyapi-autoconnect\install.ps1
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
The script copies the plugin to:
|
|
48
48
|
|
|
49
49
|
```text
|
|
50
50
|
%USERPROFILE%\.pi\agent\extensions\pi-cliproxyapi-autoconnect\
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
If `PI_CODING_AGENT_DIR` is set, it copies to `extensions\pi-cliproxyapi-autoconnect\` under that directory instead.
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
You can also copy the whole `pi-cliproxyapi-autoconnect` folder into `%USERPROFILE%\.pi\agent\extensions\`; make sure the entry point is:
|
|
56
56
|
|
|
57
57
|
```text
|
|
58
58
|
%USERPROFILE%\.pi\agent\extensions\pi-cliproxyapi-autoconnect\index.ts
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
pi
|
|
61
|
+
pi automatically loads subfolders under `extensions` whose entry point is `index.ts`.
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
After installation, verify that the plugin loads and models are available:
|
|
64
64
|
|
|
65
65
|
```powershell
|
|
66
66
|
pi --list-models
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
###
|
|
69
|
+
### Migrating from the old `pi-cliproxy` package
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
The package was renamed to `pi-cliproxyapi-autoconnect`. The provider ID is still `cliproxy`, and the existing `cliproxy.json` and `auth.json` credentials remain valid.
|
|
72
72
|
|
|
73
73
|
```powershell
|
|
74
74
|
pi remove npm:pi-cliproxy
|
|
75
75
|
pi install npm:pi-cliproxyapi-autoconnect
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
Then restart pi. Models are still listed as `cliproxy/<model-id>`; no config or cache rebuild is required.
|
|
79
79
|
|
|
80
80
|
---
|
|
81
81
|
|
|
82
|
-
##
|
|
82
|
+
## Usage
|
|
83
83
|
|
|
84
|
-
1.
|
|
85
|
-
2.
|
|
86
|
-
3.
|
|
87
|
-
4.
|
|
84
|
+
1. Restart pi and run `/login`.
|
|
85
|
+
2. Choose **“Sign in with an API key”** (not “Sign in with an account”).
|
|
86
|
+
3. Select **CLIProxy** from the list.
|
|
87
|
+
4. Enter the bound domain, for example:
|
|
88
88
|
- `proxy.example.com`
|
|
89
89
|
- `https://proxy.example.com`
|
|
90
90
|
- `http://192.168.1.10:8317`
|
|
91
|
-
-
|
|
92
|
-
5.
|
|
93
|
-
6.
|
|
91
|
+
- a complete URL such as `https://proxy.example.com/v1` also works
|
|
92
|
+
5. Enter the CLIProxy API key (the value in CLIProxy's `access.api-keys`).
|
|
93
|
+
6. The plugin tries every compatible endpoint in this order:
|
|
94
94
|
|
|
95
95
|
```text
|
|
96
|
-
https
|
|
97
|
-
https
|
|
98
|
-
https
|
|
96
|
+
https://<domain>/v1/models OpenAI (highest priority)
|
|
97
|
+
https://<domain>/v1beta/models Gemini
|
|
98
|
+
https://<domain>/v1/models Anthropic (x-api-key, best effort)
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
7.
|
|
101
|
+
The first endpoint that returns a non-empty model list is selected and saved. The models are immediately visible in `/model`.
|
|
102
|
+
If you entered a bare domain without a scheme, every endpoint is also tried with the alternate scheme (https/http), for at most 6 probes.
|
|
103
|
+
7. Run `/model` and select `cliproxy/<model-id>`.
|
|
104
104
|
|
|
105
|
-
###
|
|
105
|
+
### Address formatting
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
The input is first normalized to a root URL: trailing `/`, `/v1`, `/v1beta`, `/v1/models` and similar suffixes are removed, while any path prefix is preserved. The plugin then derives:
|
|
108
108
|
|
|
109
|
-
|
|
|
109
|
+
| Purpose | Derived endpoint |
|
|
110
110
|
| --- | --- |
|
|
111
|
-
| OpenAI
|
|
112
|
-
| Gemini
|
|
113
|
-
| Anthropic
|
|
111
|
+
| OpenAI compatible (highest priority) | `https://<domain>/v1`; model discovery uses `/v1/models` |
|
|
112
|
+
| Gemini compatible | `https://<domain>/v1beta`; model discovery uses `/v1beta/models` |
|
|
113
|
+
| Anthropic compatible | `https://<domain>` (the SDK appends `/v1/messages`; model discovery tries `/v1/models` as a best effort) |
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
Local and private addresses (`localhost`, `127.0.0.1`, `10.*`, `192.168.*`, `172.16-31.*`, `.local`, `.lan`) default to `http://`; everything else defaults to `https://`. Without an explicit scheme, each compatible endpoint is also tried with the alternate scheme.
|
|
116
116
|
|
|
117
|
-
###
|
|
117
|
+
### Fallback when discovery fails
|
|
118
118
|
|
|
119
|
-
|
|
119
|
+
The plugin first tries every compatible endpoint automatically. After that:
|
|
120
120
|
|
|
121
|
-
1.
|
|
122
|
-
-
|
|
123
|
-
-
|
|
124
|
-
-
|
|
125
|
-
2.
|
|
126
|
-
-
|
|
127
|
-
-
|
|
128
|
-
-
|
|
129
|
-
- Anthropic
|
|
130
|
-
- Gemini
|
|
131
|
-
-
|
|
132
|
-
3.
|
|
133
|
-
4.
|
|
121
|
+
1. If **any** endpoint returned 401/403, the key is the more likely problem:
|
|
122
|
+
- The plugin prompts for the key once more;
|
|
123
|
+
- It retries every endpoint;
|
|
124
|
+
- If authentication still fails, login aborts and **nothing is saved**.
|
|
125
|
+
2. If there are only other failures (404, timeout, format error, etc.) and no endpoint returned a model list:
|
|
126
|
+
- The plugin reports the failure for each endpoint and probes `/healthz` to check whether the service is CLIProxyAPI;
|
|
127
|
+
- It shows an endpoint picker:
|
|
128
|
+
- Retry OpenAI compatible: `https://<domain>/v1`
|
|
129
|
+
- Anthropic compatible: `https://<domain>`
|
|
130
|
+
- Gemini compatible: `https://<domain>/v1beta`
|
|
131
|
+
- Custom compatible endpoint (enter a full base URL and choose a protocol)
|
|
132
|
+
3. If the selected endpoint still returns no model list, you can enter model IDs manually (comma or space separated, or leave empty).
|
|
133
|
+
4. If every endpoint returns HTTP 200 with an empty model list, the picker is skipped and you go straight to manual input (usually because the upstream account has no available credentials).
|
|
134
134
|
|
|
135
|
-
Anthropic
|
|
135
|
+
An Anthropic-compatible endpoint does not always expose a model list. If `/v1/models` returns 404/405, automatic discovery skips it; after selecting that endpoint, enter model IDs manually. Gemini-compatible endpoints are probed at `/v1beta/models`.
|
|
136
136
|
|
|
137
137
|
---
|
|
138
138
|
|
|
139
|
-
## `/cliproxy`
|
|
139
|
+
## `/cliproxy` command
|
|
140
140
|
|
|
141
141
|
```text
|
|
142
|
-
/cliproxy
|
|
143
|
-
/cliproxy refresh
|
|
142
|
+
/cliproxy Show the current config, model count and last error
|
|
143
|
+
/cliproxy refresh Refresh the model list immediately
|
|
144
144
|
```
|
|
145
145
|
|
|
146
|
-
|
|
146
|
+
The model catalog is cached for **5 minutes** by default: opening `/model` does not hit the upstream every time. `/cliproxy refresh` forces a refresh.
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
To reconfigure (new domain or key), run `/login` → `CLIProxy` again. If you switched to a completely different domain and the old models are still visible, run `/reload` so the plugin rebuilds the provider from the new config.
|
|
149
149
|
|
|
150
|
-
###
|
|
150
|
+
### Relationship to pi update commands
|
|
151
151
|
|
|
152
|
-
- **`pi update --models`
|
|
153
|
-
|
|
154
|
-
-
|
|
155
|
-
- **`pi update --extensions`**
|
|
152
|
+
- **`pi update --models` does not refresh this plugin's model list.**
|
|
153
|
+
It creates a temporary ModelRuntime without extensions and refreshes pi's built-in or `models.json` catalog; plugin providers are not included. It does not break the plugin, but `cliproxy/*` models are not updated.
|
|
154
|
+
- To refresh plugin models, use `/cliproxy refresh`, open `/model`, or restart pi (interactive startup refreshes dynamic providers).
|
|
155
|
+
- **`pi update --extensions`** updates the npm package itself (the plugin code); restart pi after updating.
|
|
156
|
+
- To update only this plugin, run `pi update npm:pi-cliproxyapi-autoconnect`.
|
|
156
157
|
|
|
157
158
|
---
|
|
158
159
|
|
|
159
|
-
##
|
|
160
|
+
## Files and security
|
|
160
161
|
|
|
161
|
-
|
|
|
162
|
+
| Content | Location |
|
|
162
163
|
| --- | --- |
|
|
163
|
-
| API
|
|
164
|
-
|
|
|
165
|
-
|
|
|
164
|
+
| API key | `~/.pi/agent/auth.json` (written by pi's login flow) |
|
|
165
|
+
| Domain, endpoint, model cache | `~/.pi/agent/cliproxy.json` |
|
|
166
|
+
| pi model catalog cache (maintained by pi) | `~/.pi/agent/models-store.json` |
|
|
166
167
|
|
|
167
|
-
`cliproxy.json
|
|
168
|
+
Example `cliproxy.json`:
|
|
168
169
|
|
|
169
170
|
```json
|
|
170
171
|
{
|
|
@@ -190,51 +191,50 @@ Anthropic 兼容接口不一定提供模型列表;如果 `/v1/models` 返回 4
|
|
|
190
191
|
}
|
|
191
192
|
```
|
|
192
193
|
|
|
193
|
-
|
|
194
|
+
Notes:
|
|
194
195
|
|
|
195
|
-
-
|
|
196
|
-
-
|
|
197
|
-
|
|
198
|
-
-
|
|
199
|
-
-
|
|
200
|
-
- 删除插件不会删除 `auth.json` 里的凭据;如需清理,可在 pi 里 `/logout` 选择 CLIProxy,或手动编辑 `auth.json`。
|
|
196
|
+
- Since 1.1.0, complete model metadata (`reasoning` / `input` / `cost` / `contextWindow` / `maxTokens`) is written directly to `cliproxy.json`, not just model IDs.
|
|
197
|
+
- The previous version is saved as `cliproxy.json.bak` before each write, for manual recovery.
|
|
198
|
+
- The plugin never writes the key to `cliproxy.json` and never prints it to logs.
|
|
199
|
+
- The key prompt is **not masked** in pi's current TUI (pi renders `secret` and normal text prompts the same way). Be aware of your surroundings.
|
|
200
|
+
- Removing the plugin does not remove credentials from `auth.json`. To clean up, run `/logout` in pi and select CLIProxy, or edit `auth.json` manually.
|
|
201
201
|
|
|
202
202
|
---
|
|
203
203
|
|
|
204
|
-
##
|
|
204
|
+
## Model capabilities
|
|
205
205
|
|
|
206
|
-
`/v1/models`
|
|
206
|
+
`/v1/models` only returns model IDs (and possibly `owned_by`). It does not include context length, image support or reasoning support.
|
|
207
207
|
|
|
208
|
-
|
|
208
|
+
The plugin resolves capability fields (`name`, `reasoning`, `input`, `cost`, `contextWindow`, `maxTokens`) in this order:
|
|
209
209
|
|
|
210
|
-
1. `~/.pi/agent/models.json`
|
|
211
|
-
2. `cliproxy.json
|
|
212
|
-
3. `~/.pi/agent/models-store.json`
|
|
213
|
-
4. pi
|
|
214
|
-
5.
|
|
210
|
+
1. A model with the same ID in `~/.pi/agent/models.json` (explicit user config, highest priority);
|
|
211
|
+
2. Metadata already stored in `cliproxy.json`;
|
|
212
|
+
3. A model with the same ID cached by pi in `~/.pi/agent/models-store.json` (preserves information when upgrading from an older version);
|
|
213
|
+
4. pi's built-in model catalog (after stripping prefixes such as `cn:`, `global:` and `models/`);
|
|
214
|
+
5. Conservative guesses based on the model name.
|
|
215
215
|
|
|
216
|
-
|
|
216
|
+
In other words, if you previously hand-wrote a provider pointing at the same CLIProxyAPI instance, the new `cliproxy` provider inherits the more accurate metadata; upgrading from 1.0.x does not lose it either.
|
|
217
217
|
|
|
218
|
-
|
|
218
|
+
When no metadata is available, the plugin uses conservative guesses:
|
|
219
219
|
|
|
220
|
-
- `claude*` / Anthropic
|
|
221
|
-
- `gemini*` / Gemini
|
|
222
|
-
- `gpt-5*` / `gpt-6
|
|
223
|
-
-
|
|
224
|
-
-
|
|
220
|
+
- `claude*` / Anthropic protocol: 200K context, 64K max output;
|
|
221
|
+
- `gemini*` / Gemini protocol: 1M context, 64K max output;
|
|
222
|
+
- `gpt-5*` / `gpt-6*`: 272K context, 128K max output;
|
|
223
|
+
- everything else: 128K context, 32K max output;
|
|
224
|
+
- text input only, `reasoning` disabled, cost set to 0.
|
|
225
225
|
|
|
226
|
-
|
|
226
|
+
If a model's real capabilities differ:
|
|
227
227
|
|
|
228
|
-
-
|
|
229
|
-
-
|
|
228
|
+
- Add its metadata to `~/.pi/agent/models.json` (the plugin inherits it); or
|
|
229
|
+
- Edit `guessLimits()` / `toModel()` in `index.ts` and run `/reload`.
|
|
230
230
|
|
|
231
231
|
---
|
|
232
232
|
|
|
233
|
-
##
|
|
233
|
+
## Troubleshooting
|
|
234
234
|
|
|
235
235
|
### 401 / Invalid API key
|
|
236
236
|
|
|
237
|
-
CLIProxyAPI
|
|
237
|
+
CLIProxyAPI's client key lives in `access.api-keys` in the v8 config (the top-level `api-keys` in older versions). Check:
|
|
238
238
|
|
|
239
239
|
```yaml
|
|
240
240
|
access:
|
|
@@ -242,91 +242,102 @@ access:
|
|
|
242
242
|
- sk-your-real-key
|
|
243
243
|
```
|
|
244
244
|
|
|
245
|
-
`/v1/models`
|
|
245
|
+
`/v1/models` requires authentication by default, unless the server has no keys configured at all.
|
|
246
246
|
|
|
247
|
-
|
|
247
|
+
Since 1.2.0, login tries every compatible endpoint first (OpenAI first):
|
|
248
248
|
|
|
249
|
-
-
|
|
250
|
-
-
|
|
251
|
-
-
|
|
249
|
+
- If an endpoint returns models, it is used immediately;
|
|
250
|
+
- If no endpoint returns models and at least one returned 401/403, the plugin prompts for the key again and retries every endpoint;
|
|
251
|
+
- If authentication still fails, login aborts and does not save the config.
|
|
252
252
|
|
|
253
253
|
### 403 `unsafe_example_api_key`
|
|
254
254
|
|
|
255
|
-
CLIProxyAPI
|
|
255
|
+
CLIProxyAPI detected that the example key (`your-api-key-1`, etc.) is still in use. Replace `access.api-keys` with a real key and restart CLIProxyAPI.
|
|
256
256
|
|
|
257
|
-
### `/v1/models`
|
|
257
|
+
### `/v1/models` returns an empty list
|
|
258
258
|
|
|
259
|
-
|
|
259
|
+
This is a valid result: there are currently no available upstream credentials, or all of them are cooling down. Sign in to or configure the upstream accounts in CLIProxyAPI, then run `/cliproxy refresh`.
|
|
260
260
|
|
|
261
|
-
###
|
|
261
|
+
### Only `/healthz` works; `/v1/models` returns 404
|
|
262
262
|
|
|
263
|
-
CLIProxyAPI
|
|
263
|
+
CLIProxyAPI itself does not support a path prefix. If nginx/Caddy is in front and adds a prefix, make the reverse proxy remove it, for example:
|
|
264
264
|
|
|
265
265
|
```nginx
|
|
266
266
|
location /cliproxy/ {
|
|
267
|
-
proxy_pass http://127.0.0.1:8317/; #
|
|
267
|
+
proxy_pass http://127.0.0.1:8317/; # trailing slash removes the /cliproxy prefix
|
|
268
268
|
}
|
|
269
269
|
```
|
|
270
270
|
|
|
271
|
-
|
|
271
|
+
Alternatively, choose “Custom compatible endpoint” in the plugin and enter the full base URL exposed by the reverse proxy.
|
|
272
272
|
|
|
273
|
-
###
|
|
273
|
+
### Streaming disconnects mid-response / long answers time out
|
|
274
274
|
|
|
275
|
-
CLIProxyAPI
|
|
275
|
+
CLIProxyAPI recommends increasing nginx's `proxy_read_timeout`. Cloudflare's free-plan 100-second timeout can also truncate long streaming responses.
|
|
276
276
|
|
|
277
|
-
### `/model`
|
|
277
|
+
### No models visible in `/model`
|
|
278
278
|
|
|
279
|
-
- `/model`
|
|
280
|
-
-
|
|
281
|
-
-
|
|
282
|
-
-
|
|
279
|
+
- `/model` only lists models from providers with configured authentication; complete `/login` first.
|
|
280
|
+
- Run `/cliproxy` to check the model count and last error.
|
|
281
|
+
- Run `/cliproxy refresh` to retry.
|
|
282
|
+
- Confirm that `pi --list-models` shows `cliproxy/*`.
|
|
283
283
|
|
|
284
|
-
###
|
|
284
|
+
### Models look wrong after changing the domain
|
|
285
285
|
|
|
286
|
-
|
|
286
|
+
Run `/reload`, or restart pi after editing the plugin. The model list refreshes whenever `/model` is opened.
|
|
287
287
|
|
|
288
288
|
---
|
|
289
289
|
|
|
290
|
-
##
|
|
290
|
+
## Changelog
|
|
291
|
+
|
|
292
|
+
### 1.2.2
|
|
293
|
+
|
|
294
|
+
- Rewrote this README in English for the npm package page and the pi package gallery.
|
|
295
|
+
- Translated the npm package description to English.
|
|
296
|
+
- The pi TUI remains fully English.
|
|
297
|
+
|
|
298
|
+
### 1.2.1
|
|
299
|
+
|
|
300
|
+
- All pi TUI strings are now English: login prompts, endpoint picker, errors and `/cliproxy` output.
|
|
301
|
+
- The provider display name remains `CLIProxy`; the `/login` entry, `/cliproxy` command and `cliproxy/` model prefix are unchanged.
|
|
291
302
|
|
|
292
303
|
### 1.2.0
|
|
293
304
|
|
|
294
|
-
-
|
|
295
|
-
-
|
|
296
|
-
-
|
|
297
|
-
-
|
|
298
|
-
-
|
|
299
|
-
- `/cliproxy refresh`
|
|
305
|
+
- Renamed the package from `pi-cliproxy` to `pi-cliproxyapi-autoconnect`; the provider ID, `cliproxy.json`, `/cliproxy` command and `cliproxy/` model prefix are unchanged.
|
|
306
|
+
- Login now tries every compatible endpoint automatically: **OpenAI → Gemini → Anthropic**, OpenAI first; the first non-empty model list wins.
|
|
307
|
+
- A bare domain also tries both https and http for every protocol, for up to 6 candidates.
|
|
308
|
+
- Best-effort discovery of Anthropic's `/v1/models`; if the endpoint is missing (404/405), fall back to manual model IDs instead of skipping Anthropic entirely.
|
|
309
|
+
- When every endpoint fails and at least one returned 401/403, the plugin asks for the key again. When every endpoint returns an empty list, it skips the endpoint picker and goes straight to manual input.
|
|
310
|
+
- `/cliproxy refresh` silently keeps the cache when the Anthropic endpoint has no model list; it does not persist a refresh error in that case.
|
|
300
311
|
|
|
301
312
|
### 1.1.0
|
|
302
313
|
|
|
303
|
-
-
|
|
304
|
-
- `cliproxy.json`
|
|
305
|
-
-
|
|
306
|
-
-
|
|
307
|
-
-
|
|
314
|
+
- On 401/403 during login, prompt for the key again instead of incorrectly entering the endpoint picker; if the second attempt fails, abort without saving the config.
|
|
315
|
+
- `cliproxy.json` now stores complete model metadata (reasoning / input / cost / contextWindow / maxTokens) and keeps a `.bak` backup.
|
|
316
|
+
- Model refresh now has a 5-minute TTL; `/cliproxy refresh` still forces a refresh.
|
|
317
|
+
- Capability metadata now also comes from `models-store.json` and pi's built-in catalog, so upgrading from an older config does not lose information.
|
|
318
|
+
- Refresh failures persist `lastError` to the config, so `/cliproxy` can show the last error after a restart.
|
|
308
319
|
|
|
309
320
|
---
|
|
310
321
|
|
|
311
|
-
##
|
|
322
|
+
## Development and debugging
|
|
312
323
|
|
|
313
324
|
```powershell
|
|
314
|
-
#
|
|
325
|
+
# Load a local file temporarily (does not write settings)
|
|
315
326
|
pi --extension F:\Desktop\pi-cliproxyapi-autoconnect\index.ts
|
|
316
327
|
|
|
317
|
-
#
|
|
328
|
+
# List models only
|
|
318
329
|
pi --list-models
|
|
319
330
|
```
|
|
320
331
|
|
|
321
|
-
|
|
332
|
+
The plugin only depends on pi's host-provided peer packages and has no third-party runtime dependencies:
|
|
322
333
|
|
|
323
334
|
- `@earendil-works/pi-ai`
|
|
324
335
|
- `@earendil-works/pi-coding-agent`
|
|
325
336
|
|
|
326
|
-
|
|
337
|
+
The core implementation is in `index.ts`:
|
|
327
338
|
|
|
328
|
-
- `runLogin()
|
|
329
|
-
- `refreshProviderModels()
|
|
330
|
-
- `buildProvider()
|
|
339
|
+
- `runLogin()`: domain + key + model discovery + fallback;
|
|
340
|
+
- `refreshProviderModels()`: dynamic refresh on startup, `/model` and `/cliproxy refresh`;
|
|
341
|
+
- `buildProvider()`: registers the native pi-ai provider (`apiKey.login` makes it appear in `/login`).
|
|
331
342
|
|
|
332
|
-
CLIProxyAPI
|
|
343
|
+
CLIProxyAPI reference: <https://github.com/router-for-me/CLIProxyAPI>
|
package/index.ts
CHANGED
|
@@ -51,7 +51,7 @@ import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
|
51
51
|
|
|
52
52
|
const PROVIDER_ID = "cliproxy";
|
|
53
53
|
const PROVIDER_NAME = "CLIProxy";
|
|
54
|
-
const AUTH_NAME = "CLIProxy
|
|
54
|
+
const AUTH_NAME = "CLIProxy (domain + API key)";
|
|
55
55
|
const CONFIG_FILE_NAME = "cliproxy.json";
|
|
56
56
|
const CONFIG_VERSION = 2;
|
|
57
57
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
@@ -65,9 +65,9 @@ let lastRefreshError: string | undefined;
|
|
|
65
65
|
type SupportedApi = "openai-completions" | "anthropic-messages" | "google-generative-ai";
|
|
66
66
|
|
|
67
67
|
const PROVIDER_APIS: Readonly<Record<SupportedApi, string>> = {
|
|
68
|
-
"openai-completions": "OpenAI
|
|
69
|
-
"anthropic-messages": "Anthropic
|
|
70
|
-
"google-generative-ai": "Gemini
|
|
68
|
+
"openai-completions": "OpenAI compatible",
|
|
69
|
+
"anthropic-messages": "Anthropic compatible",
|
|
70
|
+
"google-generative-ai": "Gemini compatible",
|
|
71
71
|
};
|
|
72
72
|
|
|
73
73
|
// =============================================================================
|
|
@@ -480,7 +480,7 @@ function isLocalHostname(hostname: string): boolean {
|
|
|
480
480
|
*/
|
|
481
481
|
function normalizeDomain(input: string): NormalizedDomain {
|
|
482
482
|
const original = input.trim();
|
|
483
|
-
if (!original) throw new Error("
|
|
483
|
+
if (!original) throw new Error("Domain is required.");
|
|
484
484
|
const implicitScheme = !/^[a-z][a-z\d+.-]*:\/\//iu.test(original);
|
|
485
485
|
const withScheme = implicitScheme
|
|
486
486
|
? `${isLocalHostname(original.split("/")[0]?.split(":")[0] ?? "") ? "http" : "https"}://${original}`
|
|
@@ -489,10 +489,10 @@ function normalizeDomain(input: string): NormalizedDomain {
|
|
|
489
489
|
try {
|
|
490
490
|
parsed = new URL(withScheme);
|
|
491
491
|
} catch {
|
|
492
|
-
throw new Error(
|
|
492
|
+
throw new Error(`Could not parse domain: "${input}". Example: proxy.example.com or https://proxy.example.com`);
|
|
493
493
|
}
|
|
494
494
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
495
|
-
throw new Error(
|
|
495
|
+
throw new Error(`Only http/https URLs are supported; received: ${parsed.protocol}`);
|
|
496
496
|
}
|
|
497
497
|
parsed.username = "";
|
|
498
498
|
parsed.password = "";
|
|
@@ -527,7 +527,7 @@ function geminiBaseUrl(rootUrl: string): string {
|
|
|
527
527
|
|
|
528
528
|
function abortSignalWithTimeout(signal: AbortSignal, timeoutMs: number): { signal: AbortSignal; cleanup: () => void } {
|
|
529
529
|
const controller = new AbortController();
|
|
530
|
-
const timer = setTimeout(() => controller.abort(new Error("
|
|
530
|
+
const timer = setTimeout(() => controller.abort(new Error("Request timed out")), timeoutMs);
|
|
531
531
|
const onAbort = () => controller.abort(signal.reason);
|
|
532
532
|
if (signal.aborted) controller.abort(signal.reason);
|
|
533
533
|
else signal.addEventListener("abort", onAbort, { once: true });
|
|
@@ -549,12 +549,12 @@ async function safeText(response: Response): Promise<string> {
|
|
|
549
549
|
}
|
|
550
550
|
|
|
551
551
|
function describeFetchError(error: unknown, ownSignal: AbortSignal, outerSignal: AbortSignal): string {
|
|
552
|
-
if (outerSignal.aborted) return "
|
|
553
|
-
if (ownSignal.aborted) return "
|
|
552
|
+
if (outerSignal.aborted) return "Cancelled";
|
|
553
|
+
if (ownSignal.aborted) return "Request timed out";
|
|
554
554
|
if (error instanceof Error) {
|
|
555
555
|
const cause = error.cause;
|
|
556
556
|
if (cause instanceof Error && cause.message && cause.message !== error.message) {
|
|
557
|
-
return `${error.message}
|
|
557
|
+
return `${error.message} (${cause.message})`;
|
|
558
558
|
}
|
|
559
559
|
return error.message;
|
|
560
560
|
}
|
|
@@ -572,7 +572,7 @@ function failureKindForStatus(status: number): FetchFailure["kind"] {
|
|
|
572
572
|
function makeHttpFailure(status: number, body: string): FetchFailure {
|
|
573
573
|
return {
|
|
574
574
|
ok: false,
|
|
575
|
-
error: `HTTP ${status}${body ?
|
|
575
|
+
error: `HTTP ${status}${body ? `: ${body}` : ""}`,
|
|
576
576
|
kind: failureKindForStatus(status),
|
|
577
577
|
status,
|
|
578
578
|
};
|
|
@@ -587,8 +587,8 @@ async function probeCliproxy(rootUrl: string, signal: AbortSignal): Promise<stri
|
|
|
587
587
|
});
|
|
588
588
|
if (!response.ok) return undefined;
|
|
589
589
|
const body = (await response.json().catch(() => undefined)) as { status?: unknown } | undefined;
|
|
590
|
-
if (body?.status === "ok") return "
|
|
591
|
-
return "
|
|
590
|
+
if (body?.status === "ok") return "Detected CLIProxyAPI (/healthz OK)";
|
|
591
|
+
return "Detected /healthz, but the response is not CLIProxyAPI format";
|
|
592
592
|
} catch {
|
|
593
593
|
return undefined;
|
|
594
594
|
} finally {
|
|
@@ -638,7 +638,7 @@ async function fetchOpenAIModels(baseUrl: string, apiKey: string, signal: AbortS
|
|
|
638
638
|
headers: {
|
|
639
639
|
Accept: "application/json",
|
|
640
640
|
Authorization: `Bearer ${apiKey}`,
|
|
641
|
-
"User-Agent": "pi-cliproxyapi-autoconnect/1.2",
|
|
641
|
+
"User-Agent": "pi-cliproxyapi-autoconnect/1.2.2",
|
|
642
642
|
},
|
|
643
643
|
signal: requestSignal,
|
|
644
644
|
});
|
|
@@ -648,7 +648,7 @@ async function fetchOpenAIModels(baseUrl: string, apiKey: string, signal: AbortS
|
|
|
648
648
|
}
|
|
649
649
|
const payload = await response.json().catch(() => undefined);
|
|
650
650
|
const models = parseOpenAIModelList(payload);
|
|
651
|
-
if (!models) return { ok: false, error: "
|
|
651
|
+
if (!models) return { ok: false, error: "Response is not an OpenAI-compatible model list (missing data array)", kind: "format" };
|
|
652
652
|
return { ok: true, models };
|
|
653
653
|
} catch (error) {
|
|
654
654
|
return { ok: false, error: describeFetchError(error, requestSignal, signal), kind: "network" };
|
|
@@ -662,7 +662,7 @@ async function fetchGeminiModels(baseUrl: string, apiKey: string, signal: AbortS
|
|
|
662
662
|
const { signal: requestSignal, cleanup } = abortSignalWithTimeout(signal, REQUEST_TIMEOUT_MS);
|
|
663
663
|
try {
|
|
664
664
|
const response = await fetch(url, {
|
|
665
|
-
headers: { Accept: "application/json", "User-Agent": "pi-cliproxyapi-autoconnect/1.2" },
|
|
665
|
+
headers: { Accept: "application/json", "User-Agent": "pi-cliproxyapi-autoconnect/1.2.2" },
|
|
666
666
|
signal: requestSignal,
|
|
667
667
|
});
|
|
668
668
|
if (!response.ok) {
|
|
@@ -671,7 +671,7 @@ async function fetchGeminiModels(baseUrl: string, apiKey: string, signal: AbortS
|
|
|
671
671
|
}
|
|
672
672
|
const payload = await response.json().catch(() => undefined);
|
|
673
673
|
const models = parseOpenAIModelList(payload);
|
|
674
|
-
if (!models) return { ok: false, error: "
|
|
674
|
+
if (!models) return { ok: false, error: "Response is not a Gemini-compatible model list (missing models array)", kind: "format" };
|
|
675
675
|
return { ok: true, models };
|
|
676
676
|
} catch (error) {
|
|
677
677
|
return { ok: false, error: describeFetchError(error, requestSignal, signal), kind: "network" };
|
|
@@ -703,7 +703,7 @@ async function fetchAnthropicModels(baseUrl: string, apiKey: string, signal: Abo
|
|
|
703
703
|
// Anthropic's x-api-key header; sending both is harmless upstream.
|
|
704
704
|
Authorization: `Bearer ${apiKey}`,
|
|
705
705
|
"anthropic-version": "2023-06-01",
|
|
706
|
-
"User-Agent": "pi-cliproxyapi-autoconnect/1.2",
|
|
706
|
+
"User-Agent": "pi-cliproxyapi-autoconnect/1.2.2",
|
|
707
707
|
},
|
|
708
708
|
signal: requestSignal,
|
|
709
709
|
});
|
|
@@ -712,7 +712,7 @@ async function fetchAnthropicModels(baseUrl: string, apiKey: string, signal: Abo
|
|
|
712
712
|
if (response.status === 404 || response.status === 405 || response.status === 501) {
|
|
713
713
|
return {
|
|
714
714
|
ok: false,
|
|
715
|
-
error: `Anthropic
|
|
715
|
+
error: `The Anthropic-compatible endpoint does not expose a usable model list (HTTP ${response.status})`,
|
|
716
716
|
kind: "unsupported",
|
|
717
717
|
status: response.status,
|
|
718
718
|
};
|
|
@@ -721,7 +721,7 @@ async function fetchAnthropicModels(baseUrl: string, apiKey: string, signal: Abo
|
|
|
721
721
|
}
|
|
722
722
|
const payload = await response.json().catch(() => undefined);
|
|
723
723
|
const models = parseOpenAIModelList(payload);
|
|
724
|
-
if (!models) return { ok: false, error: "
|
|
724
|
+
if (!models) return { ok: false, error: "Response is not an Anthropic-compatible model list (missing data array)", kind: "format" };
|
|
725
725
|
return { ok: true, models };
|
|
726
726
|
} catch (error) {
|
|
727
727
|
return { ok: false, error: describeFetchError(error, requestSignal, signal), kind: "network" };
|
|
@@ -833,9 +833,9 @@ function isAuthFailure(failure: FetchFailure | undefined): boolean {
|
|
|
833
833
|
|
|
834
834
|
function authFailureMessage(failure: FetchFailure): string {
|
|
835
835
|
if (failure.error.includes("unsafe_example_api_key")) {
|
|
836
|
-
return "
|
|
836
|
+
return "CLIProxyAPI is still using the example API key (unsafe_example_api_key). Update access.api-keys on the server and restart CLIProxyAPI.";
|
|
837
837
|
}
|
|
838
|
-
return `API
|
|
838
|
+
return `API key verification failed: ${failure.error}`;
|
|
839
839
|
}
|
|
840
840
|
|
|
841
841
|
function normalizeApiKey(value: string): string {
|
|
@@ -844,7 +844,7 @@ function normalizeApiKey(value: string): string {
|
|
|
844
844
|
|
|
845
845
|
function compactMessage(value: string, maxLength = 180): string {
|
|
846
846
|
const singleLine = value.replace(/\s+/gu, " ").trim();
|
|
847
|
-
return singleLine.length > maxLength ? `${singleLine.slice(0, maxLength)}
|
|
847
|
+
return singleLine.length > maxLength ? `${singleLine.slice(0, maxLength)}...` : singleLine;
|
|
848
848
|
}
|
|
849
849
|
|
|
850
850
|
/**
|
|
@@ -870,7 +870,7 @@ function summarizeDiscoveryAttempts(attempts: DiscoveryAttempt[]): string {
|
|
|
870
870
|
return attempts
|
|
871
871
|
.map(
|
|
872
872
|
({ candidate, failure }) =>
|
|
873
|
-
`${PROVIDER_APIS[candidate.api]} ${candidate.baseUrl}
|
|
873
|
+
`${PROVIDER_APIS[candidate.api]} ${candidate.baseUrl}: ${compactMessage(failure.error)}`,
|
|
874
874
|
)
|
|
875
875
|
.join("\n");
|
|
876
876
|
}
|
|
@@ -905,10 +905,10 @@ async function autoDiscoverModels(
|
|
|
905
905
|
async function runLogin(interaction: ProviderAuthInteraction): Promise<ApiKeyCredential> {
|
|
906
906
|
const domainInput = await interaction.prompt({
|
|
907
907
|
type: "text",
|
|
908
|
-
message: "CLIProxy
|
|
908
|
+
message: "CLIProxy bound domain (for example proxy.example.com or https://proxy.example.com)",
|
|
909
909
|
placeholder: "proxy.example.com",
|
|
910
910
|
});
|
|
911
|
-
if (!domainInput.trim()) throw new Error("
|
|
911
|
+
if (!domainInput.trim()) throw new Error("Domain is required.");
|
|
912
912
|
|
|
913
913
|
const { rootUrl: initialRootUrl, implicitScheme } = normalizeDomain(domainInput);
|
|
914
914
|
const promptForApiKey = async (message: string): Promise<string> => {
|
|
@@ -919,10 +919,10 @@ async function runLogin(interaction: ProviderAuthInteraction): Promise<ApiKeyCre
|
|
|
919
919
|
placeholder: "sk-...",
|
|
920
920
|
}),
|
|
921
921
|
);
|
|
922
|
-
if (!value) throw new Error("
|
|
922
|
+
if (!value) throw new Error("API key is required.");
|
|
923
923
|
return value;
|
|
924
924
|
};
|
|
925
|
-
let apiKey = await promptForApiKey("CLIProxy API
|
|
925
|
+
let apiKey = await promptForApiKey("CLIProxy API key (stored in pi's auth.json)");
|
|
926
926
|
|
|
927
927
|
// A bare host may be either http or https; only try the alternate scheme
|
|
928
928
|
// automatically when the user did not spell one out.
|
|
@@ -944,7 +944,7 @@ async function runLogin(interaction: ProviderAuthInteraction): Promise<ApiKeyCre
|
|
|
944
944
|
autoDiscoverModels(candidates, apiKey, interaction.signal, (candidate, index, total) => {
|
|
945
945
|
interaction.notify({
|
|
946
946
|
type: "progress",
|
|
947
|
-
message:
|
|
947
|
+
message: `Trying ${PROVIDER_APIS[candidate.api]}: ${candidate.baseUrl} (${index + 1}/${total})...`,
|
|
948
948
|
});
|
|
949
949
|
});
|
|
950
950
|
|
|
@@ -962,9 +962,9 @@ async function runLogin(interaction: ProviderAuthInteraction): Promise<ApiKeyCre
|
|
|
962
962
|
if (authFailure) {
|
|
963
963
|
interaction.notify({
|
|
964
964
|
type: "info",
|
|
965
|
-
message:
|
|
965
|
+
message: `All compatible endpoints failed authentication: ${compactMessage(authFailure.error)}\nEnter the API key again.`,
|
|
966
966
|
});
|
|
967
|
-
apiKey = await promptForApiKey("
|
|
967
|
+
apiKey = await promptForApiKey("Re-enter the CLIProxy API key");
|
|
968
968
|
outcome = await discover();
|
|
969
969
|
authFailure = outcome.ok ? undefined : firstAuthFailure(outcome.attempts);
|
|
970
970
|
if (authFailure) throw new Error(authFailureMessage(authFailure));
|
|
@@ -979,7 +979,7 @@ async function runLogin(interaction: ProviderAuthInteraction): Promise<ApiKeyCre
|
|
|
979
979
|
lastError = undefined;
|
|
980
980
|
interaction.notify({
|
|
981
981
|
type: "info",
|
|
982
|
-
message:
|
|
982
|
+
message: `Selected ${PROVIDER_APIS[api]}: ${baseUrl} (${models.length} models).`,
|
|
983
983
|
});
|
|
984
984
|
} else if (outcome.empty) {
|
|
985
985
|
// Every address answered, but no upstream credentials are available.
|
|
@@ -988,26 +988,26 @@ async function runLogin(interaction: ProviderAuthInteraction): Promise<ApiKeyCre
|
|
|
988
988
|
baseUrl = outcome.empty.baseUrl;
|
|
989
989
|
api = outcome.empty.api;
|
|
990
990
|
models = [];
|
|
991
|
-
lastError = "
|
|
991
|
+
lastError = "All compatible endpoints returned an empty model list";
|
|
992
992
|
} else {
|
|
993
993
|
const responding = outcome.attempts.find(({ failure }) => failure.kind !== "network");
|
|
994
994
|
activeRoot = responding?.candidate.rootUrl ?? initialRootUrl;
|
|
995
|
-
lastError = summarizeDiscoveryAttempts(outcome.attempts) || "
|
|
995
|
+
lastError = summarizeDiscoveryAttempts(outcome.attempts) || "Unknown error";
|
|
996
996
|
|
|
997
997
|
const diagnostic = await probeCliproxy(activeRoot, interaction.signal);
|
|
998
998
|
interaction.notify({
|
|
999
999
|
type: "info",
|
|
1000
|
-
message:
|
|
1000
|
+
message: `Tried ${outcome.attempts.length} compatible endpoints, but none returned a model list:\n${lastError}${diagnostic ? `\n${diagnostic}` : ""}`,
|
|
1001
1001
|
});
|
|
1002
1002
|
|
|
1003
1003
|
const choice = await interaction.prompt({
|
|
1004
1004
|
type: "select",
|
|
1005
|
-
message: "
|
|
1005
|
+
message: "Select a CLIProxy-compatible endpoint",
|
|
1006
1006
|
options: [
|
|
1007
|
-
{ id: "retry-openai", label:
|
|
1008
|
-
{ id: "anthropic", label: `Anthropic
|
|
1009
|
-
{ id: "gemini", label: `Gemini
|
|
1010
|
-
{ id: "custom", label: "
|
|
1007
|
+
{ id: "retry-openai", label: `Retry OpenAI compatible: ${openAIBaseUrl(activeRoot)}` },
|
|
1008
|
+
{ id: "anthropic", label: `Anthropic compatible: ${anthropicBaseUrl(activeRoot)}` },
|
|
1009
|
+
{ id: "gemini", label: `Gemini compatible: ${geminiBaseUrl(activeRoot)}` },
|
|
1010
|
+
{ id: "custom", label: "Custom compatible endpoint..." },
|
|
1011
1011
|
],
|
|
1012
1012
|
});
|
|
1013
1013
|
|
|
@@ -1035,7 +1035,7 @@ async function runLogin(interaction: ProviderAuthInteraction): Promise<ApiKeyCre
|
|
|
1035
1035
|
if (anthropic.kind === "unsupported") {
|
|
1036
1036
|
interaction.notify({
|
|
1037
1037
|
type: "info",
|
|
1038
|
-
message: "
|
|
1038
|
+
message: "This Anthropic-compatible endpoint has no model list endpoint. Enter model IDs manually in the next step.",
|
|
1039
1039
|
});
|
|
1040
1040
|
}
|
|
1041
1041
|
}
|
|
@@ -1054,18 +1054,18 @@ async function runLogin(interaction: ProviderAuthInteraction): Promise<ApiKeyCre
|
|
|
1054
1054
|
const customInput = (
|
|
1055
1055
|
await interaction.prompt({
|
|
1056
1056
|
type: "text",
|
|
1057
|
-
message: "
|
|
1057
|
+
message: "Custom compatible endpoint (full base URL, for example https://proxy.example.com/v1)",
|
|
1058
1058
|
placeholder: "https://proxy.example.com/v1",
|
|
1059
1059
|
})
|
|
1060
1060
|
).trim();
|
|
1061
|
-
if (!customInput) throw new Error("
|
|
1061
|
+
if (!customInput) throw new Error("A custom endpoint is required.");
|
|
1062
1062
|
const protocolChoice = await interaction.prompt({
|
|
1063
1063
|
type: "select",
|
|
1064
|
-
message: "
|
|
1064
|
+
message: "Compatible protocol for this endpoint",
|
|
1065
1065
|
options: [
|
|
1066
|
-
{ id: "openai", label: "OpenAI
|
|
1067
|
-
{ id: "anthropic", label: "Anthropic
|
|
1068
|
-
{ id: "gemini", label: "Gemini
|
|
1066
|
+
{ id: "openai", label: "OpenAI compatible (/v1/chat/completions)" },
|
|
1067
|
+
{ id: "anthropic", label: "Anthropic compatible (/v1/messages)" },
|
|
1068
|
+
{ id: "gemini", label: "Gemini compatible (/v1beta)" },
|
|
1069
1069
|
],
|
|
1070
1070
|
});
|
|
1071
1071
|
api =
|
|
@@ -1089,7 +1089,7 @@ async function runLogin(interaction: ProviderAuthInteraction): Promise<ApiKeyCre
|
|
|
1089
1089
|
if (models.length === 0) {
|
|
1090
1090
|
const manual = await interaction.prompt({
|
|
1091
1091
|
type: "text",
|
|
1092
|
-
message: "
|
|
1092
|
+
message: "No model list was retrieved. Enter model IDs manually (comma or space separated; leave empty to add none for now)",
|
|
1093
1093
|
placeholder: "claude-sonnet-4-5, gemini-2.5-pro",
|
|
1094
1094
|
});
|
|
1095
1095
|
models = parseManualModelIds(manual);
|
|
@@ -1120,19 +1120,19 @@ async function runLogin(interaction: ProviderAuthInteraction): Promise<ApiKeyCre
|
|
|
1120
1120
|
writeConfig(config);
|
|
1121
1121
|
} catch (error) {
|
|
1122
1122
|
throw new Error(
|
|
1123
|
-
`CLIProxy
|
|
1123
|
+
`Failed to write CLIProxy config: ${error instanceof Error ? error.message : String(error)} (${configPath()})`,
|
|
1124
1124
|
);
|
|
1125
1125
|
}
|
|
1126
1126
|
|
|
1127
1127
|
if (models.length > 0) {
|
|
1128
1128
|
interaction.notify({
|
|
1129
1129
|
type: "info",
|
|
1130
|
-
message: `CLIProxy
|
|
1130
|
+
message: `CLIProxy config saved: ${baseUrl} (${models.length} models, ${PROVIDER_APIS[api]}).`,
|
|
1131
1131
|
});
|
|
1132
1132
|
} else {
|
|
1133
1133
|
interaction.notify({
|
|
1134
1134
|
type: "info",
|
|
1135
|
-
message: `CLIProxy
|
|
1135
|
+
message: `CLIProxy config saved: ${baseUrl}, but no models were imported. Use /cliproxy refresh to retry.`,
|
|
1136
1136
|
});
|
|
1137
1137
|
}
|
|
1138
1138
|
|
|
@@ -1183,7 +1183,7 @@ async function refreshProviderModels(context: RefreshModelsContext): Promise<rea
|
|
|
1183
1183
|
writeConfig({
|
|
1184
1184
|
...config,
|
|
1185
1185
|
updatedAt: Date.now(),
|
|
1186
|
-
lastError: "
|
|
1186
|
+
lastError: "Upstream returned an empty model list (manual models kept)",
|
|
1187
1187
|
});
|
|
1188
1188
|
} catch {
|
|
1189
1189
|
// Best effort.
|
|
@@ -1197,7 +1197,7 @@ async function refreshProviderModels(context: RefreshModelsContext): Promise<rea
|
|
|
1197
1197
|
models: enriched,
|
|
1198
1198
|
source: "auto",
|
|
1199
1199
|
updatedAt: Date.now(),
|
|
1200
|
-
...(enriched.length === 0 ? { lastError: "
|
|
1200
|
+
...(enriched.length === 0 ? { lastError: "Upstream returned an empty model list" } : {}),
|
|
1201
1201
|
};
|
|
1202
1202
|
if (enriched.length > 0) delete updated.lastError;
|
|
1203
1203
|
try {
|
|
@@ -1254,14 +1254,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1254
1254
|
pi.registerProvider(buildProvider(readConfig()));
|
|
1255
1255
|
|
|
1256
1256
|
pi.registerCommand("cliproxy", {
|
|
1257
|
-
description: "
|
|
1257
|
+
description: "Show CLIProxy config, or run /cliproxy refresh to refresh models",
|
|
1258
1258
|
handler: async (args, ctx) => {
|
|
1259
1259
|
const subcommand = args.trim().toLowerCase();
|
|
1260
1260
|
const config = readConfig();
|
|
1261
1261
|
|
|
1262
1262
|
if (subcommand === "refresh") {
|
|
1263
1263
|
if (!config) {
|
|
1264
|
-
ctx.ui.notify("
|
|
1264
|
+
ctx.ui.notify("CLIProxy is not configured. Run /login and choose \"Sign in with an API key\" -> CLIProxy.", "warning");
|
|
1265
1265
|
return;
|
|
1266
1266
|
}
|
|
1267
1267
|
let refreshError: string | undefined;
|
|
@@ -1277,20 +1277,20 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1277
1277
|
}
|
|
1278
1278
|
const count = ctx.modelRegistry.getAll().filter((model) => model.provider === PROVIDER_ID).length;
|
|
1279
1279
|
if (refreshError) {
|
|
1280
|
-
ctx.ui.notify(`CLIProxy
|
|
1280
|
+
ctx.ui.notify(`CLIProxy model refresh failed: ${refreshError}`, "error");
|
|
1281
1281
|
} else if (lastRefreshError) {
|
|
1282
|
-
ctx.ui.notify(`CLIProxy
|
|
1282
|
+
ctx.ui.notify(`CLIProxy model refresh failed; using cached models (${count}): ${lastRefreshError}`, "warning");
|
|
1283
1283
|
} else if (count === 0) {
|
|
1284
|
-
ctx.ui.notify("CLIProxy
|
|
1284
|
+
ctx.ui.notify("CLIProxy refreshed, but no models were found. Check the API key or run /login again.", "warning");
|
|
1285
1285
|
} else {
|
|
1286
|
-
ctx.ui.notify(`CLIProxy
|
|
1286
|
+
ctx.ui.notify(`CLIProxy models refreshed: ${count}.`, "info");
|
|
1287
1287
|
}
|
|
1288
1288
|
return;
|
|
1289
1289
|
}
|
|
1290
1290
|
|
|
1291
1291
|
if (!config) {
|
|
1292
1292
|
ctx.ui.notify(
|
|
1293
|
-
|
|
1293
|
+
`CLIProxy is not configured.\nRun /login -> "Sign in with an API key" -> CLIProxy and enter the bound domain and API key.\nConfig will be written to: ${configPath()}`,
|
|
1294
1294
|
"info",
|
|
1295
1295
|
);
|
|
1296
1296
|
return;
|
|
@@ -1299,13 +1299,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1299
1299
|
const count = ctx.modelRegistry.getAll().filter((model) => model.provider === PROVIDER_ID).length;
|
|
1300
1300
|
const lastError = lastRefreshError ?? config.lastError;
|
|
1301
1301
|
const lines = [
|
|
1302
|
-
`CLIProxy
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
lastError ?
|
|
1307
|
-
"
|
|
1308
|
-
"
|
|
1302
|
+
`CLIProxy: ${config.baseUrl}`,
|
|
1303
|
+
`Protocol: ${PROVIDER_APIS[config.api]} (${config.api})`,
|
|
1304
|
+
`Models: ${count}${config.models.length !== count ? ` (${config.models.length} cached)` : ""}`,
|
|
1305
|
+
`Config: ${configPath()}`,
|
|
1306
|
+
lastError ? `Last error: ${lastError}` : undefined,
|
|
1307
|
+
"Reconfigure: /login -> \"Sign in with an API key\" -> CLIProxy",
|
|
1308
|
+
"Refresh models: /cliproxy refresh",
|
|
1309
1309
|
].filter((line): line is string => Boolean(line));
|
|
1310
1310
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
1311
1311
|
},
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cliproxyapi-autoconnect",
|
|
3
|
-
"version": "1.2.
|
|
4
|
-
"description": "CLIProxyAPI
|
|
3
|
+
"version": "1.2.2",
|
|
4
|
+
"description": "CLIProxyAPI auto-connect provider for pi: sign in with a bound domain and API key, automatically discover OpenAI/Gemini/Anthropic-compatible endpoints, and register models in /model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"engines": {
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
"cliproxyapi",
|
|
15
15
|
"autoconnect",
|
|
16
16
|
"autodiscover",
|
|
17
|
+
"model-discovery",
|
|
18
|
+
"provider",
|
|
17
19
|
"openai-compatible"
|
|
18
20
|
],
|
|
19
21
|
"pi": {
|