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 +0 -0
- package/README.md +32 -3
- package/extensions/index.ts +32 -2
- package/package.json +4 -4
- package/src/catalog.ts +13 -6
- package/src/claude-quota.ts +273 -0
- package/src/config.ts +11 -2
- package/src/media-defaults.ts +0 -0
- package/src/media.ts +1 -5
- package/src/picker.ts +28 -24
- package/src/provider.ts +189 -16
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.
|
|
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.
|
|
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.
|
|
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:
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
43
|
-
"@earendil-works/pi-coding-agent": "^1.0.
|
|
44
|
-
"@earendil-works/pi-tui": "^1.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
|
-
:
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
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
|
|
package/src/media-defaults.ts
CHANGED
|
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
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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 (
|
|
244
|
-
save(
|
|
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
|
|
249
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
252
|
+
};
|
|
154
253
|
return lazyStream(model, async () => ({
|
|
155
254
|
async *[Symbol.asyncIterator]() {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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(
|
|
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
|
|
203
|
-
"openai-responses": route(openAIResponsesApi(), config
|
|
204
|
-
"openai-completions": route(openAICompletionsApi(), config
|
|
205
|
-
"google-generative-ai": route(googleGenerativeAIApi(), config
|
|
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;
|