Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

For releases before 0.3.6, see the [GitHub releases](https://github.com/hawkff/pi-cliproxyapi-native/releases).

## [0.3.8] - 2026-11-10

- Model discovery skips an ID it cannot use and keeps the rest of the catalog. CLIProxyAPI accepts any text as a model alias, and one ID with a space, a non-ASCII character, or more than 256 characters made discovery fail. A catalog with no usable ID still fails, so it cannot replace a working model list.
- `cliproxyapi_generate_image` creates its output directory before the request. If `.pi/` cannot be created, the call fails before anything is generated. Before, the extension generated the image and then discarded it.
- Image edits reject `data:` URLs and paths over 4096 characters as image data. Before, the file error repeated the whole value.
- Live Claude quota lookups run less often while the reset is far away. The interval grows with the remaining wait, up to ten minutes. Within 20 minutes of the reset they run every minute, as before.
- `/cli:model` reports the reason for a failure, such as the HTTP status or a rejected login. Before, most failures showed one generic message.

## [0.3.7] - 2026-11-10

- Model discovery accepts catalogs where a provider's name is empty or contains spaces. CLIProxyAPI reports the configured name of an OpenAI-compatible provider as `owned_by`, and one such entry made login, `/cliproxyapi-refresh`, `/cli:model`, and the media tools fail.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pi-cliproxyapi-native",
"version": "0.3.7",
"version": "0.3.8",
"description": "CLIProxyAPI chat discovery, image generation and editing, and video generation with Pi's native adapters",
"publishConfig": {
"access": "public"
Expand Down
31 changes: 21 additions & 10 deletions src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,26 @@ export function parseCatalog(value: unknown) {
if (!isRecord(value) || !Array.isArray(value.data) || value.data.length > 10000) {
throw new Error("Invalid CLIProxyAPI model catalog: expected a data array.");
}
return value.data.map((entry: unknown) => {
const entries = value.data.flatMap((entry: unknown) => {
if (
!isRecord(entry) ||
!isModelId(entry.id) ||
typeof entry.id !== "string" ||
(entry.owned_by !== undefined && typeof entry.owned_by !== "string")
) {
throw new Error("Invalid CLIProxyAPI model catalog entry.");
}
// The proxy accepts any text as a model alias. An ID with spaces or other characters that requests and
// the terminal cannot carry safely is skipped, so the rest of the catalog stays available.
if (!isModelId(entry.id)) return [];
// The proxy reports a custom provider's configured name as the owner; it can be empty or contain spaces.
const owner = isModelId(entry.owned_by) ? entry.owned_by : undefined;
return { id: entry.id, owner, hidden: entry.visibility === "hide" };
return [{ id: entry.id, owner, hidden: entry.visibility === "hide" }];
});
// A response with entries but no usable ID is not an empty catalog, and must not replace a working list.
if (value.data.length && !entries.length) {
throw new Error("Invalid CLIProxyAPI model catalog: no usable model ID.");
}
return entries;
}

export async function fetchCatalog(config: Config, key: string, signal: AbortSignal) {
Expand Down Expand Up @@ -79,6 +87,13 @@ export function modelRoute(id: string) {
};
}

// An alias for the full ID wins. A recognized route prefix can reuse its unprefixed ID's alias.
export function configuredAlias(config: Config, id: string) {
const { metadataId, prefix } = modelRoute(id);
if (Object.hasOwn(config.aliases, id)) return config.aliases[id];
return prefix && Object.hasOwn(config.aliases, metadataId) ? config.aliases[metadataId] : undefined;
}

export function backendLabel(id: string) {
const { backend, authType } = modelRoute(id);
if (authType) return authType === "api-key" ? "API key" : "OAuth";
Expand Down Expand Up @@ -308,13 +323,9 @@ export function mapCatalog(value: unknown, config: Config, known: readonly Model
if (entry.hidden || models.has(entry.id) || mediaPurpose(entry.id)) continue;
const { metadataId, prefix } = modelRoute(entry.id);
const routed = !entry.id.includes("/") || prefix;
const alias = Object.hasOwn(config.aliases, entry.id)
? config.aliases[entry.id]
: prefix && Object.hasOwn(config.aliases, metadataId)
? config.aliases[metadataId]
: routed && !byId.has(metadataId)
? metadataFallbacks.get(metadataId)
: undefined;
const alias =
configuredAlias(config, entry.id) ??
(routed && !byId.has(metadataId) ? metadataFallbacks.get(metadataId) : undefined);
const candidates = (alias ? byReference.get(alias) : routed ? byId.get(metadataId) : undefined) ?? [];
const families = new Set(
candidates.map((model) => (model.provider === "openai-codex" ? "openai" : model.provider)),
Expand Down
10 changes: 5 additions & 5 deletions src/claude-quota.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { setTimeout as sleep } from "node:timers/promises";
import { modelRoute } from "./catalog.ts";
import { configuredAlias, modelRoute } from "./catalog.ts";
import { type Config, deadline, isRecord, readJson } from "./config.ts";

export type QuotaWaitHandler = (id: symbol, remainingSeconds: number | undefined) => void;
Expand Down Expand Up @@ -139,9 +139,7 @@ export async function fetchClaudeQuota(
// ponytail: probe at most 100 accounts; batch larger pools before raising this limit.
if (accounts.length > 100)
throw new Error("Live quota lookup supports at most 100 active Claude accounts.");
const { metadataId, prefix } = modelRoute(modelId);
const reference =
config.aliases[modelId] ?? (prefix ? config.aliases[metadataId] : undefined) ?? metadataId;
const reference = configuredAlias(config, modelId) ?? modelRoute(modelId).metadataId;
const results = await Promise.allSettled(
accounts.map(async (account: unknown) => {
if (
Expand Down Expand Up @@ -252,7 +250,9 @@ export async function waitForClaudeQuota(
);
if (quota.state === "ready") return retryAt !== undefined;
retryAt = quota.retryAt;
const refreshAt = Math.min(quota.retryAt, Date.now() + 60000);
// Every waiting session repeats this lookup, so it slows to every ten minutes far from the reset.
const wait = quota.retryAt - Date.now();
const refreshAt = Date.now() + Math.min(wait, Math.max(60000, Math.min(Math.floor(wait / 20), 600000)));
while (Date.now() < refreshAt) {
signal?.throwIfAborted();
onWait?.(id, Math.max(0, Math.ceil((quota.retryAt - Date.now()) / 1000)));
Expand Down
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export const PROVIDER_ID = "cliproxyapi";
export const DEFAULT_BASE_URL = "http://localhost:8317";
const DEFAULT_BASE_URL = "http://localhost:8317";

export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
Expand Down
13 changes: 3 additions & 10 deletions src/media-defaults.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { type MediaPurpose, mediaCapability, mediaPurpose, modelRoute } from "./catalog.ts";
import { configuredAlias, type MediaPurpose, mediaCapability, mediaPurpose, modelRoute } from "./catalog.ts";
import { type Config, isModelId, isRecord, PROVIDER_ID } from "./config.ts";

export const MEDIA_DEFAULTS_ENTRY = "cliproxyapi-media-defaults";
Expand Down Expand Up @@ -34,15 +34,8 @@ export function readMediaDefaults(config: Config, ctx: DefaultsContext) {
export function automaticImageDefault(config: Config, ctx: DefaultsContext) {
const model = ctx.model;
if (!model || mediaPurpose(model.id)) return undefined;
const { metadataId, prefix } = modelRoute(model.id);
const alias =
model.provider === PROVIDER_ID
? Object.hasOwn(config.aliases, model.id)
? config.aliases[model.id]
: prefix && Object.hasOwn(config.aliases, metadataId)
? config.aliases[metadataId]
: undefined
: undefined;
const { metadataId } = modelRoute(model.id);
const alias = model.provider === PROVIDER_ID ? configuredAlias(config, model.id) : undefined;
const openai = alias
? /^(openai|openai-codex)\//.test(alias)
: model.provider === "openai" ||
Expand Down
119 changes: 69 additions & 50 deletions src/media.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { constants } from "node:fs";
import { mkdir, mkdtemp, open, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, open, rm, rmdir, writeFile } from "node:fs/promises";
import * as http from "node:http";
import * as https from "node:https";
import { join, resolve } from "node:path";
Expand Down Expand Up @@ -315,15 +315,19 @@ async function imageInputs(
let total = 0;
for (const [index, path] of [...params.images, ...(layer === undefined ? [] : [layer])].entries()) {
signal.throwIfAborted();
const location = typeof path === "string" ? resolve(cwd, path) : "";
if (
typeof path !== "string" ||
!location ||
!path.trim() ||
path.includes("\0") ||
/^[a-z][a-z\d+.-]*:\/\//i.test(path)
// No file system takes a longer path, so a longer value is image data.
location.length > 4096 ||
// A data URL has a comma after its media type; a file name that starts with "data:" need not.
/^(?:data:[^,]*,|[a-z][a-z\d+.-]*:\/\/)/i.test(path)
)
throw new Error("CLIProxyAPI editing accepts local image paths, not URLs.");
throw new Error("CLIProxyAPI editing accepts local image paths, not URLs or image data.");
// Nonblocking open prevents FIFOs from hanging before the regular-file check.
const file = await open(resolve(cwd, path), constants.O_RDONLY | constants.O_NONBLOCK);
const file = await open(location, constants.O_RDONLY | constants.O_NONBLOCK);
let bytes: Buffer;
try {
const info = await file.stat();
Expand Down Expand Up @@ -529,7 +533,7 @@ function errorText(value: unknown, key: string) {
}

// Server-sent events: partial previews go to the callback, other events are returned in order.
async function readEvents(
export async function readEvents(
reader: StreamReader,
maxBytes: number,
onPartial: (event: Record<string, unknown>) => void,
Expand Down Expand Up @@ -1083,56 +1087,71 @@ export async function generateImage(
const inputs = await imageInputs(model, params, ctx.cwd, bounded);
const key = await generationKey(config, { ...params, model }, ctx, bounded);
bounded.throwIfAborted();
onProgress?.(inputs.images ? "Editing image..." : "Generating image...");
bounded.throwIfAborted();
const { images, usage } = await requestImages(
config,
key,
model,
{ prompt: params.prompt, output, ...inputs },
bounded,
onPartial,
);
bounded.throwIfAborted();
onProgress?.("Saving image...");
bounded.throwIfAborted();
// A generated image cannot be regenerated for free, so its directory exists before the request.
const artifacts = join(ctx.cwd, ".pi");
await mkdir(artifacts, { recursive: true, mode: 0o700 });
const directory = await mkdtemp(join(artifacts, "cliproxyapi-image-"));
const files = images.map(({ extension, image }, index) => ({
path: join(directory, `${index === 0 ? "image" : `image-${index + 1}`}.${extension}`),
mimeType: image.mimeType,
}));
let created: string | undefined;
let directory = "";
try {
for (const [index, { bytes }] of images.entries()) {
await writeFile(files[index].path, bytes, { flag: "wx", mode: 0o600, signal: bounded });
try {
created = await mkdir(artifacts, { recursive: true, mode: 0o700 });
directory = await mkdtemp(join(artifacts, "cliproxyapi-image-"));
} catch {
throw new Error(
"CLIProxyAPI cannot create .pi/ in the working directory to save images. Nothing was generated.",
);
}
onProgress?.(inputs.images ? "Editing image..." : "Generating image...");
bounded.throwIfAborted();
} catch {
await rm(directory, { recursive: true, force: true });
throw new Error("CLIProxyAPI generated images but could not save the artifacts.");
}
let previewBudget = MAX_INLINE_IMAGE_BASE64_BYTES;
const content: (TextContent | ImageContent)[] = [];
for (const [index, { image }] of images.entries()) {
// Chat providers disagree on animated GIF input and reject SVG, so both skip inline previews.
const previewOmitted = ["image/gif", "image/svg+xml"].includes(image.mimeType)
? "Preview omitted: GIF and SVG results are saved without an inline preview."
: !ctx.model?.input.includes("image")
? "Preview omitted: the current chat model does not support images."
: image.data.length > previewBudget
? "Preview omitted: image base64 exceeds the 4 MiB inline budget. Use read on the saved file."
: undefined;
content.push({
type: "text",
text: `Saved image: ${files[index].path}${previewOmitted ? `\n${previewOmitted}` : ""}`,
});
if (!previewOmitted) {
content.push(image);
previewBudget -= image.data.length;
const { images, usage } = await requestImages(
config,
key,
model,
{ prompt: params.prompt, output, ...inputs },
bounded,
onPartial,
);
bounded.throwIfAborted();
onProgress?.("Saving image...");
bounded.throwIfAborted();
const files = images.map(({ extension, image }, index) => ({
path: join(directory, `${index === 0 ? "image" : `image-${index + 1}`}.${extension}`),
mimeType: image.mimeType,
}));
try {
for (const [index, { bytes }] of images.entries()) {
await writeFile(files[index].path, bytes, { flag: "wx", mode: 0o600, signal: bounded });
}
bounded.throwIfAborted();
} catch {
throw new Error("CLIProxyAPI generated images but could not save the artifacts.");
}
let previewBudget = MAX_INLINE_IMAGE_BASE64_BYTES;
const content: (TextContent | ImageContent)[] = [];
for (const [index, { image }] of images.entries()) {
// Chat providers disagree on animated GIF input and reject SVG, so both skip inline previews.
const previewOmitted = ["image/gif", "image/svg+xml"].includes(image.mimeType)
? "Preview omitted: GIF and SVG results are saved without an inline preview."
: !ctx.model?.input.includes("image")
? "Preview omitted: the current chat model does not support images."
: image.data.length > previewBudget
? "Preview omitted: image base64 exceeds the 4 MiB inline budget. Use read on the saved file."
: undefined;
content.push({
type: "text",
text: `Saved image: ${files[index].path}${previewOmitted ? `\n${previewOmitted}` : ""}`,
});
if (!previewOmitted) {
content.push(image);
previewBudget -= image.data.length;
}
}
return { content, details: { model, files }, usage };
} catch (error) {
// A failed call leaves no files, and no .pi/ that it created.
if (directory) await rm(directory, { recursive: true, force: true }).catch(() => {});
if (created) await rmdir(artifacts).catch(() => {});
throw error;
}
return { content, details: { model, files }, usage };
}

function requestId(value: unknown) {
Expand Down
7 changes: 5 additions & 2 deletions src/picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,9 +316,12 @@ export function registerModelPicker(pi: ExtensionAPI, config: Config) {
if (!clearedPurpose(choice) && !rows.find((row) => row.id === choice)?.supported)
throw new Error("Model unavailable or unsupported. Use /cli:model to list supported IDs.");
save(choice);
} catch {
} catch (error) {
// Each failure explains itself. A cancelled or timed-out request carries only its signal's reason.
report(
"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.",
error instanceof Error && error.name !== "AbortError" && error.name !== "TimeoutError"
? error.message
: "CLIProxyAPI model selection was cancelled or timed out.",
true,
);
}
Expand Down
Loading
Loading