pi-cliproxyapi-native 0.1.5 → 0.1.7

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/LICENSE CHANGED
File without changes
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 1.0.0 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.5
14
+ pi install npm:pi-cliproxyapi-native@0.1.7
15
15
  ```
16
16
 
17
- For Git installation, use `pi install git:github.com/hawkff/pi-cliproxyapi-native@v0.1.5`. 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.7`. 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
 
@@ -44,6 +44,7 @@ Set `CLIPROXYAPI_BASE_URL` or create `~/.pi/agent/pi-cliproxyapi.json`:
44
44
  | --- | --- |
45
45
  | `CLIPROXYAPI_BASE_URL` | Overrides `baseUrl` in the configuration file. |
46
46
  | `CLIPROXYAPI_API_KEY` | Supplies a client key when Pi has no stored login credential. |
47
+ | `CLIPROXYAPI_MANAGEMENT_KEY` | Enables live Claude quota lookup through the same proxy's management API. Keep this separate from the client key. |
47
48
 
48
49
  Use an absolute URL. Root URLs and URLs ending in `/v1` or `/v1beta` work; the extension preserves path prefixes. Remote endpoints require HTTPS. HTTP works on loopback addresses. URLs cannot contain credentials, query strings, or fragments.
49
50
 
@@ -176,6 +177,30 @@ The extension discovers available IDs through `/v1/models` and matches them agai
176
177
 
177
178
  Run `/cliproxyapi-refresh` to update the chat catalog.
178
179
 
180
+ ### Wait for quota reset
181
+
182
+ Set `CLIPROXYAPI_MANAGEMENT_KEY` in Pi's environment to enable live Claude quota lookup. With live lookup enabled, Claude subscription requests enter quota handling on the first quota rejection, before SDK or Pi retries. The extension asks CLIProxyAPI to fetch Claude's OAuth usage, using the same upstream endpoint as the management dashboard. It reads absolute `resets_at` timestamps.
183
+
184
+ The extension refreshes usage every minute while waiting and at the reported reset, with a one-second buffer. It checks the five-hour allowance and applicable weekly limits, including model-specific limits. A request that would exceed the remaining account allowance can wait for the five-hour reset before utilization reaches 100%. For an account pool, it considers enabled Claude subscription accounts that advertise the selected model and uses the earliest eligible recovery. The terminal countdown follows updated reset times. Cancel the current request to stop waiting; closing or reloading Pi discards the wait.
185
+
186
+ Weekly exhaustion stops the request by default and reports the reset time. To wait for weekly resets too, merge this setting into `~/.pi/agent/pi-cliproxyapi.json`, then run `/reload`:
187
+
188
+ ```json
189
+ {
190
+ "quota": {
191
+ "waitForWeeklyReset": true
192
+ }
193
+ }
194
+ ```
195
+
196
+ Without a management key, Claude retains its SDK retry settings and response-based waits. If live usage shows available quota before a wait begins, the extension uses valid response reset timing or returns the original error for Pi's bounded retries.
197
+
198
+ Invalid management credentials, rejected account access, invalid usage data, or an exhausted allowance with an expired reset stop the quota wait with an explanation. These failures do not trigger Pi's quick retries. Extension-managed waits share one fixed seven-day deadline per provider call; a new cooldown cannot extend it. The extension checks the deadline after waking and before accepting available quota, including after machine sleep. It does not replay a request that has started streaming.
199
+
200
+ Management requests stay on the configured proxy and reject redirects. Account access tokens stay in CLIProxyAPI. The extension reads account metadata and proxies usage GETs; it does not reset quotas, change credentials, or clear proxy cooldowns. Management keys grant broader access than chat keys, so keep them private. Live lookup supports up to 100 active Claude subscription accounts.
201
+
202
+ 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.
203
+
179
204
  ### Aliases and model limits
180
205
 
181
206
  The chat catalog skips unknown IDs rather than guessing their capabilities. Recognized catalog owners resolve duplicate metadata IDs; ambiguous cross-family matches stay out.
@@ -194,6 +219,8 @@ Replace `<canonical-model-id>` with an ID from Pi's Anthropic catalog. Reference
194
219
 
195
220
  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
221
 
222
+ 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.
223
+
197
224
  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
225
 
199
226
  ## Backend routes
@@ -213,6 +240,8 @@ Set the CLIProxyAPI auth record's top-level `prefix` to `vertex` or `antigravity
213
240
 
