pi-cliproxyapi-native 0.1.6 → 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
@@ -11,10 +11,10 @@ This release targets Pi 1.0.4 and CLIProxyAPI 8.0.18, with Node.js 22.19 or newe
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.6
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.6`. 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.
@@ -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.6",
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"
package/src/catalog.ts CHANGED
File without changes
@@ -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
File without changes
package/src/picker.ts CHANGED
File without changes
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"),
@@ -106,14 +110,83 @@ export function nonStrictTools(payload: unknown) {
106
110
  };
107
111
  }
108
112
 
109
- 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 {
110
180
  const wrap =
111
181
  (stream: ProviderStreams["streamSimple"]): ProviderStreams["streamSimple"] =>
112
182
  (selected, context, options) => {
113
183
  // A resumed selection must not send the current key to an old endpoint.
114
- const model = { ...selected, baseUrl: endpoint(baseUrl, selected.api) };
184
+ const model = { ...selected, baseUrl: endpoint(config.baseUrl, selected.api) };
115
185
  const { metadataId, backend } = modelRoute(model.id);
116
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 !== "";
117
190
  const adapterModel = google ? { ...model, id: metadataId } : model;
118
191
  // Google needs canonical IDs for tool turns. Swap identities to keep bare history cross-route.
119
192
  const adapterContext = google
@@ -145,22 +218,114 @@ function route(streams: ProviderStreams, baseUrl: string): ProviderStreams {
145
218
  const result = await options?.onPayload?.(adapted, model);
146
219
  return result === undefined ? adapted : result;
147
220
  };
148
- const run = () =>
149
- stream(adapterModel, adapterContext, {
221
+ let response: Pick<Response, "status" | "headers"> | undefined;
222
+ const run = () => {
223
+ response = undefined;
224
+ return stream(adapterModel, adapterContext, {
150
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
+ },
151
246
  onPayload,
152
247
  onResponse: options?.onResponse ? (response) => options.onResponse?.(response, model) : undefined,
153
248
  onProviderStreamEvent: options?.onProviderStreamEvent
154
249
  ? (event) => options.onProviderStreamEvent?.(event, model)
155
250
  : undefined,
156
251
  });
157
- if (!google) return run();
252
+ };
158
253
  return lazyStream(model, async () => ({
159
254
  async *[Symbol.asyncIterator]() {
160
- for await (const event of run()) {
161
- if (event.type === "done") yield { ...event, message: { ...event.message, model: model.id } };
162
- else if (event.type === "error") yield { ...event, error: { ...event.error, model: model.id } };
163
- 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;
164
329
  }
165
330
  },
166
331
  }));
@@ -168,10 +333,14 @@ function route(streams: ProviderStreams, baseUrl: string): ProviderStreams {
168
333
  return { stream: wrap(streams.stream), streamSimple: wrap(streams.streamSimple) };
169
334
  }
170
335
 
171
- 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
+ ) {
172
341
  const standardAuth = envApiKeyAuth("CLIProxyAPI API key", ["CLIPROXYAPI_API_KEY"]);
173
342
  const scope = createHash("sha256")
174
- .update(JSON.stringify([4, config]))
343
+ .update(JSON.stringify([4, { baseUrl: config.baseUrl, aliases: config.aliases }]))
175
344
  .digest("hex");
176
345
  const provider = createProvider<CpaApi>({
177
346
  id: PROVIDER_ID,
@@ -203,10 +372,10 @@ export function createCliproxyProvider(config: Config, known: readonly Model<Api
203
372
  return (await discover(config, context.credential.key, context.signal, known)).models;
204
373
  },
205
374
  api: {
206
- "anthropic-messages": route(anthropicMessagesApi(), config.baseUrl),
207
- "openai-responses": route(openAIResponsesApi(), config.baseUrl),
208
- "openai-completions": route(openAICompletionsApi(), config.baseUrl),
209
- "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),
210
379
  },
211
380
  });
212
381
  const refresh = provider.refreshModels;