pi-cliproxyapi-native 0.1.4 → 0.1.6

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
@@ -6,15 +6,15 @@ Chat uses Pi's native API adapters and model metadata, with availability from yo
6
6
 
7
7
  ## Install
8
8
 
9
- This release targets Pi 0.99.2 and CLIProxyAPI 8.0.7, 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.
9
+ This release targets Pi 1.0.4 and CLIProxyAPI 8.0.18, 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.
10
10
 
11
11
  If you installed `pi-cliproxy-native`, remove it before installing the renamed package. Both register the same `cliproxyapi` provider.
12
12
 
13
13
  ```sh
14
- pi install npm:pi-cliproxyapi-native@0.1.4
14
+ pi install npm:pi-cliproxyapi-native@0.1.6
15
15
  ```
16
16
 
17
- For Git installation, use `pi install git:github.com/hawkff/pi-cliproxyapi-native@v0.1.4`. For a local checkout, use `pi install /path/to/pi-cliproxyapi-native`.
17
+ For Git installation, use `pi install git:github.com/hawkff/pi-cliproxyapi-native@v0.1.6`. For a local checkout, use `pi install /path/to/pi-cliproxyapi-native`.
18
18
 
19
19
  The default proxy address is `http://localhost:8317`. For another address, set the [connection](#connection) before logging in. Restart Pi, then run:
20
20
 
@@ -194,6 +194,8 @@ Replace `<canonical-model-id>` with an ID from Pi's Anthropic catalog. Reference
194
194
 
195
195
  A `vertex/` or `antigravity/` route can reuse its unprefixed ID's alias if that reference resolves to one metadata entry. An alias for the full prefixed ID takes precedence. Custom prefixes need an explicit chat alias. Arbitrary aliases may lack ID-specific adapter behavior; canonical Gemini IDs and the two recognized prefixes retain native thinking and tool-turn handling.
196
196
 
197
+ For Antigravity IDs `claude-opus-5-5-high` and `claude-sonnet-5-5-high`, the extension falls back to `anthropic/claude-opus-5-5` and `anthropic/claude-sonnet-5-5` metadata. This applies to bare IDs and recognized `antigravity/` or `vertex/` routes. Explicit aliases take precedence, followed by exact native metadata. The fallback requires the corresponding canonical entry in Pi and names the model like a configured alias, for example `claude-opus-5-5-high (Claude Opus 5.5)`; requests keep the advertised `-high` ID.
198
+
197
199
  Use Pi's `models.json` `modelOverrides` for limits, prices, or compatibility changes. Use its `models` array for IDs with no built-in metadata. Updating Pi supplies newer metadata. Catalog prices are estimates, not the proxy's bill; check upstream limits before increasing them.
198
200
 
199
201
  ## Backend routes
@@ -213,6 +215,8 @@ Set the CLIProxyAPI auth record's top-level `prefix` to `vertex` or `antigravity
213
215
 
214
216
  Use a reference such as `cliproxyapi/<model-id>:high` in Pi or pi-subagents. Pi separates the thinking suffix from the request ID. Supported levels come from model metadata; `xhigh` and `max` require explicit support. Pi clamps unsupported levels, and models that require thinking cannot use `off`. Keep thinking suffixes out of alias references.
215
217
 
218
+ The `-high` in Antigravity model IDs belongs to the request ID. Pi's `:high` suffix sets thinking effort. For example, `cliproxyapi/antigravity/claude-opus-5-5-high:high` sends `antigravity/claude-opus-5-5-high` with high effort.
219
+
216
220
  Child loading depends on the [pi-subagents extension settings](https://github.com/nicobailon/pi-subagents/blob/main/docs/agents.md#tool-and-extension-selection). Local foreground children can inherit providers from the parent. Background children load extensions through discovery or an allowlist. A saved catalog alone does not register this provider.
217
221
 
218
222
  For native roles that need explicit loading, add this extension to `subagentOnlyExtensions`. Merge this example into `~/.pi/agent/settings.json`, replacing the path and role name while preserving existing entries:
@@ -237,7 +241,7 @@ Claude uses the legacy fine-grained tool-streaming header in place of eager tool
237
241
 
238
242
  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.
239
243
 
240
- OpenAI image POSTs use a dedicated HTTP connection so shorter transport timeouts do not interrupt the ten-minute deadline. They request identity encoding and reject compressed responses.
244
+ 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.
241
245
 
242
246
  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.
243
247
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cliproxyapi-native",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "CLIProxyAPI chat discovery and image/video generation with Pi's native adapters",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -39,9 +39,9 @@
39
39
  },
40
40
  "devDependencies": {
41
41
  "@biomejs/biome": "^2.5.13",
42
- "@earendil-works/pi-ai": "^0.99.2",
43
- "@earendil-works/pi-coding-agent": "^0.99.2",
44
- "@earendil-works/pi-tui": "^0.99.2",
42
+ "@earendil-works/pi-ai": "^1.0.4",
43
+ "@earendil-works/pi-coding-agent": "^1.0.4",
44
+ "@earendil-works/pi-tui": "^1.0.4",
45
45
  "@types/node": "^24.13.4",
46
46
  "typescript": "5.9.3"
47
47
  },
package/src/catalog.ts CHANGED
@@ -171,9 +171,17 @@ const owners: Readonly<Record<string, string>> = {
171
171
  "gemini-cli": "google",
172
172
  };
173
173
 
174
+ // Built-in aliases for advertised IDs without native metadata; exact metadata still wins.
175
+ const metadataFallbacks = new Map([
176
+ ["claude-opus-5-5-high", "anthropic/claude-opus-5-5"],
177
+ ["claude-sonnet-5-5-high", "anthropic/claude-sonnet-5-5"],
178
+ ]);
179
+
174
180
  export function mapCatalog(value: unknown, config: Config, known: readonly Model<Api>[]) {
175
181
  const models = new Map<string, Model<CpaApi>>();
176
182
  const skipped = new Set<string>();
183
+ const byId = Map.groupBy(known, (model) => model.id);
184
+ const byReference = Map.groupBy(known, (model) => `${model.provider}/${model.id}`);
177
185
  for (const entry of parseCatalog(value)) {
178
186
  if (entry.hidden || models.has(entry.id)) continue;
179
187
  if (mediaPurpose(entry.id)) {
@@ -181,16 +189,15 @@ export function mapCatalog(value: unknown, config: Config, known: readonly Model
181
189
  continue;
182
190
  }
183
191
  const { metadataId, backend } = modelRoute(entry.id);
192
+ const routed = !entry.id.includes("/") || backend;
184
193
  const alias = Object.hasOwn(config.aliases, entry.id)
185
194
  ? config.aliases[entry.id]
186
195
  : backend && Object.hasOwn(config.aliases, metadataId)
187
196
  ? config.aliases[metadataId]
188
- : undefined;
189
- const candidates = known.filter((model) =>
190
- alias
191
- ? `${model.provider}/${model.id}` === alias
192
- : (!entry.id.includes("/") || backend) && model.id === metadataId,
193
- );
197
+ : routed && !byId.has(metadataId)
198
+ ? metadataFallbacks.get(metadataId)
199
+ : undefined;
200
+ const candidates = (alias ? byReference.get(alias) : routed ? byId.get(metadataId) : undefined) ?? [];
194
201
  const families = new Set(
195
202
  candidates.map((model) => (model.provider === "openai-codex" ? "openai" : model.provider)),
196
203
  );
package/src/media.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
- import { request as httpRequest } from "node:http";
3
- import { request as httpsRequest } from "node:https";
2
+ import * as http from "node:http";
3
+ import * as https from "node:https";
4
4
  import { join } from "node:path";
5
5
  import { addAbortSignal, Readable } from "node:stream";
6
6
  import { type ImageContent, type Static, type TextContent, Type } from "@earendil-works/pi-ai";
@@ -8,7 +8,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
8
8
  import { mapMediaCatalog, mediaCapability, mediaControls } from "./catalog.ts";
9
9
  import { type Config, isRecord, PROVIDER_ID } from "./config.ts";
10
10
  import { type DefaultsContext, effectiveMediaDefaults, resolveMediaModel } from "./media-defaults.ts";
11
- import { fetchCatalog, validateKey } from "./provider.ts";
11
+ import { deadline, fetchCatalog, validateKey } from "./provider.ts";
12
12
 
13
13
  type MediaContext = Pick<ExtensionContext, "cwd" | "model"> &
14
14
  DefaultsContext & {
@@ -102,10 +102,6 @@ function mediaOutputOptions(
102
102
  return options;
103
103
  }
104
104
 
105
- function deadline(signal: AbortSignal | undefined, milliseconds: number) {
106
- return AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(milliseconds)]);
107
- }
108
-
109
105
  export async function mediaKey(ctx: Pick<MediaContext, "modelRegistry">, signal: AbortSignal) {
110
106
  signal.throwIfAborted();
111
107
  const cancelled = Promise.withResolvers<never>();
@@ -133,12 +129,25 @@ function openaiImageResponse(config: Config, key: string, body: object, signal:
133
129
  return new Promise<{ ok: boolean; status: number; body: ReturnType<typeof Readable.toWeb> | null }>(
134
130
  (resolve, reject) => {
135
131
  const url = new URL(`${config.baseUrl}/v1/images/generations`);
136
- // A private agent and no socket timeout leave the overall signal in control of long image waits.
137
- const request = (url.protocol === "https:" ? httpsRequest : httpRequest)(
132
+ const transport = url.protocol === "https:" ? https : http;
133
+ const options = "options" in transport.globalAgent ? transport.globalAgent.options : undefined;
134
+ // Preserve configured proxies without inheriting the global agent's socket timeout.
135
+ const agent = new transport.Agent({
136
+ ...(isRecord(options) ? options : {}),
137
+ keepAlive: false,
138
+ timeout: 0,
139
+ });
140
+ const connect = agent.createConnection.bind(agent);
141
+ agent.createConnection = (connectionOptions, callback) => {
142
+ // Node assigns HTTPS proxy sockets to the request only after CONNECT completes.
143
+ const socket = connect(connectionOptions, callback);
144
+ return socket ? addAbortSignal(signal, socket) : socket;
145
+ };
146
+ const request = transport.request(
138
147
  url,
139
148
  {
140
149
  method: "POST",
141
- agent: false,
150
+ agent,
142
151
  timeout: 0,
143
152
  signal,
144
153
  headers: {
package/src/picker.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  type MediaDefaults,
17
17
  readMediaDefaults,
18
18
  } from "./media-defaults.ts";
19
- import { fetchCatalog } from "./provider.ts";
19
+ import { deadline, fetchCatalog } from "./provider.ts";
20
20
 
21
21
  export function pickerCatalog(value: unknown) {
22
22
  const rows = new Map<
@@ -58,6 +58,10 @@ export function searchPickerItems(items: readonly PickerItem[], query: string) {
58
58
  );
59
59
  }
60
60
 
61
+ function clearedPurpose(choice: string) {
62
+ return choice === "clear image" ? "image" : choice === "clear video" ? "video" : undefined;
63
+ }
64
+
61
65
  function defaultsLabel(defaults: MediaDefaults, automatic?: string) {
62
66
  const label = (id?: string | null) => (id === null ? "invalid / disabled" : id ? routedName(id) : "none");
63
67
  return `Image: ${label(defaults.image)}${automatic ? " (automatic default)" : ""} | Video: ${label(defaults.video)}`;
@@ -207,6 +211,8 @@ export class ModelPicker extends Container implements Focusable {
207
211
  }
208
212
 
209
213
  export function registerModelPicker(pi: ExtensionAPI, config: Config) {
214
+ let commandSequence = 0;
215
+ const savedSequence = { image: 0, video: 0 };
210
216
  pi.registerCommand("cli:model", {
211
217
  description: "Search CLIProxyAPI image/video models and select a session media default",
212
218
  getArgumentCompletions: (prefix) =>
@@ -214,6 +220,7 @@ export function registerModelPicker(pi: ExtensionAPI, config: Config) {
214
220
  .filter((value) => value.startsWith(prefix))
215
221
  .map((value) => ({ value, label: value })),
216
222
  async handler(args, ctx) {
223
+ const sequence = ++commandSequence;
217
224
  const report = (content: string, error = false) => {
218
225
  if (ctx.hasUI) ctx.ui.notify(content, error ? "error" : "info");
219
226
  else if (ctx.mode === "print") console.error(content);
@@ -225,39 +232,41 @@ export function registerModelPicker(pi: ExtensionAPI, config: Config) {
225
232
  };
226
233
  try {
227
234
  const input = args.trim();
228
- const clear = input === "clear image" ? "image" : input === "clear video" ? "video" : undefined;
229
235
  const save = (choice: string) => {
230
- const defaults = readMediaDefaults(config, ctx);
231
- if (choice === "clear image") delete defaults.image;
232
- else if (choice === "clear video") delete defaults.video;
233
- else {
234
- const capability = mediaCapability(choice);
235
- if (!capability || capability.disabledReason)
236
- throw new Error("Unsupported CLIProxyAPI media selection.");
237
- defaults[capability.purpose] = choice;
236
+ const clearing = clearedPurpose(choice);
237
+ const capability = clearing ? undefined : mediaCapability(choice);
238
+ const purpose = clearing ?? capability?.purpose;
239
+ if (!purpose || capability?.disabledReason)
240
+ throw new Error("Unsupported CLIProxyAPI media selection.");
241
+ ctx.signal?.throwIfAborted();
242
+ if (sequence < savedSequence[purpose]) {
243
+ report(`CLIProxyAPI ${purpose} choice superseded by a newer command; no change saved.`);
244
+ return;
238
245
  }
246
+ const defaults = readMediaDefaults(config, ctx);
247
+ if (clearing) delete defaults[purpose];
248
+ else defaults[purpose] = choice;
239
249
  pi.appendEntry(MEDIA_DEFAULTS_ENTRY, { version: 1, endpoint: config.baseUrl, defaults });
250
+ savedSequence[purpose] = sequence;
240
251
  const effective = effectiveMediaDefaults(config, ctx);
241
252
  report(defaultsLabel(effective.defaults, effective.automatic));
242
253
  };
243
- if (clear) {
244
- save(`clear ${clear}`);
254
+ if (clearedPurpose(input)) {
255
+ save(input);
245
256
  return;
246
257
  }
247
258
  if (input.startsWith("clear")) throw new Error("Usage: /cli:model clear image|video");
248
- const disabledReason = input.startsWith("select ")
249
- ? mediaCapability(input.slice(7).trim())?.disabledReason
250
- : undefined;
259
+ const explicit = input.startsWith("select ") ? input.slice(7).trim() : undefined;
260
+ const disabledReason = explicit === undefined ? undefined : mediaCapability(explicit)?.disabledReason;
251
261
  if (disabledReason) {
252
262
  report(disabledReason, true);
253
263
  return;
254
264
  }
255
- const signal = AbortSignal.any([...(ctx.signal ? [ctx.signal] : []), AbortSignal.timeout(15000)]);
265
+ const signal = deadline(ctx.signal, 15000);
256
266
  const key = await mediaKey(ctx, signal);
257
267
  const rows = pickerCatalog(await fetchCatalog(config, key, signal));
258
268
  const { defaults, automatic } = effectiveMediaDefaults(config, ctx);
259
269
  const items = pickerItems(rows, defaults);
260
- const explicit = input.startsWith("select ") ? input.slice(7).trim() : undefined;
261
270
  const query = input === "list" ? "" : input.startsWith("search ") ? input.slice(7) : input;
262
271
  let choice = explicit;
263
272
  if (choice === undefined) {
@@ -291,14 +300,9 @@ export function registerModelPicker(pi: ExtensionAPI, config: Config) {
291
300
  }
292
301
  }
293
302
  if (choice === undefined) return;
294
- if (choice === "clear image" || choice === "clear video") {
295
- save(choice);
296
- return;
297
- }
298
- const row = rows.find((row) => row.id === choice);
299
- if (!row?.supported)
303
+ if (!clearedPurpose(choice) && !rows.find((row) => row.id === choice)?.supported)
300
304
  throw new Error("Model unavailable or unsupported. Use /cli:model to list supported IDs.");
301
- save(row.id);
305
+ save(choice);
302
306
  } catch {
303
307
  report(
304
308
  "CLIProxyAPI model selection failed: unavailable/unsupported model, invalid command, authentication, or connection error. Check /login cliproxyapi. Use /cli:model list, select <exact ID>, or clear image|video.",
package/src/provider.ts CHANGED
@@ -36,9 +36,13 @@ export function validateKey(key: string) {
36
36
  return key;
37
37
  }
38
38
 
39
+ export function deadline(signal: AbortSignal | undefined, milliseconds: number) {
40
+ return AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(milliseconds)]);
41
+ }
42
+
39
43
  export async function fetchCatalog(config: Config, key: string, signal: AbortSignal) {
40
44
  validateKey(key);
41
- const boundedSignal = AbortSignal.any([signal, AbortSignal.timeout(10000)]);
45
+ const boundedSignal = deadline(signal, 10000);
42
46
  let response: Response;
43
47
  try {
44
48
  response = await fetch(`${config.baseUrl}/v1/models`, {
@@ -150,7 +154,7 @@ function route(streams: ProviderStreams, baseUrl: string): ProviderStreams {
150
154
  ? (event) => options.onProviderStreamEvent?.(event, model)
151
155
  : undefined,
152
156
  });
153
- if (!backend) return run();
157
+ if (!google) return run();
154
158
  return lazyStream(model, async () => ({
155
159
  async *[Symbol.asyncIterator]() {
156
160
  for await (const event of run()) {