214
241
  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
242
 
243
+ 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.
244
+
216
245
  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
246
 
218
247
  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:
@@ -1,6 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
3
+ import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
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";
@@ -22,7 +22,37 @@ export default async function (pi: ExtensionAPI) {
22
22
  throw new Error("pi-cliproxyapi.json must contain valid JSON.");
23
23
  }
24
24
  const config = parseConfig(raw, process.env.CLIPROXYAPI_BASE_URL);
25
- pi.registerProvider(createCliproxyProvider(config));
25
+ let context: ExtensionContext | undefined;
26
+ const waits = new Map<symbol, number>();
27
+ pi.on("session_start", (_event, ctx) => {
28
+ context = ctx;
29
+ });
30
+ pi.on("session_shutdown", () => {
31
+ context?.ui.setStatus("cliproxyapi-quota", undefined);
32
+ context = undefined;
33
+ waits.clear();
34
+ });
35
+ pi.registerProvider(
36
+ createCliproxyProvider(config, undefined, (id, seconds) => {
37
+ if (!context) return;
38
+ if (seconds === undefined) waits.delete(id);
39
+ else {
40
+ if (!waits.has(id)) {
41
+ const message = `CLIProxyAPI quota limit. Retrying at ${new Date(Date.now() + seconds * 1000).toLocaleString()}. Cancel the current request to stop waiting.`;
42
+ if (context.mode === "print" || context.mode === "json") process.stderr.write(`${message}\n`);
43
+ else context.ui.notify(message, "warning");
44
+ }
45
+ waits.set(id, seconds);
46
+ }
47
+ const remaining = Math.min(...waits.values());
48
+ context.ui.setStatus(
49
+ "cliproxyapi-quota",
50
+ waits.size
51
+ ? `CLIProxyAPI quota: retry in ${Math.floor(remaining / 3600)}h ${Math.floor(remaining / 60) % 60}m ${remaining % 60}s`
52
+ : undefined,
53
+ );
54
+ }),
55
+ );
26
56
  registerMediaTools(pi, config);
27
57
  registerModelPicker(pi, config);
28
58
  pi.registerCommand("cliproxyapi-refresh", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cliproxyapi-native",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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": "^1.0.0",
43
- "@earendil-works/pi-coding-agent": "^1.0.0",
44
- "@earendil-works/pi-tui": "^1.0.0",
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
  );
