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/src/picker.ts CHANGED
@@ -7,8 +7,8 @@ import {
7
7
  type TuiMouseEvent,
8
8
  truncateToWidth,
9
9
  } from "@earendil-works/pi-tui";
10
- import { backendLabel, mediaCapability, parseCatalog, routedName } from "./catalog.ts";
11
- import type { Config } from "./config.ts";
10
+ import { backendLabel, fetchCatalog, mediaCapability, parseCatalog, routedName } from "./catalog.ts";
11
+ import { type Config, deadline } from "./config.ts";
12
12
  import { mediaKey } from "./media.ts";
13
13
  import {
14
14
  effectiveMediaDefaults,
@@ -16,7 +16,6 @@ import {
16
16
  type MediaDefaults,
17
17
  readMediaDefaults,
18
18
  } from "./media-defaults.ts";
19
- import { deadline, fetchCatalog } from "./provider.ts";
20
19
 
21
20
  export function pickerCatalog(value: unknown) {
22
21
  const rows = new Map<
@@ -212,6 +211,15 @@ export class ModelPicker extends Container implements Focusable {
212
211
 
213
212
  export function registerModelPicker(pi: ExtensionAPI, config: Config) {
214
213
  let commandSequence = 0;
214
+ let navigation = 0;
215
+ const invalidate = () => {
216
+ navigation++;
217
+ };
218
+ pi.on("session_before_switch", invalidate);
219
+ pi.on("session_before_fork", invalidate);
220
+ pi.on("session_before_tree", invalidate);
221
+ pi.on("session_start", invalidate);
222
+ pi.on("session_shutdown", invalidate);
215
223
  const savedSequence = { image: 0, video: 0 };
216
224
  pi.registerCommand("cli:model", {
217
225
  description: "Search CLIProxyAPI image/video models and select a session media default",
@@ -221,7 +229,9 @@ export function registerModelPicker(pi: ExtensionAPI, config: Config) {
221
229
  .map((value) => ({ value, label: value })),
222
230
  async handler(args, ctx) {
223
231
  const sequence = ++commandSequence;
232
+ const generation = navigation;
224
233
  const report = (content: string, error = false) => {
234
+ if (generation !== navigation) return;
225
235
  if (ctx.hasUI) ctx.ui.notify(content, error ? "error" : "info");
226
236
  else if (ctx.mode === "print") console.error(content);
227
237
  else
@@ -233,6 +243,7 @@ export function registerModelPicker(pi: ExtensionAPI, config: Config) {
233
243
  try {
234
244
  const input = args.trim();
235
245
  const save = (choice: string) => {
246
+ if (generation !== navigation) return;
236
247
  const clearing = clearedPurpose(choice);
237
248
  const capability = clearing ? undefined : mediaCapability(choice);
238
249
  const purpose = clearing ?? capability?.purpose;
@@ -265,6 +276,7 @@ export function registerModelPicker(pi: ExtensionAPI, config: Config) {
265
276
  const signal = deadline(ctx.signal, 15000);
266
277
  const key = await mediaKey(ctx, signal);
267
278
  const rows = pickerCatalog(await fetchCatalog(config, key, signal));
279
+ if (generation !== navigation) return;
268
280
  const { defaults, automatic } = effectiveMediaDefaults(config, ctx);
269
281
  const items = pickerItems(rows, defaults);
270
282
  const query = input === "list" ? "" : input.startsWith("search ") ? input.slice(7) : input;
package/src/provider.ts CHANGED
@@ -18,10 +18,19 @@ import {
18
18
  openAIResponsesApi,
19
19
  } from "@earendil-works/pi-ai/compat";
20
20
  import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
21
- import { type CpaApi, endpoint, mapCatalog, modelRoute } from "./catalog.ts";
21
+ import {
22
+ type CpaApi,
23
+ endpoint,
24
+ fetchCatalog,
25
+ IMAGE_API,
26
+ mapCatalog,
27
+ mapImageModels,
28
+ modelRoute,
29
+ } from "./catalog.ts";
22
30
  import { type QuotaWaitHandler, waitForClaudeQuota } from "./claude-quota.ts";
23
31
  import { withCompactionRecovery } from "./compaction.ts";
24
- import { type Config, isRecord, PROVIDER_ID } from "./config.ts";
32
+ import { type Config, isRecord, PROVIDER_ID, validateKey } from "./config.ts";
33
+ import { generateNativeImages } from "./media.ts";
25
34
 
26
35
  export type { QuotaWaitHandler } from "./claude-quota.ts";
27
36
 
@@ -31,63 +40,21 @@ export function builtinCatalog() {
31
40
  ...getBuiltinModels("openai"),
32
41
  ...getBuiltinModels("openai-codex"),
33
42
  ...getBuiltinModels("google"),
43
+ ...getBuiltinModels("xai"),
34
44
  ];
35
45
  }
36
46
 
37
- export function validateKey(key: string) {
38
- if (!/^[!-~]{1,4096}$/.test(key)) {
39
- throw new Error("CLIProxyAPI requires a non-empty API key without whitespace or control characters.");
40
- }
41
- return key;
42
- }
43
-
44
- export function deadline(signal: AbortSignal | undefined, milliseconds: number) {
45
- return AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(milliseconds)]);
46
- }
47
-
48
- export async function fetchCatalog(config: Config, key: string, signal: AbortSignal) {
49
- validateKey(key);
50
- const boundedSignal = deadline(signal, 10000);
51
- let response: Response;
52
- try {
53
- response = await fetch(`${config.baseUrl}/v1/models`, {
54
- headers: { Authorization: `Bearer ${key}` },
55
- redirect: "error",
56
- signal: boundedSignal,
57
- });
58
- } catch {
59
- signal.throwIfAborted();
60
- throw new Error("CLIProxyAPI discovery failed: connection, timeout, or redirect error.");
61
- }
62
- if (!response.ok) {
63
- await response.body?.cancel();
64
- throw new Error(`CLIProxyAPI discovery failed (HTTP ${response.status}).`);
65
- }
66
- let payload: unknown;
67
- const reader = response.body?.getReader();
68
- if (!reader) throw new Error("CLIProxyAPI discovery returned an empty response.");
69
- try {
70
- const decoder = new TextDecoder();
71
- let text = "";
72
- let size = 0;
73
- while (true) {
74
- const chunk = await reader.read();
75
- if (chunk.done) break;
76
- size += chunk.value.byteLength;
77
- if (size > 4 * 1024 * 1024) {
78
- await reader.cancel();
79
- throw new Error("Catalog too large.");
80
- }
81
- text += decoder.decode(chunk.value, { stream: true });
82
- }
83
- payload = JSON.parse(text + decoder.decode());
84
- } catch {
85
- signal.throwIfAborted();
86
- throw new Error("CLIProxyAPI catalog is invalid, incomplete, or larger than 4 MiB.");
87
- } finally {
88
- reader.releaseLock();
89
- }
90
- return payload;
47
+ // Pi's live catalogs add models released after this Pi build; bundled metadata wins for known IDs.
48
+ export function metadataCatalog(live?: (provider: string) => readonly Model<Api>[] | undefined) {
49
+ const bundled = builtinCatalog();
50
+ if (!live) return bundled;
51
+ const ids = new Set(bundled.map(({ provider, id }) => `${provider}/${id}`));
52
+ return [
53
+ ...bundled,
54
+ ...["anthropic", "openai", "openai-codex", "google", "xai"]
55
+ .flatMap((provider) => live(provider) ?? [])
56
+ .filter(({ provider, id }) => !ids.has(`${provider}/${id}`)),
57
+ ];
91
58
  }
92
59
 
93
60
  export async function discover(
@@ -345,10 +312,12 @@ function route(
345
312
 
346
313
  export function createCliproxyProvider(
347
314
  config: Config,
348
- known: readonly Model<Api>[] = builtinCatalog(),
315
+ known: readonly Model<Api>[] | (() => readonly Model<Api>[]) = builtinCatalog,
349
316
  onQuotaWait?: QuotaWaitHandler,
350
317
  compactionSignals = new WeakSet<AbortSignal>(),
351
318
  ) {
319
+ const metadata = typeof known === "function" ? known : () => known;
320
+ let skipped: string[] = [];
352
321
  const standardAuth = envApiKeyAuth("CLIProxyAPI API key", ["CLIPROXYAPI_API_KEY"]);
353
322
  const scope = createHash("sha256")
354
323
  .update(JSON.stringify([5, { baseUrl: config.baseUrl, aliases: config.aliases }]))
@@ -364,7 +333,7 @@ export function createCliproxyProvider(
364
333
  const credential = await standardAuth.login?.(interaction);
365
334
  if (!credential?.key) throw new Error("CLIProxyAPI requires an API key.");
366
335
  validateKey(credential.key);
367
- await discover(config, credential.key, interaction.signal, known);
336
+ await discover(config, credential.key, interaction.signal, metadata());
368
337
  return credential;
369
338
  },
370
339
  async resolve(input) {
@@ -380,7 +349,15 @@ export function createCliproxyProvider(
380
349
  if (context.credential?.type !== "api_key" || !context.credential.key) {
381
350
  throw new Error("Use /login cliproxyapi or set CLIPROXYAPI_API_KEY before refreshing.");
382
351
  }
383
- return (await discover(config, context.credential.key, context.signal, known)).models;
352
+ const payload = await fetchCatalog(config, context.credential.key, context.signal);
353
+ const chat = mapCatalog(payload, config, metadata());
354
+ skipped = chat.skipped;
355
+ return [...chat.models, ...mapImageModels(payload, config)];
356
+ },
357
+ images: {
358
+ [IMAGE_API]: {
359
+ generateImages: (model, context, options) => generateNativeImages(config, model, context, options),
360
+ },
384
361
  },
385
362
  api: {
386
363
  "anthropic-messages": route(anthropicMessagesApi(), config, compactionSignals, onQuotaWait),
@@ -392,6 +369,8 @@ export function createCliproxyProvider(
392
369
  const refresh = provider.refreshModels;
393
370
  return {
394
371
  ...provider,
372
+ // Chat IDs without Pi metadata from the latest network refresh, for diagnostics.
373
+ skippedModels: () => skipped,
395
374
  async refreshModels(context: RefreshModelsContext) {
396
375
  await refresh?.({
397
376
  ...context,