pi-cliproxyapi-native 0.3.1 → 0.3.3

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
@@ -13,6 +13,8 @@ Chat uses Pi's native API adapters and model metadata, with availability from yo
13
13
  - [Generate media](#generate-media)
14
14
  - [Resolution and aspect ratio](#resolution-and-aspect-ratio)
15
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)
16
18
  - [Supported media models](#supported-media-models)
17
19
  - [Files, status, and limits](#files-status-and-limits)
18
20
  - [Chat models](#chat-models)
@@ -34,10 +36,10 @@ The extension targets Pi 1.4.0 and CLIProxyAPI 8.0.48, with Node.js 22.19 or new
34
36
  If you installed `pi-cliproxy-native`, remove it before installing the renamed package. Both register the same `cliproxyapi` provider.
35
37
 
36
38
  ```sh
37
- pi install npm:pi-cliproxyapi-native@0.3.1
39
+ pi install npm:pi-cliproxyapi-native@0.3.3
38
40
  ```
39
41
 
40
- For Git installation, use `pi install git:github.com/hawkff/pi-cliproxyapi-native@v0.3.1`. 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.3`. For a local checkout, use `pi install /path/to/pi-cliproxyapi-native`.
41
43
 
42
44
  The default proxy address is `http://localhost:8317`. For another address, set the [connection](#connection) before logging in. Restart Pi, then run:
43
45
 
@@ -154,17 +156,18 @@ Control values follow the [OpenAI image guide](https://developers.openai.com/api
154
156
 
155
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.
156
158
 
157
- | Option | Supported values |
158
- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
159
- | `quality` | `auto`, `low`, `medium`, `high`. Sunburst and Flare also accept `xhigh` and `max`. |
160
- | `background` | `auto`, `opaque`, `transparent`. Transparent output requires PNG or WebP; GPT Image 2 transparency is in preview upstream. |
161
- | `output_format` | `png`, `jpeg`, `webp`. JPEG and WebP can reduce file size and help keep results within the response limit. |
162
- | `output_compression` | Integer from 0 to 100, with an explicit `jpeg` or `webp` format. Zero is a valid value. |
163
- | `moderation` | `auto` or `low`. OpenAI still applies its content policies. |
164
- | `n` | Integer from 1 to 10 on an explicit `api/` image route. Other routes allow only 1 because subscription translation can drop the count. |
165
- | `images` | One to 16 local PNG, JPEG, or WebP paths. Supplying these selects `/v1/images/edits` instead of generation. |
166
- | `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. |
167
- | `input_fidelity` | `low` or `high`, for GPT Image 1.5 editing only. Omit it for other models. |
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. |
168
171
 
169
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.
170
173
 
@@ -173,9 +176,21 @@ Generate two images with api/gpt-image-2.5-sunburst at 1536x864, quality max, ou
173
176
  Edit ./photo.png with api/gpt-image-2.5-flare: replace the background with a garden, using ./mask.png as the mask.
174
177
  ```
175
178
 
176
- All returned images share the existing 32 MiB response limit and 4 MiB preview budget. 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.
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.
177
180
 
178
- The extension supports the verified options above, not a general OpenAI API passthrough. Streaming image previews, 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.
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.
179
194
 
180
195
  ### Supported media models
181
196
 
@@ -187,7 +202,7 @@ Your proxy must advertise the exact ID for a model to be available.
187
202
  | Google models | `gemini-2.5-flash-image`, `gemini-3.1-flash-image`, `gemini-3-pro-image`, `gemini-3.1-flash-lite-image` |
188
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` |
189
204
 
190
- 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 non-OpenAI editing 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.
191
206
 
192
207
  The extension disables these retired Imagen IDs, including their supported prefixed forms:
193
208
 
@@ -213,21 +228,23 @@ Video submission returns a request ID without waiting for generation. Call `clip
213
228
  | ---------------------------------- | ---------- | ---------------------------------------------- |
214
229
  | Catalog fetch | 10 seconds | 4 MiB |
215
230
  | Media listing or video status | 15 seconds | 4 MiB for the catalog; 64 KiB for video status |
216
- | OpenAI image generation or editing | 10 minutes | 32 MiB |
231
+ | OpenAI image generation or editing | 10 minutes | 32 MiB, plus 32 MiB per streamed draft |
217
232
  | Other image generation | 3 minutes | 32 MiB |
218
233
  | Video submission | 60 seconds | 64 KiB |
219
234
 
220
- Media deadlines include input-file reads, 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.
221
236
 
222
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.
223
238
 
224
239
  ## Chat models
225
240
 
226
- 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.
227
244
 
228
245
  ### Refresh
229
246
 
230
- 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.
231
248
 
232
249
  ### Wait for quota reset
233
250
 
@@ -359,11 +376,17 @@ Run `/reload` after changing settings and `/cliproxyapi-refresh` before starting
359
376
 
360
377
  ## Protocol notes
361
378
 
362
- 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.
379
+ Claude uses the legacy fine-grained tool-streaming header in place of eager tool fields.
380
+
381
+ GPT models keep Pi's OpenAI Responses capabilities when the proxy lists them as owned by OpenAI. These capabilities are strict tools, grammar tools such as codemode, tool declarations added mid-conversation, tool search, and instructions placed mid-conversation. Each passed live checks through CLIProxyAPI 8.0.48 with an OpenAI API key and with a ChatGPT subscription. OpenAI-compatible providers and other model families keep these capabilities off.
382
+
383
+ On `api/` routes, Pi can send thinking effort as a `configuration_update` item. ChatGPT subscriptions ignore that item, so unprefixed and `oauth/` routes send the effort as a request field.
384
+
385
+ When Pi omits strictness in the top-level Responses tool list, the extension sends `strict: null`, so optional arguments remain optional. Tools declared inside the conversation keep Pi's strictness values. Explicit payload hooks retain final control.
363
386
 
364
- 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. 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.
387
+ 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.
365
388
 
366
- 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.
389
+ 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.
367
390
 
368
391
  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.
369
392
 
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cliproxyapi-native",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "CLIProxyAPI chat discovery, image generation and editing, and video generation with Pi's native adapters",
5
5
  "publishConfig": {
6
6
  "access": "public"
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
 
@@ -160,6 +228,7 @@ export function mediaControls(id: string) {
160
228
  moderation: openai ? ["auto", "low"] : [],
161
229
  // Subscription image-tool translation does not forward n.
162
230
  n: openai ? { min: 1, max: authType === "api-key" ? 10 : 1 } : undefined,
231
+ partial_images: openai ? { min: 0, max: 3 } : undefined,
163
232
  editing: openai
164
233
  ? { max_images: 16, max_file_bytes: 14 * 1024 * 1024, max_total_bytes: 20 * 1024 * 1024, mask: "png" }
165
234
  : undefined,
@@ -167,6 +236,29 @@ export function mediaControls(id: string) {
167
236
  };
168
237
  }
169
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
+
170
262
  export function mapMediaCatalog(value: unknown) {
171
263
  const models = new Map<
172
264
  string,
@@ -194,6 +286,7 @@ const owners: Readonly<Record<string, string>> = {
194
286
  google: "google",
195
287
  gemini: "google",
196
288
  "gemini-cli": "google",
289
+ xai: "xai",
197
290
  };
198
291
 
199
292
  // Built-in aliases for advertised IDs without native metadata; exact metadata still wins.
@@ -209,11 +302,7 @@ export function mapCatalog(value: unknown, config: Config, known: readonly Model
209
302
  const byId = Map.groupBy(known, (model) => model.id);
210
303
  const byReference = Map.groupBy(known, (model) => `${model.provider}/${model.id}`);
211
304
  for (const entry of parseCatalog(value)) {
212
- if (entry.hidden || models.has(entry.id)) continue;
213
- if (mediaPurpose(entry.id)) {
214
- skipped.add(entry.id);
215
- continue;
216
- }
305
+ if (entry.hidden || models.has(entry.id) || mediaPurpose(entry.id)) continue;
217
306
  const { metadataId, prefix } = modelRoute(entry.id);
218
307
  const routed = !entry.id.includes("/") || prefix;
219
308
  const alias = Object.hasOwn(config.aliases, entry.id)
@@ -243,6 +332,16 @@ export function mapCatalog(value: unknown, config: Config, known: readonly Model
243
332
  skipped.add(entry.id);
244
333
  continue;
245
334
  }
335
+ // CLIProxyAPI 8.0.48 forwards strict and grammar tools, additional_tools, tool search, and
336
+ // mid-conversation instructions to OpenAI API keys and ChatGPT subscriptions (live-checked).
337
+ // Other owners stay conservative. If an OpenAI-compatible provider shares a bare ID, the proxy
338
+ // lists one owner, and its Chat translator converts these items for that provider.
339
+ const openai =
340
+ api === "openai-responses" &&
341
+ !metadataId.includes("/") &&
342
+ (!prefix || prefix === "api" || prefix === "oauth") &&
343
+ (preferred === "openai" || preferred === "openai-codex") &&
344
+ (reference.provider === "openai" || reference.provider === "openai-codex");
246
345
  const model: Model<CpaApi> = {
247
346
  ...structuredClone(reference),
248
347
  id: entry.id,
@@ -264,15 +363,19 @@ export function mapCatalog(value: unknown, config: Config, known: readonly Model
264
363
  }
265
364
  : api === "google-generative-ai"
266
365
  ? reference.compat
267
- : {
268
- ...reference.compat,
269
- supportsStrictMode: false,
270
- supportsMidConvoSystemMessages: false,
271
- supportsMidConvoToolAdditions: false,
272
- supportsOpenAIGrammarTools: false,
273
- supportsToolSearch: false,
274
- supportsAdditionalTools: false,
275
- },
366
+ : openai
367
+ ? // ChatGPT subscriptions ignore configuration_update effort items; only API keys apply them.
368
+ { ...reference.compat, ...(prefix === "api" ? {} : { supportsMidConvoEffort: false }) }
369
+ : {
370
+ ...reference.compat,
371
+ supportsStrictMode: false,
372
+ supportsMidConvoSystemMessages: false,
373
+ supportsMidConvoToolAdditions: false,
374
+ supportsOpenAIGrammarTools: false,
375
+ supportsToolSearch: false,
376
+ supportsAdditionalTools: false,
377
+ supportsMidConvoEffort: false,
378
+ },
276
379
  };
277
380
  models.set(model.id, model);
278
381
  skipped.delete(model.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 {