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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Chat uses Pi's native API adapters and model metadata, with availability from yo

## Install

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.
The extension targets Pi 1.1.0 and CLIProxyAPI 8.0.22, 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.

If you installed `pi-cliproxy-native`, remove it before installing the renamed package. Both register the same `cliproxyapi` provider.

Expand Down Expand Up @@ -201,6 +201,14 @@ Management requests stay on the configured proxy and reject redirects. Account a

Other chat routes retain the response-based fallback: `Retry-After` on HTTP 429 or a recognized quota HTTP 403, or `reset_seconds` in a `model_cooldown` error. Google's native adapter exposes only the structured error body. Those waits share the same seven-day deadline but cannot inspect live weekly usage. Image and video generation still do not retry.

### Compaction

For automatic compaction and `/compact`, the extension limits medium or higher thinking to low without changing your chat thinking level. It uses Pi's default thinking budgets for these requests. If a summary hits its output cap, it retries once with more output room, up to the model's output limit. Pi's native adapters also limit output to the remaining context space. An empty or thinking-only response gets one retry.

Pi still selects which messages to summarize and retains recent history, previous summaries, custom instructions, and file tracking. Only the final response reaches the compactor, with usage from both attempts. A second incomplete response fails compaction rather than saving a partial checkpoint. Cancellation, tool calls, and provider failures do not trigger these output retries; Pi keeps its configured transient-error retries and the extension keeps its quota handling.

Authentication failures, exhausted quotas, and inputs that exceed the model's context window still need correction. Run `/compact` after resolving the cause. The extension does not truncate history to force a summary or switch models. CLIProxyAPI's Responses compaction endpoint is separate from Pi's text-summary workflow.

### Aliases and model limits

The chat catalog skips unknown IDs rather than guessing their capabilities. Recognized catalog owners resolve duplicate metadata IDs; ambiguous cross-family matches stay out.
Expand Down
45 changes: 27 additions & 18 deletions extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export default async function (pi: ExtensionAPI) {
throw new Error("pi-cliproxyapi.json must contain valid JSON.");
}
const config = parseConfig(raw, process.env.CLIPROXYAPI_BASE_URL);
const compactionSignals = new WeakSet<AbortSignal>();
pi.on("session_before_compact", (event) => {
compactionSignals.add(event.signal);
});
let context: ExtensionContext | undefined;
const waits = new Map<symbol, number>();
pi.on("session_start", (_event, ctx) => {
Expand All @@ -33,25 +37,30 @@ export default async function (pi: ExtensionAPI) {
waits.clear();
});
pi.registerProvider(
createCliproxyProvider(config, undefined, (id, seconds) => {
if (!context) return;
if (seconds === undefined) waits.delete(id);
else {
if (!waits.has(id)) {
const message = `CLIProxyAPI quota limit. Retrying at ${new Date(Date.now() + seconds * 1000).toLocaleString()}. Cancel the current request to stop waiting.`;
if (context.mode === "print" || context.mode === "json") process.stderr.write(`${message}\n`);
else context.ui.notify(message, "warning");
createCliproxyProvider(
config,
undefined,
(id, seconds) => {
if (!context) return;
if (seconds === undefined) waits.delete(id);
else {
if (!waits.has(id)) {
const message = `CLIProxyAPI quota limit. Retrying at ${new Date(Date.now() + seconds * 1000).toLocaleString()}. Cancel the current request to stop waiting.`;
if (context.mode === "print" || context.mode === "json") process.stderr.write(`${message}\n`);
else context.ui.notify(message, "warning");
}
waits.set(id, seconds);
}
waits.set(id, seconds);
}
const remaining = Math.min(...waits.values());
context.ui.setStatus(
"cliproxyapi-quota",
waits.size
? `CLIProxyAPI quota: retry in ${Math.floor(remaining / 3600)}h ${Math.floor(remaining / 60) % 60}m ${remaining % 60}s`
: undefined,
);
}),
const remaining = Math.min(...waits.values());
context.ui.setStatus(
"cliproxyapi-quota",
waits.size
? `CLIProxyAPI quota: retry in ${Math.floor(remaining / 3600)}h ${Math.floor(remaining / 60) % 60}m ${remaining % 60}s`
: undefined,
);
},
compactionSignals,
),
);
registerMediaTools(pi, config);
registerModelPicker(pi, config);
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@
},
"devDependencies": {
"@biomejs/biome": "^2.5.13",
"@earendil-works/pi-ai": "^1.0.4",
"@earendil-works/pi-coding-agent": "^1.0.4",
"@earendil-works/pi-tui": "^1.0.4",
"@earendil-works/pi-ai": "^1.1.0",
"@earendil-works/pi-coding-agent": "^1.1.0",
"@earendil-works/pi-tui": "^1.1.0",
"@types/node": "^24.13.4",
"typescript": "5.9.3"
},
Expand Down
76 changes: 38 additions & 38 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

78 changes: 78 additions & 0 deletions src/compaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { contentText, lazyStream, type ProviderStreams } from "@earendil-works/pi-ai";

export function withCompactionRecovery(
stream: ProviderStreams["streamSimple"],
signals: WeakSet<AbortSignal>,
): ProviderStreams["streamSimple"] {
return (model, context, options) => {
if (!options?.signal || !signals.has(options.signal)) return stream(model, context, options);
const signal = options.signal;
return lazyStream(model, async () => ({
async *[Symbol.asyncIterator]() {
const limit = model.maxTokens > 0 ? model.maxTokens : 32768;
let maxTokens = Math.max(1, Math.min(options.maxTokens ?? 16384, limit));
const reasoning =
options.reasoning && !["minimal", "low"].includes(options.reasoning) ? "low" : options.reasoning;
const run = () =>
stream(model, context, {
...options,
maxTokens,
reasoning,
// Chat-specific thinking budgets must not consume the summary's output allowance.
thinkingBudgets: undefined,
}).result();
let response = await run();
const retryTokens = Math.min(Math.max(maxTokens * 2, 16384), limit);
const empty = response.stopReason === "stop" && !contentText(response.content).trim();
if (
!signal.aborted &&
!response.content.some((block) => block.type === "toolCall") &&
((response.stopReason === "length" && retryTokens > maxTokens) || empty)
) {
maxTokens = retryTokens;
const previous = response.usage;
response = await run();
response = { ...response, usage: structuredClone(response.usage) };
for (const key of [
"input",
"output",
"cacheRead",
"cacheWrite",
"cacheWrite1h",
"reasoning",
"totalTokens",
] as const) {
if (previous[key] !== undefined) response.usage[key] = (response.usage[key] ?? 0) + previous[key];
}
for (const key of ["input", "output", "cacheRead", "cacheWrite", "total"] as const)
response.usage.cost[key] += previous.cost[key];
}
if (signal.aborted) {
yield {
type: "error",
reason: "aborted",
error: { ...response, stopReason: "aborted", errorMessage: "Compaction was aborted." },
};
return;
}
if (
response.content.some((block) => block.type === "toolCall") ||
!["stop", "length", "error"].includes(response.stopReason) ||
(response.stopReason === "stop" && !contentText(response.content).trim())
) {
response = {
...response,
stopReason: "error",
errorMessage: "CLIProxyAPI compaction did not return a complete text summary.",
};
}
if (response.stopReason === "stop" || response.stopReason === "length") {
yield { type: "start", partial: { ...response, content: [], stopReason: "pending" } };
yield { type: "done", reason: response.stopReason, message: response };
} else {
yield { type: "error", reason: "error", error: response };
}
},
}));
};
}
22 changes: 16 additions & 6 deletions src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
import { type CpaApi, endpoint, mapCatalog, modelRoute } from "./catalog.ts";
import { type QuotaWaitHandler, waitForClaudeQuota } from "./claude-quota.ts";
import { withCompactionRecovery } from "./compaction.ts";
import { type Config, isRecord, PROVIDER_ID } from "./config.ts";

export type { QuotaWaitHandler } from "./claude-quota.ts";
Expand Down Expand Up @@ -176,7 +177,12 @@ async function waitForQuota(resetAt: number, signal?: AbortSignal, onWait?: Quot
}
}