@@ -0,0 +1,273 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
2
+ import { type Config, isRecord } from "./config.ts";
3
+
4
+ export type QuotaWaitHandler = (id: symbol, remainingSeconds: number | undefined) => void;
5
+
6
+ const WEEK_MS = 7 * 86400000;
7
+
8
+ async function managementRequest(
9
+ config: Config,
10
+ key: string,
11
+ path: string,
12
+ signal: AbortSignal,
13
+ body?: object,
14
+ ) {
15
+ let response: Response;
16
+ try {
17
+ response = await fetch(`${config.baseUrl}/v0/management/${path}`, {
18
+ method: body ? "POST" : "GET",
19
+ headers: { Authorization: `Bearer ${key}`, ...(body ? { "Content-Type": "application/json" } : {}) },
20
+ body: body ? JSON.stringify(body) : undefined,
21
+ redirect: "error",
22
+ signal,
23
+ });
24
+ } catch {
25
+ throw new Error("The live quota request could not complete.");
26
+ }
27
+ if (!response.ok) {
28
+ await response.body?.cancel().catch(() => {});
29
+ throw new Error(
30
+ response.status === 401 || response.status === 403
31
+ ? "Management API authentication or access was rejected. Check its key and access settings."
32
+ : "The management API is unavailable. Waiting stopped.",
33
+ );
34
+ }
35
+ const reader = response.body?.getReader();
36
+ if (!reader) throw new Error("The management API returned an empty response.");
37
+ try {
38
+ const decoder = new TextDecoder();
39
+ let text = "";
40
+ let size = 0;
41
+ for (;;) {
42
+ const chunk = await reader.read();
43
+ if (chunk.done) break;
44
+ size += chunk.value.byteLength;
45
+ if (size > 2 * 1024 * 1024) {
46
+ await reader.cancel();
47
+ throw new Error("Response too large.");
48
+ }
49
+ text += decoder.decode(chunk.value, { stream: true });
50
+ }
51
+ return JSON.parse(text + decoder.decode()) as unknown;
52
+ } catch {
53
+ throw new Error("The live quota response is invalid, incomplete, or larger than 2 MiB.");
54
+ } finally {
55
+ reader.releaseLock();
56
+ }
57
+ }
58
+
59
+ export function claudeQuotaState(
60
+ payload: unknown,
61
+ modelId: string,
62
+ now = Date.now(),
63
+ serverNow = now,
64
+ requestRejected = false,
65
+ ) {
66
+ if (!isRecord(payload) || !("five_hour" in payload) || !("seven_day" in payload)) {
67
+ throw new Error("Claude did not report both five-hour and seven-day quota windows.");
68
+ }
69
+ const family = ["opus", "sonnet", "fable"].find((name) => modelId.toLowerCase().includes(name));
70
+ const keys = ["five_hour", "seven_day", "seven_day_oauth_apps"];
71
+ if (family) keys.push(family === "fable" ? "iguana_necktie" : `seven_day_${family}`);
72
+ const windows = keys.map((key) => ({ value: payload[key], weekly: key !== "five_hour" }));
73
+ if (family === "fable" && Array.isArray(payload.limits)) {
74
+ const scoped = payload.limits.filter((limit: unknown) => {
75
+ if (!isRecord(limit) || limit.kind !== "weekly_scoped" || limit.is_active === false) return false;
76
+ const model = isRecord(limit.scope) && isRecord(limit.scope.model) ? limit.scope.model : undefined;
77
+ return (
78
+ typeof model?.display_name === "string" &&
79
+ ["fable", "fable 5"].includes(model.display_name.trim().toLowerCase())
80
+ );
81
+ });
82
+ const limit: unknown =
83
+ scoped.find((limit: unknown) => isRecord(limit) && limit.is_active === true) ?? scoped[0];
84
+ if (isRecord(limit)) {
85
+ windows.pop();
86
+ windows.push({ value: { utilization: limit.percent, resets_at: limit.resets_at }, weekly: true });
87
+ }
88
+ }
89
+ const blocked: { retryAt: number; resetAt: number; weekly: boolean }[] = [];
90
+ for (const { value, weekly } of windows) {
91
+ if (value === undefined || value === null) continue;
92
+ if (
93
+ !isRecord(value) ||
94
+ typeof value.utilization !== "number" ||
95
+ !Number.isFinite(value.utilization) ||
96
+ value.utilization < 0 ||
97
+ value.utilization > 100
98
+ ) {
99
+ throw new Error("Claude returned an invalid quota window.");
100
+ }
101
+ // A rejected request can need more tokens than the unused part of the five-hour allowance.
102
+ if (value.utilization < 100 && (weekly || !requestRejected || value.utilization === 0)) continue;
103
+ const resetAt =
104
+ typeof value.resets_at === "string" && /T.*(?:Z|[+-]\d{2}:\d{2})$/i.test(value.resets_at)
105
+ ? Date.parse(value.resets_at)
106
+ : NaN;
107
+ const delay = resetAt - serverNow;
108
+ if (!Number.isFinite(delay) || delay <= 0 || delay > WEEK_MS) {
109
+ throw new Error("Claude still reports exhausted quota without a valid future reset. Waiting stopped.");
110
+ }
111
+ blocked.push({ resetAt, retryAt: now + delay + 1000, weekly });
112
+ }
113
+ if (!blocked.length) return { state: "ready" as const };
114
+ const latest = blocked.reduce((a, b) => (a.retryAt > b.retryAt ? a : b));
115
+ const weekly = blocked.filter((window) => window.weekly);
116
+ return {
117
+ state: "blocked" as const,
118
+ retryAt: latest.retryAt,
119
+ resetAt: latest.resetAt,
120
+ weeklyResetAt: weekly.length ? Math.max(...weekly.map((window) => window.resetAt)) : undefined,
121
+ };
122
+ }
123
+
124
+ export async function fetchClaudeQuota(
125
+ config: Config,
126
+ key: string,
127
+ modelId: string,
128
+ signal?: AbortSignal,
129
+ requestRejected = false,
130
+ ) {
131
+ if (!/^[!-~]{1,4096}$/.test(key)) {
132
+ throw new Error(
133
+ "Set CLIPROXYAPI_MANAGEMENT_KEY to read live Claude quota. The chat API key is not a management key.",
134
+ );
135
+ }
136
+ const bounded = AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(15000)]);
137
+ const listing = await managementRequest(config, key, "auth-files", bounded);
138
+ if (!isRecord(listing) || !Array.isArray(listing.files) || listing.files.length > 10000) {
139
+ throw new Error("The management API returned an invalid account list.");
140
+ }
141
+ const accounts = listing.files.filter(
142
+ (file: unknown) =>
143
+ isRecord(file) &&
144
+ (file.provider ?? file.type) === "claude" &&
145
+ file.disabled !== true &&
146
+ file.status !== "disabled" &&
147
+ file.account_type !== "api_key",
148
+ );
149
+ if (!accounts.length) throw new Error("No supported active Claude subscription account was found.");
150
+ // ponytail: probe at most 100 accounts; batch larger pools before raising this limit.
151
+ if (accounts.length > 100)
152
+ throw new Error("Live quota lookup supports at most 100 active Claude accounts.");
153
+ const reference = config.aliases[modelId] ?? modelId;
154
+ const results = await Promise.allSettled(
155
+ accounts.map(async (account: unknown) => {
156
+ if (
157
+ !isRecord(account) ||
158
+ typeof account.auth_index !== "string" ||
159
+ !account.auth_index ||
160
+ typeof account.name !== "string" ||
161
+ !account.name
162
+ ) {
163
+ throw new Error("A Claude account is missing its quota lookup identifier.");
164
+ }
165
+ const models = await managementRequest(
166
+ config,
167
+ key,
168
+ `auth-files/models?name=${encodeURIComponent(account.name)}`,
169
+ bounded,
170
+ );
171
+ if (!isRecord(models) || !Array.isArray(models.models))
172
+ throw new Error("The management API returned an invalid account catalog.");
173
+ if (!models.models.some((model: unknown) => isRecord(model) && model.id === modelId)) return undefined;
174
+ const response = await managementRequest(config, key, "api-call", bounded, {
175
+ auth_index: account.auth_index,
176
+ method: "GET",
177
+ url: "https://api.anthropic.com/api/oauth/usage",
178
+ header: {
179
+ Authorization: "Bearer $TOKEN$",
180
+ "Content-Type": "application/json",
181
+ "anthropic-beta": "oauth-2025-04-20",
182
+ "User-Agent": "claude-cli/2.1.280 (external, cli)",
183
+ },
184
+ });
185
+ if (!isRecord(response) || typeof response.status_code !== "number")
186
+ throw new Error("The management API returned an invalid quota result.");
187
+ if (response.status_code === 401 || response.status_code === 403) return { state: "rejected" as const };
188
+ if (response.status_code !== 200 || typeof response.body !== "string")
189
+ throw new Error("Claude usage is unavailable. Waiting stopped rather than guessing a reset time.");
190
+ let payload: unknown;
191
+ try {
192
+ payload = JSON.parse(response.body);
193
+ } catch {
194
+ throw new Error("Claude returned invalid usage data.");
195
+ }
196
+ const date = isRecord(response.header)
197
+ ? Object.entries(response.header).find(([name]) => name.toLowerCase() === "date")?.[1]
198
+ : undefined;
199
+ const serverNow = Array.isArray(date) && typeof date[0] === "string" ? Date.parse(date[0]) : NaN;
200
+ return claudeQuotaState(
201
+ payload,
202
+ reference,
203
+ Date.now(),
204
+ Number.isFinite(serverNow) ? serverNow : Date.now(),
205
+ requestRejected,
206
+ );
207
+ }),
208
+ );
209
+ const eligible = results.flatMap((result) =>
210
+ result.status === "fulfilled" && result.value !== undefined ? [result.value] : [],
211
+ );
212
+ if (!eligible.length) {
213
+ const failed = results.find((result) => result.status === "rejected");
214
+ if (failed?.status === "rejected" && failed.reason instanceof Error) throw failed.reason;
215
+ throw new Error("No active Claude subscription account advertises the selected model.");
216
+ }
217
+ if (eligible.some((result) => result.state === "ready")) return { state: "ready" as const };
218
+ const blocked = eligible.filter((result) => result.state === "blocked");
219
+ if (!blocked.length)
220
+ throw new Error(
221
+ "Claude rejected the account credentials or access. Sign in again or check the account status.",
222
+ );
223
+ const permitted = config.quota.waitForWeeklyReset
224
+ ? blocked
225
+ : blocked.filter((result) => result.weeklyResetAt === undefined);
226
+ if (!permitted.length) {
227
+ const resetAt = Math.min(...blocked.map((result) => result.weeklyResetAt ?? result.resetAt));
228
+ throw new Error(
229
+ `Seven-day quota is exhausted. Earliest weekly reset: ${new Date(resetAt).toISOString()}. Set quota.waitForWeeklyReset to true in pi-cliproxyapi.json to wait for it.`,
230
+ );
231
+ }
232
+ return permitted.reduce((a, b) => (a.retryAt < b.retryAt ? a : b));
233
+ }
234
+
235
+ export async function waitForClaudeQuota(
236
+ config: Config,
237
+ key: string,
238
+ modelId: string,
239
+ deadline: number,
240
+ signal?: AbortSignal,
241
+ onWait?: QuotaWaitHandler,
242
+ remainingInsufficient = false,
243
+ ) {
244
+ const id = Symbol();
245
+ let retryAt: number | undefined;
246
+ try {
247
+ for (;;) {
248
+ signal?.throwIfAborted();
249
+ const quota = await fetchClaudeQuota(
250
+ config,
251
+ key,
252
+ modelId,
253
+ signal,
254
+ remainingInsufficient && (retryAt === undefined || Date.now() < retryAt),
255
+ );
256
+ signal?.throwIfAborted();
257
+ if (Date.now() >= deadline || (quota.state === "blocked" && quota.retryAt > deadline))
258
+ throw new Error(
259
+ "The seven-day quota waiting deadline was reached. Start a new request after confirming account access.",
260
+ );
261
+ if (quota.state === "ready") return retryAt !== undefined;
262
+ retryAt = quota.retryAt;
263
+ const refreshAt = Math.min(quota.retryAt, Date.now() + 60000);
264
+ while (Date.now() < refreshAt) {
265
+ signal?.throwIfAborted();
266
+ onWait?.(id, Math.max(0, Math.ceil((quota.retryAt - Date.now()) / 1000)));
267
+ await sleep(Math.min(1000, refreshAt - Date.now()), undefined, { signal });
268
+ }
269
+ }
270
+ } finally {
271
+ onWait?.(id, undefined);
272
+ }
273
+ }
package/src/config.ts CHANGED
@@ -28,12 +28,20 @@ export function normalizeBaseUrl(value: string) {
28
28
  }
