pi-cliproxyapi-native 0.3.0 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Pi CLIProxyAPI Native
2
2
 
3
- Use [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) for chat, image generation, and video generation in [Pi](https://pi.dev).
3
+ Use [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) for chat, image generation and editing, and video generation in [Pi](https://pi.dev).
4
4
 
5
5
  Chat uses Pi's native API adapters and model metadata, with availability from your proxy. Image and video tools have separate model selections, so you can generate media without switching your chat model.
6
6
 
@@ -12,6 +12,9 @@ Chat uses Pi's native API adapters and model metadata, with availability from yo
12
12
  - [Choose a media model](#choose-a-media-model)
13
13
  - [Generate media](#generate-media)
14
14
  - [Resolution and aspect ratio](#resolution-and-aspect-ratio)
15
+ - [OpenAI image controls and editing](#openai-image-controls-and-editing)
16
+ - [Image usage and cost](#image-usage-and-cost)
17
+ - [Pi image models](#pi-image-models)
15
18
  - [Supported media models](#supported-media-models)
16
19
  - [Files, status, and limits](#files-status-and-limits)
17
20
  - [Chat models](#chat-models)
@@ -28,15 +31,15 @@ Chat uses Pi's native API adapters and model metadata, with availability from yo
28
31
 
29
32
  ## Install
30
33
 
31
- The extension targets Pi 1.4.0 and CLIProxyAPI 8.0.38, with Node.js 22.19 or newer. Run CLIProxyAPI with your upstream accounts configured before connecting Pi. The extension uses Pi's host-provided packages, adds no runtime dependencies, and needs no compilation.
34
+ The extension targets Pi 1.4.0 and CLIProxyAPI 8.0.48, with Node.js 22.19 or newer. Run CLIProxyAPI with your upstream accounts configured before connecting Pi. The extension uses Pi's host-provided packages, adds no runtime dependencies, and needs no compilation.
32
35
 
33
36
  If you installed `pi-cliproxy-native`, remove it before installing the renamed package. Both register the same `cliproxyapi` provider.
34
37
 
35
38
  ```sh
36
- pi install npm:pi-cliproxyapi-native@0.3.0
39
+ pi install npm:pi-cliproxyapi-native@0.3.2
37
40
  ```
38
41
 
39
- For Git installation, use `pi install git:github.com/hawkff/pi-cliproxyapi-native@v0.3.0`. For a local checkout, use `pi install /path/to/pi-cliproxyapi-native`.
42
+ For Git installation, use `pi install git:github.com/hawkff/pi-cliproxyapi-native@v0.3.2`. For a local checkout, use `pi install /path/to/pi-cliproxyapi-native`.
40
43
 
41
44
  The default proxy address is `http://localhost:8317`. For another address, set the [connection](#connection) before logging in. Restart Pi, then run:
42
45
 
@@ -62,10 +65,10 @@ Set `CLIPROXYAPI_BASE_URL` or create `~/.pi/agent/pi-cliproxyapi.json`:
62
65
  }
63
66
  ```
64
67
 
65
- | Setting | Behavior |
66
- | --- | --- |
67
- | `CLIPROXYAPI_BASE_URL` | Overrides `baseUrl` in the configuration file. |
68
- | `CLIPROXYAPI_API_KEY` | Supplies a client key when Pi has no stored login credential. |
68
+ | Setting | Behavior |
69
+ | ---------------------------- | ----------------------------------------------------------------------------------------------------------------- |
70
+ | `CLIPROXYAPI_BASE_URL` | Overrides `baseUrl` in the configuration file. |
71
+ | `CLIPROXYAPI_API_KEY` | Supplies a client key when Pi has no stored login credential. |
69
72
  | `CLIPROXYAPI_MANAGEMENT_KEY` | Enables live Claude quota lookup through the same proxy's management API. Keep this separate from the client key. |
70
73
 
71
74
  Use an absolute URL. Root URLs and URLs ending in `/v1` or `/v1beta` work; the extension preserves path prefixes. Remote endpoints require HTTPS. HTTP works on loopback addresses. URLs cannot contain credentials, query strings, or fragments.
@@ -92,6 +95,8 @@ Search by friendly name, exact ID, backend, owner, or image/video purpose. The p
92
95
 
93
96
  The terminal picker is keyboard-only. Type to search, use the selection keys to move, press Enter to select, or Escape to cancel. It respects custom selection keybindings. Mouse clicks and wheel events do not change selection.
94
97
 
98
+ Starting session or branch navigation discards pending picker commands, even if another extension later cancels navigation. Run the selection again if you stay on the same branch.
99
+
95
100
  RPC, print, and JSON modes return text instead of opening the picker. Print mode uses stderr, JSON mode emits custom-message events, and RPC sends notifications. Listings show up to 100 matches; narrow larger lists with `search`. Use `select <exact ID>` to choose a model in these modes.
96
101
 
97
102
  ### Generate media
@@ -105,12 +110,12 @@ Generate a 1-second video of a red circle moving left with grok-imagine-video.
105
110
  Check video status for request_id <returned-id>.
106
111
  ```
107
112
 
108
- | Tool | Arguments | Result |
109
- | --- | --- | --- |
110
- | `cliproxyapi_media_models` | None | Available image/video IDs, supported output controls, and effective defaults. |
111
- | `cliproxyapi_generate_image` | `prompt`; optional `model`, `size`, `resolution`, `aspect_ratio` | Saved images and paths, plus previews when the chat model supports images. |
112
- | `cliproxyapi_generate_video` | `prompt`; optional `model`, `resolution`, `aspect_ratio`, and integer `duration` from 1 to 15 seconds | A `request_id` for the submitted video. |
113
- | `cliproxyapi_video_status` | `request_id` | One status check: pending, completed with a URL, or failed. |
113
+ | Tool | Arguments | Result |
114
+ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
115
+ | `cliproxyapi_media_models` | None | Available image/video IDs, supported output controls, and effective defaults. |
116
+ | `cliproxyapi_generate_image` | `prompt`; optional `model`, `size`, `resolution`, `aspect_ratio`, and [OpenAI controls and editing inputs](#openai-image-controls-and-editing) | Saved images and paths, plus previews when the chat model supports images. |
117
+ | `cliproxyapi_generate_video` | `prompt`; optional `model`, `resolution`, `aspect_ratio`, and integer `duration` from 1 to 15 seconds | A `request_id` for the submitted video. |
118
+ | `cliproxyapi_video_status` | `request_id` | One status check: pending, completed with a URL, or failed. |
114
119
 
115
120
  Pass `model` to choose an exact ID for one tool call. Otherwise, the tool uses its saved `/cli:model` selection. Without either, eligible OpenAI/GPT chats default to `gpt-image-2.5-sunburst` for images. Other chats need an image selection or explicit ID. Videos need a selection or explicit ID.
116
121
 
@@ -124,12 +129,12 @@ Invalid or disabled saved selections block automatic fallback until you clear or
124
129
 
125
130
  Pass output controls for each generation, or omit them to keep the backend defaults. `cliproxyapi_media_models` lists each model's `controls.size`, `controls.resolution`, and `controls.aspect_ratio`. Empty lists mean the control is unsupported; `size_limits` describes custom pixel sizes beyond the listed presets.
126
131
 
127
- | Models | Controls |
128
- | --- | --- |
129
- | OpenAI images | `size`: `auto` or `WIDTHxHEIGHT`. No separate `resolution` or `aspect_ratio`. |
130
- | Gemini images | `aspect_ratio` and model-specific `resolution` tiers. |
131
- | xAI images | `resolution`: `1k` or `2k`, plus `aspect_ratio`. |
132
- | xAI videos | `resolution`: `480p` or `720p`, plus `aspect_ratio`. The 1.5 model and its preview alias also accept `1080p`. |
132
+ | Models | Controls |
133
+ | ------------- | ------------------------------------------------------------------------------------------------------------- |
134
+ | OpenAI images | `size`: `auto` or `WIDTHxHEIGHT`. No separate `resolution` or `aspect_ratio`. |
135
+ | Gemini images | `aspect_ratio` and model-specific `resolution` tiers. |
136
+ | xAI images | `resolution`: `1k` or `2k`, plus `aspect_ratio`. |
137
+ | xAI videos | `resolution`: `480p` or `720p`, plus `aspect_ratio`. The 1.5 model and its preview alias also accept `1080p`. |
133
138
 
134
139
  GPT Image 1.5 accepts `auto`, `1024x1024`, `1536x1024`, and `1024x1536`. GPT Image 2 and 2.5 also accept custom dimensions: both edges must be multiples of 16 and at most 3840 pixels. The longer edge cannot exceed three times the shorter one. Total pixels must be between 655,360 and 8,294,400. OpenAI marks resolutions above `2560x1440` as experimental.
135
140
 
@@ -147,17 +152,57 @@ Larger outputs can increase generation cost and latency. The extension saves the
147
152
 
148
153
  Control values follow the [OpenAI image guide](https://developers.openai.com/api/docs/guides/image-generation#customize-image-output), [Gemini image guide](https://ai.google.dev/gemini-api/docs/generate-content/image-generation#aspect_ratios_and_image_size), and xAI's [image](https://docs.x.ai/developers/model-capabilities/images/generation#configuration) and [video](https://docs.x.ai/developers/model-capabilities/video/generation#configuration) documentation, restricted to what CLIProxyAPI forwards.
149
154
 
155
+ ### OpenAI image controls and editing
156
+
157
+ Use `cliproxyapi_media_models` to inspect controls for the exact advertised model ID. Omitted options keep the backend defaults. The extension rejects unsupported values before contacting the proxy.
158
+
159
+ | Option | Supported values |
160
+ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
161
+ | `quality` | `auto`, `low`, `medium`, `high`. Sunburst and Flare also accept `xhigh` and `max`. |
162
+ | `background` | `auto`, `opaque`, `transparent`. Transparent output requires PNG or WebP; GPT Image 2 transparency is in preview upstream. |
163
+ | `output_format` | `png`, `jpeg`, `webp`. JPEG and WebP can reduce file size and help keep results within the response limit. |
164
+ | `output_compression` | Integer from 0 to 100, with an explicit `jpeg` or `webp` format. Zero is a valid value. |
165
+ | `moderation` | `auto` or `low`. OpenAI still applies its content policies. |
166
+ | `n` | Integer from 1 to 10 on an explicit `api/` image route. Other routes allow only 1 because subscription translation can drop the count. |
167
+ | `partial_images` | Integer from 0 to 3. The proxy streams up to that many draft frames while the image renders, and Pi shows them as tool progress. Requires `n` of 1. Each draft adds about 100 image output tokens. |
168
+ | `images` | One to 16 local PNG, JPEG, or WebP paths. Supplying these selects `/v1/images/edits` instead of generation. |
169
+ | `mask` | Optional local PNG mask. The first image must also be PNG with matching dimensions. The mask needs a grayscale-alpha or RGBA alpha channel; transparent regions mark edits. |
170
+ | `input_fidelity` | `low` or `high`, for GPT Image 1.5 editing only. Omit it for other models. |
171
+
172
+ Editing uploads the files you name to the configured proxy. Paths can be absolute or relative to the working directory. The extension accepts regular files, checks file signatures, and limits uploads to 14 MiB per file and 20 MiB total including the mask. It does not fetch input URLs, resize inputs, or modify source files. OpenAI performs full image decoding and validates the edit. These local limits keep base64 uploads bounded and may be lower than upstream limits.
173
+
174
+ ```text
175
+ Generate two images with api/gpt-image-2.5-sunburst at 1536x864, quality max, output_format webp, output_compression 85.
176
+ Edit ./photo.png with api/gpt-image-2.5-flare: replace the background with a garden, using ./mask.png as the mask.
177
+ ```
178
+
179
+ All returned images share the existing 32 MiB response limit and 4 MiB preview budget. Streamed requests allow another 32 MiB for each draft frame. The extension saves only the final image and drops drafts it cannot decode. Larger sizes, higher quality, and multiple images can increase cost and latency. Use fewer images or JPEG/WebP if the response exceeds the limit. Generation and editing do not retry after a failure.
180
+
181
+ The extension supports the verified options above, not a general OpenAI API passthrough. Remote file IDs, annotation markup, audio, batches, and realtime APIs are not exposed. Adding an upstream API key does not enable endpoints that the proxy or extension cannot handle.
182
+
183
+ ### Image usage and cost
184
+
185
+ OpenAI image responses report token usage. The image tool returns it to Pi, so image calls count toward the session totals in the footer and `/session`.
186
+
187
+ Costs are estimates at OpenAI list prices per million tokens: $5 for text input, $8 for image input, and $30 for image output on GPT Image 2 and 2.5 Sunburst and Flare. GPT Image 1.5 output costs $32. The plain `gpt-image-2.5` subscription model has no published price, so it reports tokens at zero cost. Subscription routes show the same estimates when the proxy reports usage, even though the plan covers them. Google and xAI image results report no usage.
188
+
189
+ ### Pi image models
190
+
191
+ When Pi refreshes the catalog, at interactive startup or through `/cliproxyapi-refresh`, the advertised image models also register as Pi image models. Codemode scripts list them with `models.getAvailableOfType("image", "cliproxyapi")` and call `models.generateImages()`, and other extensions can use `ctx.modelRegistry.generateImages()`.
192
+
193
+ These calls send the prompt with backend defaults. Pi's image interface has no size, quality, count, mask, or preview options. Image blocks in the input select OpenAI editing, under the same 16-image, 14 MiB per image, and 20 MiB total limits. Google and xAI models accept text only. Results arrive as image blocks with usage and are not saved to disk. Use `cliproxyapi_generate_image` when you need controls, masks, or saved files.
194
+
150
195
  ### Supported media models
151
196
 
152
197
  Your proxy must advertise the exact ID for a model to be available.
153
198
 
154
- | Provider | Models |
155
- | --- | --- |
156
- | OpenAI models | `gpt-image-2.5-flare`, `gpt-image-2.5-sunburst`, `gpt-image-2.5`, `gpt-image-2`, `gpt-image-1.5` |
157
- | Google models | `gemini-2.5-flash-image`, `gemini-3.1-flash-image`, `gemini-3-pro-image`, `gemini-3.1-flash-lite-image` |
158
- | xAI models | `grok-imagine-image`, `grok-imagine-image-quality`, `grok-imagine-image-2.0`, `grok-imagine-video`, `grok-imagine-video-1.5`, `grok-imagine-video-1.5-preview` |
199
+ | Provider | Models |
200
+ | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
201
+ | OpenAI models | `gpt-image-2.5-flare`, `gpt-image-2.5-sunburst`, `gpt-image-2.5`, `gpt-image-2`, `gpt-image-1.5` |
202
+ | Google models | `gemini-2.5-flash-image`, `gemini-3.1-flash-image`, `gemini-3-pro-image`, `gemini-3.1-flash-lite-image` |
203
+ | xAI models | `grok-imagine-image`, `grok-imagine-image-quality`, `grok-imagine-image-2.0`, `grok-imagine-video`, `grok-imagine-video-1.5`, `grok-imagine-video-1.5-preview` |
159
204
 
160
- Supported media IDs can also use advertised `api/` and `oauth/` credential routes. Gemini image models also support `vertex/` and `antigravity/` backend routes. Those backend prefixes do not enable OpenAI or xAI media execution; the picker shows those entries as disabled. Other prefixes, nested prefixes, media aliases, unknown media IDs, Google Veo, and editing endpoints are unsupported.
205
+ Supported media IDs can also use advertised `api/` and `oauth/` credential routes. OpenAI's API has no plain `gpt-image-2.5`, so the picker disables `api/gpt-image-2.5` and points to Sunburst or Flare; subscription routes keep it. Gemini image models also support `vertex/` and `antigravity/` backend routes. Those backend prefixes do not enable OpenAI or xAI media execution; the picker shows those entries as disabled. Other prefixes, nested prefixes, media aliases, unknown media IDs, Google Veo, and non-OpenAI editing are unsupported.
161
206
 
162
207
  The extension disables these retired Imagen IDs, including their supported prefixed forms:
163
208
 
@@ -179,25 +224,27 @@ Gemini responses must contain one completed candidate. The extension skips thoug
179
224
 
180
225
  Video submission returns a request ID without waiting for generation. Call `cliproxyapi_video_status` with that ID for each later check. Upstream `done` maps to completed; `failed`, `expired`, and moderation-rejected results map to failed. The extension omits URLs for moderation failures. It returns successful video URLs without downloading them or sending proxy credentials to them. xAI video URLs are temporary.
181
226
 
182
- | Operation | Deadline | Response limit |
183
- | --- | --- | --- |
184
- | Catalog fetch | 10 seconds | 4 MiB |
185
- | Media listing or video status | 15 seconds | 4 MiB for the catalog; 64 KiB for video status |
186
- | OpenAI image generation | 10 minutes | 32 MiB |
187
- | Other image generation | 3 minutes | 32 MiB |
188
- | Video submission | 60 seconds | 64 KiB |
227
+ | Operation | Deadline | Response limit |
228
+ | ---------------------------------- | ---------- | ---------------------------------------------- |
229
+ | Catalog fetch | 10 seconds | 4 MiB |
230
+ | Media listing or video status | 15 seconds | 4 MiB for the catalog; 64 KiB for video status |
231
+ | OpenAI image generation or editing | 10 minutes | 32 MiB, plus 32 MiB per streamed draft |
232
+ | Other image generation | 3 minutes | 32 MiB |
233
+ | Video submission | 60 seconds | 64 KiB |
189
234
 
190
- Media deadlines include authentication and network waits. Discovery and media requests reject redirects and report HTTP errors without upstream response bodies.
235
+ Media deadlines include input-file reads, authentication, and network waits. Discovery and media requests reject redirects. Media errors quote the `message` from an upstream JSON error, cut to 500 characters, with credentials and control characters removed. Other error bodies and discovery errors report only the HTTP status.
191
236
 
192
237
  The media tools do not retry generation. Cancellation or timeout can leave work running upstream. Check a known video request ID before submitting another generation.
193
238
 
194
239
  ## Chat models
195
240
 
196
- The extension discovers available IDs through `/v1/models` and matches them against Pi's built-in metadata for limits, prices, inputs, and thinking capabilities.
241
+ The extension discovers available IDs through `/v1/models` and matches them against Pi's Anthropic, OpenAI, OpenAI Codex, Google, and xAI metadata for limits, prices, inputs, and thinking capabilities. xAI Grok models use Pi's native Responses adapter through the proxy.
242
+
243
+ During a session, the extension also reads the provider catalogs that Pi refreshes from its catalog service. Models newer than Pi's bundled data can then register; for models Pi already bundles, the bundled metadata wins.
197
244
 
198
245
  ### Refresh
199
246
 
200
- Run `/cliproxyapi-refresh` to update the chat catalog.
247
+ Run `/cliproxyapi-refresh` to update the chat and image catalogs. It reports both counts and lists up to 20 advertised IDs that it could not register because Pi has no metadata for them or the match is ambiguous.
201
248
 
202
249
  ### Wait for quota reset
203
250
 
@@ -331,11 +378,11 @@ Run `/reload` after changing settings and `/cliproxyapi-refresh` before starting
331
378
 
332
379
  Claude uses the legacy fine-grained tool-streaming header in place of eager tool fields. The extension disables unverified deferred-tool and strict-tool capabilities. Responses function tools use `strict: null` when Pi omits strictness, so optional arguments remain optional. Explicit payload hooks retain final control.
333
380
 
334
- OpenAI images POST `model`, `prompt`, and `n=1` to `/v1/images/generations`, without `response_format`. xAI uses the same route with `response_format: "b64_json"`. Gemini uses `/v1beta/models/{id}:generateContent`. Media generation sends the prompt and generation options without chat history, system instructions, or tools.
381
+ OpenAI images POST `model`, `prompt`, `n`, and supplied controls to `/v1/images/generations`, without `response_format`. Editing uses `/v1/images/edits` with JSON `images` and optional `mask` data URLs. A nonzero `partial_images` adds `stream: true`, and the extension reads server-sent events. Each `*.partial_image` frame becomes a progress preview, and the `*.completed` frame carries the final image and usage. xAI uses the same route with `response_format: "b64_json"`. Gemini uses `/v1beta/models/{id}:generateContent`. Media generation sends the prompt and generation options without chat history, system instructions, or tools.
335
382
 
336
- OpenAI image POSTs use a dedicated HTTP connection and preserve Node's configured proxy routing. They disable socket timeouts so the ten-minute deadline controls the request, request identity encoding, and reject compressed responses.
383
+ OpenAI image POSTs use a dedicated HTTP connection. It follows `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` from the environment, where Pi also places its `httpProxy` setting, on Node 22.21+ and 24.5+. Older Node versions connect directly. The connection disables socket timeouts so the ten-minute deadline controls the request, requests identity encoding, and rejects compressed responses.
337
384
 
338
- The implementation follows CLIProxyAPI v7.3.1's [OpenAI image handler](https://github.com/router-for-me/CLIProxyAPI/blob/v7.3.1/sdk/api/handlers/openai/openai_images_handlers.go) and [Codex image executor](https://github.com/router-for-me/CLIProxyAPI/blob/v7.3.1/internal/runtime/executor/codex_openai_images.go). The xAI and Google routes follow v7.2.158's [video handler](https://github.com/router-for-me/CLIProxyAPI/blob/v7.2.158/sdk/api/handlers/openai/openai_videos_handlers.go), [Gemini handler](https://github.com/router-for-me/CLIProxyAPI/blob/v7.2.158/sdk/api/handlers/gemini/gemini_handlers.go), and [Vertex executor](https://github.com/router-for-me/CLIProxyAPI/blob/v7.2.158/internal/runtime/executor/gemini_vertex_executor.go). See [xAI's video documentation](https://docs.x.ai/developers/model-capabilities/video/generation) for duration and status semantics.
385
+ The OpenAI image implementation follows CLIProxyAPI v8.0.48's [image handler](https://github.com/router-for-me/CLIProxyAPI/blob/v8.0.48/sdk/api/handlers/openai/openai_images_handlers.go) and [Codex image executor](https://github.com/router-for-me/CLIProxyAPI/blob/v8.0.48/internal/runtime/executor/codex_openai_images.go), plus the OpenAI [generation](https://developers.openai.com/api/reference/resources/images/methods/generate) and [editing](https://developers.openai.com/api/reference/resources/images/methods/edit) references. The xAI and Google routes follow v7.2.158's [video handler](https://github.com/router-for-me/CLIProxyAPI/blob/v7.2.158/sdk/api/handlers/openai/openai_videos_handlers.go), [Gemini handler](https://github.com/router-for-me/CLIProxyAPI/blob/v7.2.158/sdk/api/handlers/gemini/gemini_handlers.go), and [Vertex executor](https://github.com/router-for-me/CLIProxyAPI/blob/v7.2.158/internal/runtime/executor/gemini_vertex_executor.go). See [xAI's video documentation](https://docs.x.ai/developers/model-capabilities/video/generation) for duration and status semantics.
339
386
 
340
387
  ## License
341
388
 
@@ -4,7 +4,7 @@ import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil
4
4
  import { PROVIDER_ID, parseConfig } from "../src/config.ts";
5
5
  import { registerMediaTools } from "../src/media.ts";
6
6
  import { registerModelPicker } from "../src/picker.ts";
7
- import { createCliproxyProvider } from "../src/provider.ts";
7
+ import { createCliproxyProvider, metadataCatalog } from "../src/provider.ts";
8
8
  import { registerAutoRetry } from "../src/retry.ts";
9
9
 
10
10
  export default async function (pi: ExtensionAPI) {
@@ -37,32 +37,32 @@ export default async function (pi: ExtensionAPI) {
37
37
  context = undefined;
38
38
  waits.clear();
39
39
  });
40
- pi.registerProvider(
41
- createCliproxyProvider(
42
- config,
43
- undefined,
44
- (id, seconds) => {
45
- if (!context) return;
46
- if (seconds === undefined) waits.delete(id);
47
- else {
48
- if (!waits.has(id)) {
49
- const message = `CLIProxyAPI quota limit. Retrying at ${new Date(Date.now() + seconds * 1000).toLocaleString()}. Cancel the current request to stop waiting.`;
50
- if (context.mode === "print" || context.mode === "json") process.stderr.write(`${message}\n`);
51
- else context.ui.notify(message, "warning");
52
- }
53
- waits.set(id, seconds);
40
+ const provider = createCliproxyProvider(
41
+ config,
42
+ // Session context exposes Pi's refreshed catalogs, including models newer than its bundled data.
43
+ () => metadataCatalog(context && ((id) => context?.modelRegistry.getProvider(id)?.getModels())),
44
+ (id, seconds) => {
45
+ if (!context) return;
46
+ if (seconds === undefined) waits.delete(id);
47
+ else {
48
+ if (!waits.has(id)) {
49
+ const message = `CLIProxyAPI quota limit. Retrying at ${new Date(Date.now() + seconds * 1000).toLocaleString()}. Cancel the current request to stop waiting.`;
50
+ if (context.mode === "print" || context.mode === "json") process.stderr.write(`${message}\n`);
51
+ else context.ui.notify(message, "warning");
54
52
  }
55
- const remaining = Math.min(...waits.values());
56
- context.ui.setStatus(
57
- "cliproxyapi-quota",
58
- waits.size
59
- ? `CLIProxyAPI quota: retry in ${Math.floor(remaining / 3600)}h ${Math.floor(remaining / 60) % 60}m ${remaining % 60}s`
60
- : undefined,
61
- );
62
- },
63
- compactionSignals,
64
- ),
53
+ waits.set(id, seconds);
54
+ }
55
+ const remaining = Math.min(...waits.values());
56
+ context.ui.setStatus(
57
+ "cliproxyapi-quota",
58
+ waits.size
59
+ ? `CLIProxyAPI quota: retry in ${Math.floor(remaining / 3600)}h ${Math.floor(remaining / 60) % 60}m ${remaining % 60}s`
60
+ : undefined,
61
+ );
62
+ },
63
+ compactionSignals,
65
64
  );
65
+ pi.registerProvider(provider);
66
66
  registerAutoRetry(pi);
67
67
  registerMediaTools(pi, config);
68
68
  registerModelPicker(pi, config);
@@ -88,7 +88,16 @@ export default async function (pi: ExtensionAPI) {
88
88
  return;
89
89
  }
90
90
  const count = ctx.modelRegistry.getAll().filter((model) => model.provider === PROVIDER_ID).length;
91
- ctx.ui.notify(`CLIProxyAPI: ${count} models.`, "info");
91
+ const images = ctx.modelRegistry.getModelsOfType("image", PROVIDER_ID).length;
92
+ const skipped = provider.skippedModels();
93
+ const listed = [
94
+ ...skipped.slice(0, 20),
95
+ ...(skipped.length > 20 ? [`${skipped.length - 20} more`] : []),
96
+ ];
97
+ ctx.ui.notify(
98
+ `CLIProxyAPI: ${count} chat models, ${images} image models.${listed.length ? ` Not registered (missing or ambiguous Pi metadata): ${listed.join(", ")}.` : ""}`,
99
+ "info",
100
+ );
92
101
  },
93
102
  });
94
103
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-cliproxyapi-native",
3
- "version": "0.3.0",
4
- "description": "CLIProxyAPI chat discovery and image/video generation with Pi's native adapters",
3
+ "version": "0.3.2",
4
+ "description": "CLIProxyAPI chat discovery, image generation and editing, and video generation with Pi's native adapters",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -38,12 +38,12 @@
38
38
  "check": "pnpm typecheck && pnpm lint && pnpm test"
39
39
  },
40
40
  "devDependencies": {
41
- "@biomejs/biome": "^2.5.13",
41
+ "@biomejs/biome": "^2.6.4",
42
42
  "@earendil-works/pi-ai": "^1.4.0",
43
43
  "@earendil-works/pi-coding-agent": "^1.4.0",
44
44
  "@earendil-works/pi-tui": "^1.4.0",
45
- "@types/node": "^24.13.4",
46
- "typescript": "5.9.3"
45
+ "@types/node": "^24.19.2",
46
+ "typescript": "7.0.2"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@earendil-works/pi-ai": "*",
package/src/catalog.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Api, Model } from "@earendil-works/pi-ai";
2
- import { type Config, isModelId, isRecord, PROVIDER_ID } from "./config.ts";
2
+ import { type Config, deadline, isModelId, isRecord, PROVIDER_ID, validateKey } from "./config.ts";
3
3
 
4
4
  export type CpaApi =
5
5
  | "anthropic-messages"
@@ -34,6 +34,51 @@ export function parseCatalog(value: unknown) {
34
34
  });
35
35
  }
36
36
 
37
+ export async function fetchCatalog(config: Config, key: string, signal: AbortSignal) {
38
+ validateKey(key);
39
+ const boundedSignal = deadline(signal, 10000);
40
+ let response: Response;
41
+ try {
42
+ response = await fetch(`${config.baseUrl}/v1/models`, {
43
+ headers: { Authorization: `Bearer ${key}` },
44
+ redirect: "error",
45
+ signal: boundedSignal,
46
+ });
47
+ } catch {
48
+ signal.throwIfAborted();
49
+ throw new Error("CLIProxyAPI discovery failed: connection, timeout, or redirect error.");
50
+ }
51
+ if (!response.ok) {
52
+ await response.body?.cancel();
53
+ throw new Error(`CLIProxyAPI discovery failed (HTTP ${response.status}).`);
54
+ }
55
+ let payload: unknown;
56
+ const reader = response.body?.getReader();
57
+ if (!reader) throw new Error("CLIProxyAPI discovery returned an empty response.");
58
+ try {
59
+ const decoder = new TextDecoder();
60
+ let text = "";
61
+ let size = 0;
62
+ while (true) {
63
+ const chunk = await reader.read();
64
+ if (chunk.done) break;
65
+ size += chunk.value.byteLength;
66
+ if (size > 4 * 1024 * 1024) {
67
+ await reader.cancel();
68
+ throw new Error("Catalog too large.");
69
+ }
70
+ text += decoder.decode(chunk.value, { stream: true });
71
+ }
72
+ payload = JSON.parse(text + decoder.decode());
73
+ } catch {
74
+ signal.throwIfAborted();
75
+ throw new Error("CLIProxyAPI catalog is invalid, incomplete, or larger than 4 MiB.");
76
+ } finally {
77
+ reader.releaseLock();
78
+ }
79
+ return payload;
80
+ }
81
+
37
82
  // Resolve metadata and capabilities without changing registry or request IDs.
38
83
  export function modelRoute(id: string) {
39
84
  const match = /^(vertex|antigravity|oauth|api)\/(.+)$/.exec(id);
@@ -59,17 +104,31 @@ export function routedName(id: string, name = id) {
59
104
  export type MediaPurpose = "image" | "video";
60
105
 
61
106
  const imagenRetirement = "Retired on Vertex (June 2026); disabled. Select a Nano Banana image model instead.";
107
+ // OpenAI list prices in USD per million tokens: text input, image input, image output.
108
+ const gptImage2Price = [5, 8, 30] as const;
62
109
 
63
110
  // Exact media IDs, including retired entries; vision input alone does not imply image output.
64
111
  const mediaModels = new Map<
65
112
  string,
66
- { name: string; purpose: MediaPurpose; route?: "xai" | "gemini" | "openai"; disabledReason?: string }
113
+ {
114
+ name: string;
115
+ purpose: MediaPurpose;
116
+ route?: "xai" | "gemini" | "openai";
117
+ disabledReason?: string;
118
+ price?: readonly [number, number, number];
119
+ }
67
120
  >([
68
- ["gpt-image-2.5-flare", { name: "GPT Image 2.5 Flare", purpose: "image", route: "openai" }],
69
- ["gpt-image-2.5-sunburst", { name: "GPT Image 2.5 Sunburst", purpose: "image", route: "openai" }],
121
+ [
122
+ "gpt-image-2.5-flare",
123
+ { name: "GPT Image 2.5 Flare", purpose: "image", route: "openai", price: gptImage2Price },
124
+ ],
125
+ [
126
+ "gpt-image-2.5-sunburst",
127
+ { name: "GPT Image 2.5 Sunburst", purpose: "image", route: "openai", price: gptImage2Price },
128
+ ],
70
129
  ["gpt-image-2.5", { name: "GPT Image 2.5", purpose: "image", route: "openai" }],
71
- ["gpt-image-2", { name: "GPT Image 2", purpose: "image", route: "openai" }],
72
- ["gpt-image-1.5", { name: "GPT Image 1.5", purpose: "image", route: "openai" }],
130
+ ["gpt-image-2", { name: "GPT Image 2", purpose: "image", route: "openai", price: gptImage2Price }],
131
+ ["gpt-image-1.5", { name: "GPT Image 1.5", purpose: "image", route: "openai", price: [5, 8, 32] }],
73
132
  ["grok-imagine-image", { name: "Grok Imagine Image", purpose: "image", route: "xai" }],
74
133
  ["grok-imagine-image-quality", { name: "Grok Imagine Image Quality", purpose: "image", route: "xai" }],
75
134
  ["grok-imagine-image-2.0", { name: "Grok Imagine Image 2.0", purpose: "image", route: "xai" }],
@@ -100,11 +159,20 @@ const mediaModels = new Map<
100
159
  ]);
101
160
 
102
161
  export function mediaCapability(id: string) {
103
- const { metadataId, backend } = modelRoute(id);
162
+ const { metadataId, backend, authType } = modelRoute(id);
104
163
  const capability = mediaModels.get(metadataId);
105
164
  if (backend && (capability?.route === "xai" || capability?.route === "openai")) {
106
165
  return { ...capability, route: undefined, disabledReason: `Unsupported media execution on ${backend}.` };
107
166
  }
167
+ // CLIProxyAPI 8.0.48 advertises it for API keys, but OpenAI's Images API rejects the bare 2.5 name.
168
+ if (capability && authType === "api-key" && metadataId === "gpt-image-2.5") {
169
+ return {
170
+ ...capability,
171
+ route: undefined,
172
+ disabledReason:
173
+ "OpenAI's API has no gpt-image-2.5 model. Use api/gpt-image-2.5-sunburst or api/gpt-image-2.5-flare.",
174
+ };
175
+ }
108
176
  return capability;
109
177
  }
110
178
 
@@ -114,7 +182,8 @@ export function mediaPurpose(id: string) {
114
182
 
115
183
  export function mediaControls(id: string) {
116
184
  const capability = mediaCapability(id);
117
- const { metadataId } = modelRoute(id);
185
+ const { metadataId, authType } = modelRoute(id);
186
+ const openai = capability?.route === "openai";
118
187
  const commonRatios = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"];
119
188
  let resolution: string[] = [];
120
189
  let aspect_ratio: string[] = [];
@@ -144,9 +213,52 @@ export function mediaControls(id: string) {
144
213
  : undefined,
145
214
  resolution,
146
215
  aspect_ratio,
216
+ quality: openai
217
+ ? [
218
+ "auto",
219
+ "low",
220
+ "medium",
221
+ "high",
222
+ ...(/^gpt-image-2\.5-(sunburst|flare)$/.test(metadataId) ? ["xhigh", "max"] : []),
223
+ ]
224
+ : [],
225
+ background: openai ? ["auto", "opaque", "transparent"] : [],
226
+ output_format: openai ? ["png", "jpeg", "webp"] : [],
227
+ output_compression: openai ? { min: 0, max: 100, formats: ["jpeg", "webp"] } : undefined,
228
+ moderation: openai ? ["auto", "low"] : [],
229
+ // Subscription image-tool translation does not forward n.
230
+ n: openai ? { min: 1, max: authType === "api-key" ? 10 : 1 } : undefined,
231
+ partial_images: openai ? { min: 0, max: 3 } : undefined,
232
+ editing: openai
233
+ ? { max_images: 16, max_file_bytes: 14 * 1024 * 1024, max_total_bytes: 20 * 1024 * 1024, mask: "png" }
234
+ : undefined,
235
+ input_fidelity: openai && metadataId === "gpt-image-1.5" ? ["low", "high"] : [],
147
236
  };
148
237
  }
149
238
 
239
+ export const IMAGE_API = "cliproxyapi-images";
240
+
241
+ // Pi image models for codemode and extensions; video stays tool-only because Pi has no video interface.
242
+ export function mapImageModels(value: unknown, config: Config) {
243
+ return mapMediaCatalog(value).flatMap(({ id, purpose, name, controls }) => {
244
+ if (purpose !== "image") return [];
245
+ const [input = 0, , output = 0] = mediaCapability(id)?.price ?? [];
246
+ return [
247
+ {
248
+ type: "image" as const,
249
+ id,
250
+ name,
251
+ api: IMAGE_API,
252
+ provider: PROVIDER_ID,
253
+ baseUrl: config.baseUrl,
254
+ input: controls.editing ? ["text" as const, "image" as const] : ["text" as const],
255
+ output: ["image" as const],
256
+ cost: { input, output, cacheRead: 0, cacheWrite: 0 },
257
+ },
258
+ ];
259
+ });
260
+ }
261
+
150
262
  export function mapMediaCatalog(value: unknown) {
151
263
  const models = new Map<
152
264
  string,
@@ -174,6 +286,7 @@ const owners: Readonly<Record<string, string>> = {
174
286
  google: "google",
175
287
  gemini: "google",
176
288
  "gemini-cli": "google",
289
+ xai: "xai",
177
290
  };
178
291
 
179
292
  // Built-in aliases for advertised IDs without native metadata; exact metadata still wins.
@@ -189,11 +302,7 @@ export function mapCatalog(value: unknown, config: Config, known: readonly Model
189
302
  const byId = Map.groupBy(known, (model) => model.id);
190
303
  const byReference = Map.groupBy(known, (model) => `${model.provider}/${model.id}`);
191
304
  for (const entry of parseCatalog(value)) {
192
- if (entry.hidden || models.has(entry.id)) continue;
193
- if (mediaPurpose(entry.id)) {
194
- skipped.add(entry.id);
195
- continue;
196
- }
305
+ if (entry.hidden || models.has(entry.id) || mediaPurpose(entry.id)) continue;
197
306
  const { metadataId, prefix } = modelRoute(entry.id);
198
307
  const routed = !entry.id.includes("/") || prefix;
199
308
  const alias = Object.hasOwn(config.aliases, entry.id)
package/src/config.ts CHANGED
@@ -9,6 +9,17 @@ export function isModelId(value: unknown): value is string {
9
9
  return typeof value === "string" && /^[!-~]{1,256}$/.test(value);
10
10
  }
11
11
 
12
+ export function validateKey(key: string) {
13
+ if (!/^[!-~]{1,4096}$/.test(key)) {
14
+ throw new Error("CLIProxyAPI requires a non-empty API key without whitespace or control characters.");
15
+ }
16
+ return key;
17
+ }
18
+
19
+ export function deadline(signal: AbortSignal | undefined, milliseconds: number) {
20
+ return AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(milliseconds)]);
21
+ }
22
+
12
23
  export function normalizeBaseUrl(value: string) {
13
24
  let url: URL;
14
25
  try {