function route(streams: ProviderStreams, config: Config, onWait?: QuotaWaitHandler): ProviderStreams {
function route(
streams: ProviderStreams,
config: Config,
compactionSignals: WeakSet<AbortSignal>,
onWait?: QuotaWaitHandler,
): ProviderStreams {
const wrap =
(stream: ProviderStreams["streamSimple"]): ProviderStreams["streamSimple"] =>
(selected, context, options) => {
Expand Down Expand Up @@ -330,13 +336,17 @@ function route(streams: ProviderStreams, config: Config, onWait?: QuotaWaitHandl
},
}));
};
return { stream: wrap(streams.stream), streamSimple: wrap(streams.streamSimple) };
return {
stream: wrap(streams.stream),
streamSimple: withCompactionRecovery(wrap(streams.streamSimple), compactionSignals),
};
}

export function createCliproxyProvider(
config: Config,
known: readonly Model<Api>[] = builtinCatalog(),
onQuotaWait?: QuotaWaitHandler,
compactionSignals = new WeakSet<AbortSignal>(),
) {
const standardAuth = envApiKeyAuth("CLIProxyAPI API key", ["CLIPROXYAPI_API_KEY"]);
const scope = createHash("sha256")
Expand Down Expand Up @@ -372,10 +382,10 @@ export function createCliproxyProvider(
return (await discover(config, context.credential.key, context.signal, known)).models;
},
api: {
"anthropic-messages": route(anthropicMessagesApi(), config, onQuotaWait),
"openai-responses": route(openAIResponsesApi(), config, onQuotaWait),
"openai-completions": route(openAICompletionsApi(), config, onQuotaWait),
"google-generative-ai": route(googleGenerativeAIApi(), config, onQuotaWait),
"anthropic-messages": route(anthropicMessagesApi(), config, compactionSignals, onQuotaWait),
"openai-responses": route(openAIResponsesApi(), config, compactionSignals, onQuotaWait),
"openai-completions": route(openAICompletionsApi(), config, compactionSignals, onQuotaWait),
"google-generative-ai": route(googleGenerativeAIApi(), config, compactionSignals, onQuotaWait),
},
});
const refresh = provider.refreshModels;
Expand Down
Loading
Loading