29
29
 
30
30
  export function parseConfig(value: unknown, baseUrlOverride?: string) {
31
- if (!isRecord(value) || Object.keys(value).some((key) => !["baseUrl", "aliases"].includes(key))) {
32
- throw new Error("pi-cliproxyapi.json accepts only baseUrl and aliases.");
31
+ if (!isRecord(value) || Object.keys(value).some((key) => !["baseUrl", "aliases", "quota"].includes(key))) {
32
+ throw new Error("pi-cliproxyapi.json accepts only baseUrl, aliases, and quota.");
33
33
  }
34
34
  if (value.baseUrl !== undefined && typeof value.baseUrl !== "string") {
35
35
  throw new Error("CLIProxyAPI baseUrl must be a string.");
36
36
  }
37
+ const quota = value.quota === undefined ? {} : value.quota;
38
+ if (
39
+ !isRecord(quota) ||
40
+ Object.keys(quota).some((key) => key !== "waitForWeeklyReset") ||
41
+ (quota.waitForWeeklyReset !== undefined && typeof quota.waitForWeeklyReset !== "boolean")
42
+ ) {
43
+ throw new Error("CLIProxyAPI quota accepts only a boolean waitForWeeklyReset.");
44
+ }
37
45
  const rawAliases = value.aliases === undefined ? {} : value.aliases;
38
46
  if (!isRecord(rawAliases)) throw new Error("CLIProxyAPI aliases must be an object.");
39
47
  const aliases = Object.fromEntries(
@@ -58,6 +66,7 @@ export function parseConfig(value: unknown, baseUrlOverride?: string) {
58
66
  return {
59
67
  baseUrl: normalizeBaseUrl(baseUrlOverride ?? value.baseUrl ?? DEFAULT_BASE_URL),
60
68
  aliases,
69
+ quota: { waitForWeeklyReset: quota.waitForWeeklyReset ?? false },
61
70
  };
62
71
  }
63
72
 
File without changes
package/src/media.ts CHANGED
@@ -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>();
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
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { setTimeout as sleep } from "node:timers/promises";
2
3
  import {
3
4
  type Api,
4
5
  createProvider,
@@ -18,8 +19,11 @@ import {
18
19
  } from "@earendil-works/pi-ai/compat";
19
20
  import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
20
21
  import { type CpaApi, endpoint, mapCatalog, modelRoute } from "./catalog.ts";
22
+ import { type QuotaWaitHandler, waitForClaudeQuota } from "./claude-quota.ts";
21
23
  import { type Config, isRecord, PROVIDER_ID } from "./config.ts";
22
24
 
25
+ export type { QuotaWaitHandler } from "./claude-quota.ts";
26
+
23
27
  export function builtinCatalog() {
24
28
  return [
25
29
  ...getBuiltinModels("anthropic"),
@@ -36,9 +40,13 @@ export function validateKey(key: string) {
36
40
  return key;
37
41
  }
38
42
 
43
+ export function deadline(signal: AbortSignal | undefined, milliseconds: number) {
44
+ return AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(milliseconds)]);
45
+ }
46
+
39
47
  export async function fetchCatalog(config: Config, key: string, signal: AbortSignal) {
40
48
  validateKey(key);
41
- const boundedSignal = AbortSignal.any([signal, AbortSignal.timeout(10000)]);
49
+ const boundedSignal = deadline(signal, 10000);
42
50
  let response: Response;
43
51
  try {
44
52
  response = await fetch(`${config.baseUrl}/v1/models`, {
@@ -102,14 +110,83 @@ export function nonStrictTools(payload: unknown) {
102
110
  };
103
111
  }
104
112
 
105
- function route(streams: ProviderStreams, baseUrl: string): ProviderStreams {
113
+ function quotaError(message: string | undefined) {
114
+ let error: unknown;
115
+ if (message && message.length <= 65536) {
116
+ try {
117
+ const body: unknown = JSON.parse(message.slice(message.indexOf("{"), message.lastIndexOf("}") + 1));
118
+ error = isRecord(body) && isRecord(body.error) ? body.error : body;
119
+ } catch {
120
+ // SDK messages without a JSON body can still carry Retry-After headers.
121
+ }
122
+ }
123
+ return isRecord(error) ? error : undefined;
124
+ }
125
+
126
+ const quotaErrorTypes = ["model_cooldown", "rate_limit_error", "rate_limit_exceeded", "usage_limit_reached"];
127
+
128
+ function isQuotaFailure(error: ReturnType<typeof quotaError>, status?: number) {
129
+ const code = error?.code ?? error?.type;
130
+ if (code === "permission_error" || code === "authentication_error") return false;
131
+ const quota = typeof code === "string" && quotaErrorTypes.includes(code);
132
+ return status === undefined ? code === "model_cooldown" : status === 429 || (status === 403 && quota);
133
+ }
134
+
135
+ export function quotaRetryAt(
136
+ message: string | undefined,
137
+ response?: Pick<Response, "status" | "headers">,
138
+ now = Date.now(),
139
+ ) {
140
+ const error = quotaError(message);
141
+ if (!isQuotaFailure(error, response?.status)) return undefined;
142
+ const resets: number[] = [];
143
+ const retryAfter = response?.headers.get("retry-after")?.trim();
144
+ if (retryAfter) {
145
+ const serverDate = Date.parse(response?.headers.get("date") ?? "");
146
+ resets.push(
147
+ /^\d+$/.test(retryAfter)
148
+ ? now + Number(retryAfter) * 1000
149
+ : now + Date.parse(retryAfter) - (Number.isFinite(serverDate) ? serverDate : now),
150
+ );
151
+ }
152
+ const code = error?.code ?? error?.type;
153
+ if (
154
+ typeof code === "string" &&
155
+ quotaErrorTypes.includes(code) &&
156
+ typeof error?.reset_seconds === "number"
157
+ ) {
158
+ resets.push(now + error.reset_seconds * 1000);
159
+ }
160
+ // Support five-hour and weekly windows without trusting unbounded server delays.
161
+ const valid = resets.filter((at) => Number.isSafeInteger(at) && at > now && at - now <= 7 * 86400000);
162
+ return valid.length ? Math.max(...valid) + 1000 : undefined;
163
+ }
164
+
165
+ async function waitForQuota(resetAt: number, signal?: AbortSignal, onWait?: QuotaWaitHandler) {
166
+ const id = Symbol();
167
+ try {
168
+ for (let remaining = resetAt - Date.now(); remaining > 0; remaining = resetAt - Date.now()) {
169
+ signal?.throwIfAborted();
170
+ onWait?.(id, Math.ceil(remaining / 1000));
171
+ await sleep(Math.min(remaining, 1000), undefined, { signal });
172
+ }
173
+ signal?.throwIfAborted();
174
+ } finally {
175
+ onWait?.(id, undefined);
176
+ }
177
+ }
178
+
179
+ function route(streams: ProviderStreams, config: Config, onWait?: QuotaWaitHandler): ProviderStreams {
106
180
  const wrap =
107
181
  (stream: ProviderStreams["streamSimple"]): ProviderStreams["streamSimple"] =>
108
182
  (selected, context, options) => {
109
183
  // A resumed selection must not send the current key to an old endpoint.
110
- const model = { ...selected, baseUrl: endpoint(baseUrl, selected.api) };
184
+ const model = { ...selected, baseUrl: endpoint(config.baseUrl, selected.api) };
111
185
  const { metadataId, backend } = modelRoute(model.id);
112
186
  const google = backend && model.api === "google-generative-ai";
187
+ const managementKey =
188
+ options?.env?.CLIPROXYAPI_MANAGEMENT_KEY ?? process.env.CLIPROXYAPI_MANAGEMENT_KEY ?? "";
189
+ const liveClaude = model.api === "anthropic-messages" && !backend && managementKey !== "";
113
190
  const adapterModel = google ? { ...model, id: metadataId } : model;
114
191
  // Google needs canonical IDs for tool turns. Swap identities to keep bare history cross-route.
115
192
  const adapterContext = google
@@ -141,22 +218,114 @@ function route(streams: ProviderStreams, baseUrl: string): ProviderStreams {
141
218
  const result = await options?.onPayload?.(adapted, model);
142
219
  return result === undefined ? adapted : result;
143
220
  };
144
- const run = () =>
145
- stream(adapterModel, adapterContext, {
221
+ let response: Pick<Response, "status" | "headers"> | undefined;
222
+ const run = () => {
223
+ response = undefined;
224
+ return stream(adapterModel, adapterContext, {
146
225
  ...options,
226
+ // Google rejects custom fetch; its structured cooldown body remains available on errors.
227
+ fetch:
228
+ model.api === "google-generative-ai"
229
+ ? options?.fetch
230
+ : async (input, init) => {
231
+ response = undefined;
232
+ const result = await (options?.fetch ?? globalThis.fetch)(input, init);
233
+ response = { status: result.status, headers: result.headers };
234
+ if (liveClaude && result.status === 429) {
235
+ // Inspect the first quota rejection without disabling transient-error retries.
236
+ const headers = new Headers(result.headers);
237
+ headers.set("x-should-retry", "false");
238
+ return new Response(result.body, {
239
+ status: result.status,
240
+ statusText: result.statusText,
241
+ headers,
242
+ });
243
+ }
244
+ return result;
245
+ },
147
246
  onPayload,
148
247
  onResponse: options?.onResponse ? (response) => options.onResponse?.(response, model) : undefined,
149
248
  onProviderStreamEvent: options?.onProviderStreamEvent
150
249
  ? (event) => options.onProviderStreamEvent?.(event, model)
151
250
  : undefined,
152
251
  });
153
- if (!google) return run();
252
+ };
154
253
  return lazyStream(model, async () => ({
155
254
  async *[Symbol.asyncIterator]() {
156
- for await (const event of run()) {
157
- if (event.type === "done") yield { ...event, message: { ...event.message, model: model.id } };
158
- else if (event.type === "error") yield { ...event, error: { ...event.error, model: model.id } };
159
- else yield { ...event, partial: { ...event.partial, model: model.id } };
255
+ let quotaDeadline: number | undefined;
256
+ for (;;) {
257
+ let started = false;
258
+ let retry = false;
259
+ for await (const event of run()) {
260
+ if (event.type === "error" && event.reason === "error" && !started) {
261
+ const resetAt = quotaRetryAt(event.error.errorMessage, response);
262
+ const failure = quotaError(event.error.errorMessage);
263
+ const upstream =
264
+ failure?.provider ??
265
+ (typeof failure?.message === "string"
266
+ ? /via provider ([\w-]+)/.exec(failure.message)?.[1]
267
+ : undefined);
268
+ const checkClaude =
269
+ liveClaude &&
270
+ (upstream === undefined || upstream === "claude") &&
271
+ isQuotaFailure(failure, response?.status);
272
+ if ((checkClaude || resetAt) && !options?.signal?.aborted) {
273
+ quotaDeadline ??= Date.now() + 7 * 86400000 + 1000;
274
+ try {
275
+ // Keep quota waits outside HTTP timeouts and within one fixed weekly deadline.
276
+ let waited = false;
277
+ if (checkClaude) {
278
+ waited = await waitForClaudeQuota(
279
+ config,
280
+ managementKey,
281
+ model.id,
282
+ quotaDeadline,
283
+ options?.signal,
284
+ onWait,
285
+ typeof failure?.message === "string" &&
286
+ /would exceed your account's rate limit/i.test(failure.message),
287
+ );
288
+ }
289
+ if (!waited) {
290
+ if (!resetAt) {
291
+ yield event;
292
+ return;
293
+ }
294
+ if (resetAt > quotaDeadline)
295
+ throw new Error("The seven-day quota waiting deadline was reached.");
296
+ await waitForQuota(resetAt, options?.signal, onWait);
297
+ }
298
+ if (Date.now() >= quotaDeadline)
299
+ throw new Error("The seven-day quota waiting deadline was reached.");
300
+ } catch (error) {
301
+ const reason = options?.signal?.aborted ? ("aborted" as const) : ("error" as const);
302
+ yield {
303
+ type: "error" as const,
304
+ reason,
305
+ error: {
306
+ ...event.error,
307
+ model: model.id,
308
+ stopReason: reason,
309
+ errorMessage:
310
+ reason === "aborted"
311
+ ? "Request was aborted during the quota wait."
312
+ : `CLIProxyAPI quota wait stopped. ${error instanceof Error ? error.message : "Live quota could not be verified."}`,
313
+ },
314
+ };
315
+ return;
316
+ }
317
+ retry = true;
318
+ break;
319
+ }
320
+ }
321
+ started = true;
322
+ if (!google) yield event;
323
+ else if (event.type === "done")
324
+ yield { ...event, message: { ...event.message, model: model.id } };
325
+ else if (event.type === "error") yield { ...event, error: { ...event.error, model: model.id } };
326
+ else yield { ...event, partial: { ...event.partial, model: model.id } };
327
+ }
328
+ if (!retry) return;
160
329
  }
161
330
  },
162
331
  }));
@@ -164,10 +333,14 @@ function route(streams: ProviderStreams, baseUrl: string): ProviderStreams {
164
333
  return { stream: wrap(streams.stream), streamSimple: wrap(streams.streamSimple) };
165
334
  }
166
335
 
167
- export function createCliproxyProvider(config: Config, known: readonly Model<Api>[] = builtinCatalog()) {
336
+ export function createCliproxyProvider(
337
+ config: Config,
338
+ known: readonly Model<Api>[] = builtinCatalog(),
339
+ onQuotaWait?: QuotaWaitHandler,
340
+ ) {
168
341
  const standardAuth = envApiKeyAuth("CLIProxyAPI API key", ["CLIPROXYAPI_API_KEY"]);
169
342
  const scope = createHash("sha256")
170
- .update(JSON.stringify([4, config]))
343
+ .update(JSON.stringify([4, { baseUrl: config.baseUrl, aliases: config.aliases }]))
171
344
  .digest("hex");
172
345
  const provider = createProvider<CpaApi>({
173
346
  id: PROVIDER_ID,
@@ -199,10 +372,10 @@ export function createCliproxyProvider(config: Config, known: readonly Model<Api
199
372
  return (await discover(config, context.credential.key, context.signal, known)).models;
200
373
  },
201
374
  api: {
202
- "anthropic-messages": route(anthropicMessagesApi(), config.baseUrl),
203
- "openai-responses": route(openAIResponsesApi(), config.baseUrl),
204
- "openai-completions": route(openAICompletionsApi(), config.baseUrl),
205
- "google-generative-ai": route(googleGenerativeAIApi(), config.baseUrl),
375
+ "anthropic-messages": route(anthropicMessagesApi(), config, onQuotaWait),
376
+ "openai-responses": route(openAIResponsesApi(), config, onQuotaWait),
377
+ "openai-completions": route(openAICompletionsApi(), config, onQuotaWait),
378
+ "google-generative-ai": route(googleGenerativeAIApi(), config, onQuotaWait),
206
379
  },
207
380
  });
208
381
  const refresh = provider.refreshModels;