@juvio15/pi-cliproxyapi-provider 0.0.0-stage → 0.1.1
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 +21 -0
- package/package.json +51 -4
- package/src/core/catalog.ts +400 -0
- package/src/core/connection.ts +119 -0
- package/src/core/costs.ts +477 -0
- package/src/core/index.ts +189 -0
- package/src/core/oauth.ts +279 -0
- package/src/core/streams.ts +89 -0
- package/src/core/usage.ts +813 -0
- package/src/extension/completions.ts +20 -0
- package/src/extension/config.ts +314 -0
- package/src/extension/fast.ts +81 -0
- package/src/extension/files.ts +146 -0
- package/src/extension/footer.ts +229 -0
- package/src/extension/index.ts +477 -0
- package/src/extension/oauth.ts +203 -0
- package/src/extension/pause.ts +126 -0
- package/src/extension/prices.ts +205 -0
- package/src/extension/prompt-cache.ts +17 -0
- package/src/extension/provider.ts +386 -0
- package/src/extension/retry.ts +41 -0
- package/src/extension/status.ts +166 -0
- package/src/extension/tps.ts +215 -0
- package/src/extension/usage.ts +609 -0
- package/src/extension/web-search.ts +307 -0
- package/src/web-search/endpoint.ts +21 -0
- package/src/web-search/index.ts +21 -0
- package/src/web-search/model-selector.ts +93 -0
- package/src/web-search/search.ts +571 -0
- package/src/web-search/sse.ts +144 -0
- package/README.md +0 -3
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type { Static, TSchema } from "typebox";
|
|
3
|
+
import { Value } from "typebox/value";
|
|
4
|
+
|
|
5
|
+
import { resolveEndpoints } from "./connection.ts";
|
|
6
|
+
|
|
7
|
+
/** One rate-limit window of an upstream account, such as Claude's five hours. */
|
|
8
|
+
export interface UsageWindow {
|
|
9
|
+
label: string;
|
|
10
|
+
usedPercent: number;
|
|
11
|
+
/** Epoch milliseconds. */
|
|
12
|
+
resetAt?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Usage of one CLIProxyAPI credential, the upstream account behind some models. */
|
|
16
|
+
export interface ProviderUsage {
|
|
17
|
+
/** CLIProxyAPI's provider id, such as `claude` or `kimi-ai`. */
|
|
18
|
+
provider: string;
|
|
19
|
+
/** Short name for a footer. */
|
|
20
|
+
name: string;
|
|
21
|
+
models: string[];
|
|
22
|
+
windows: UsageWindow[];
|
|
23
|
+
/** Epoch milliseconds until which the account can't serve requests. */
|
|
24
|
+
exhaustedUntil?: number;
|
|
25
|
+
/** Epoch milliseconds when the server recorded the quota signals behind these windows, when it reports one. */
|
|
26
|
+
observedAt?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UsageFetchOptions {
|
|
30
|
+
baseUrl: string;
|
|
31
|
+
/** The CLIProxyAPI management key, not the client API key. */
|
|
32
|
+
managementKey: string;
|
|
33
|
+
fetch?: typeof globalThis.fetch;
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
now?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ResetCooldownsOptions extends UsageFetchOptions {
|
|
40
|
+
/** Reset only the credentials that serve this model. Omit to reset every credential. */
|
|
41
|
+
modelId?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
45
|
+
const SECOND_MS = 1000;
|
|
46
|
+
const MINUTE_MS = 60 * SECOND_MS;
|
|
47
|
+
const FULL_PERCENT = 100;
|
|
48
|
+
|
|
49
|
+
/** Wait this long when a quota error names no reset time. */
|
|
50
|
+
export const QUOTA_FALLBACK_MS = 15 * MINUTE_MS;
|
|
51
|
+
|
|
52
|
+
const QUOTA_ERROR =
|
|
53
|
+
/quota exhausted|subscription quota|usage[ _]limit|usage window|insufficient_quota|rate limit reached/iu;
|
|
54
|
+
|
|
55
|
+
const RESETS_AT_EPOCH = /"resets_at"\s*:\s*(?<seconds>\d{9,})/u;
|
|
56
|
+
const RESETS_AT_ISO = /resets at (?<time>\d{4}-\d{2}-\d{2}T[\d:.]+Z)/iu;
|
|
57
|
+
const RESETS_IN_SECONDS = /"resets_in_seconds"\s*:\s*(?<seconds>\d+)/u;
|
|
58
|
+
const TRY_AGAIN_MINUTES = /try again in ~?\s*(?<minutes>\d+)\s*min/iu;
|
|
59
|
+
|
|
60
|
+
/** Parses a number or numeric string. Undefined for anything else, including NaN. */
|
|
61
|
+
const finite = (value: string | number | undefined): number | undefined => {
|
|
62
|
+
if (value === undefined || value === "") {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const parsed = Number(value);
|
|
67
|
+
|
|
68
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Parses an ISO time to epoch milliseconds. */
|
|
72
|
+
const isoTime = (value: string | undefined): number | undefined => {
|
|
73
|
+
const parsed = value === undefined ? Number.NaN : Date.parse(value);
|
|
74
|
+
|
|
75
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* When an error message says an upstream account ran out of quota, returns the
|
|
80
|
+
* epoch milliseconds when it resets. Undefined for every other error.
|
|
81
|
+
*/
|
|
82
|
+
export const quotaResetFromError = (
|
|
83
|
+
message: string,
|
|
84
|
+
now = Date.now()
|
|
85
|
+
): number | undefined => {
|
|
86
|
+
if (!QUOTA_ERROR.test(message)) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const epoch = finite(RESETS_AT_EPOCH.exec(message)?.groups?.seconds);
|
|
91
|
+
const inSeconds = finite(RESETS_IN_SECONDS.exec(message)?.groups?.seconds);
|
|
92
|
+
const inMinutes = finite(TRY_AGAIN_MINUTES.exec(message)?.groups?.minutes);
|
|
93
|
+
|
|
94
|
+
const candidates = [
|
|
95
|
+
epoch === undefined ? undefined : epoch * SECOND_MS,
|
|
96
|
+
isoTime(RESETS_AT_ISO.exec(message)?.groups?.time),
|
|
97
|
+
inSeconds === undefined ? undefined : now + inSeconds * SECOND_MS,
|
|
98
|
+
inMinutes === undefined ? undefined : now + inMinutes * MINUTE_MS,
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
candidates.find((time) => time !== undefined && time > now) ??
|
|
103
|
+
now + QUOTA_FALLBACK_MS
|
|
104
|
+
);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const SignalsSchema = Type.Record(
|
|
108
|
+
Type.String(),
|
|
109
|
+
Type.Union([Type.String(), Type.Number()])
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
type Signals = Static<typeof SignalsSchema>;
|
|
113
|
+
|
|
114
|
+
const CredentialSchema = Type.Object({
|
|
115
|
+
auth_index: Type.String(),
|
|
116
|
+
disabled: Type.Optional(Type.Boolean()),
|
|
117
|
+
name: Type.String(),
|
|
118
|
+
next_retry_after: Type.Optional(Type.String()),
|
|
119
|
+
provider: Type.String(),
|
|
120
|
+
quota: Type.Optional(
|
|
121
|
+
Type.Object({
|
|
122
|
+
observed_at: Type.Optional(Type.String()),
|
|
123
|
+
signals: Type.Optional(SignalsSchema),
|
|
124
|
+
})
|
|
125
|
+
),
|
|
126
|
+
quota_plugin_id: Type.Optional(Type.String()),
|
|
127
|
+
unavailable: Type.Optional(Type.Boolean()),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
type Credential = Static<typeof CredentialSchema>;
|
|
131
|
+
|
|
132
|
+
const CredentialsSchema = Type.Object({
|
|
133
|
+
files: Type.Array(CredentialSchema),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const CredentialModelsSchema = Type.Object({
|
|
137
|
+
models: Type.Array(Type.Object({ id: Type.String() })),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const CooldownResetSchema = Type.Object({
|
|
141
|
+
auth_index: Type.String(),
|
|
142
|
+
models: Type.Array(Type.String()),
|
|
143
|
+
status: Type.String(),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
/** CLIProxyAPI relays the upstream body as text. */
|
|
147
|
+
const ApiCallSchema = Type.Object({
|
|
148
|
+
body: Type.Optional(Type.String()),
|
|
149
|
+
status_code: Type.Number(),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const KimiUsageSchema = Type.Object({
|
|
153
|
+
usages: Type.Record(
|
|
154
|
+
Type.String(),
|
|
155
|
+
Type.Object({
|
|
156
|
+
reset_time: Type.Optional(Type.String()),
|
|
157
|
+
used_ratio: Type.Number(),
|
|
158
|
+
})
|
|
159
|
+
),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
type KimiUsage = Static<typeof KimiUsageSchema>;
|
|
163
|
+
|
|
164
|
+
const XaiBillingSchema = Type.Object({
|
|
165
|
+
config: Type.Object({
|
|
166
|
+
creditUsagePercent: Type.Number(),
|
|
167
|
+
currentPeriod: Type.Optional(
|
|
168
|
+
Type.Object({
|
|
169
|
+
end: Type.Optional(Type.String()),
|
|
170
|
+
start: Type.Optional(Type.String()),
|
|
171
|
+
})
|
|
172
|
+
),
|
|
173
|
+
}),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
type XaiBilling = Static<typeof XaiBillingSchema>;
|
|
177
|
+
|
|
178
|
+
/** Normalized quota a CLIProxyAPI quota plugin returns for one credential. */
|
|
179
|
+
const PluginQuotaSchema = Type.Object({
|
|
180
|
+
groups: Type.Optional(
|
|
181
|
+
Type.Array(
|
|
182
|
+
Type.Object({
|
|
183
|
+
buckets: Type.Optional(
|
|
184
|
+
Type.Array(
|
|
185
|
+
Type.Object({
|
|
186
|
+
remainingFraction: Type.Optional(Type.Number()),
|
|
187
|
+
remaining_fraction: Type.Optional(
|
|
188
|
+
Type.Union([Type.Number(), Type.String()])
|
|
189
|
+
),
|
|
190
|
+
resetTime: Type.Optional(Type.String()),
|
|
191
|
+
reset_time: Type.Optional(Type.String()),
|
|
192
|
+
window: Type.Optional(Type.String()),
|
|
193
|
+
})
|
|
194
|
+
)
|
|
195
|
+
),
|
|
196
|
+
displayName: Type.Optional(Type.String()),
|
|
197
|
+
display_name: Type.Optional(Type.String()),
|
|
198
|
+
})
|
|
199
|
+
)
|
|
200
|
+
),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
type PluginQuota = Static<typeof PluginQuotaSchema>;
|
|
204
|
+
|
|
205
|
+
const FOOTER_NAMES = new Map([
|
|
206
|
+
["antigravity", "antigravity"],
|
|
207
|
+
["claude", "claude"],
|
|
208
|
+
["codex", "codex"],
|
|
209
|
+
["devin", "devin"],
|
|
210
|
+
["gemini", "gemini"],
|
|
211
|
+
["kimi-ai", "kimi"],
|
|
212
|
+
["meta", "muse"],
|
|
213
|
+
["xai", "grok"],
|
|
214
|
+
]);
|
|
215
|
+
|
|
216
|
+
const KIMI_WINDOWS = [
|
|
217
|
+
["limit_5h", "5h"],
|
|
218
|
+
["limit_month_total", "mo"],
|
|
219
|
+
] as const;
|
|
220
|
+
|
|
221
|
+
const DEVIN_WINDOWS = [
|
|
222
|
+
["daily", "1d"],
|
|
223
|
+
["weekly", "7d"],
|
|
224
|
+
] as const;
|
|
225
|
+
|
|
226
|
+
const XAI_BILLING_URL =
|
|
227
|
+
"https://cli-chat-proxy.grok.com/v1/billing?format=credits";
|
|
228
|
+
|
|
229
|
+
const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages";
|
|
230
|
+
const KIMI_AI_USAGE_URL = "https://api.kimi.ai/coding/v1/usages";
|
|
231
|
+
const KIMI_AI = /kimi-ai|kimi\.ai/iu;
|
|
232
|
+
|
|
233
|
+
/** CLIProxyAPI swaps this placeholder for the credential's own access token. */
|
|
234
|
+
const TOKEN_HEADER = { Authorization: "Bearer $TOKEN$" } as const;
|
|
235
|
+
|
|
236
|
+
export const windowLabel = (minutes: number): string => {
|
|
237
|
+
const day = 24 * 60;
|
|
238
|
+
|
|
239
|
+
if (minutes >= day && minutes % day === 0) {
|
|
240
|
+
return `${minutes / day}d`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (minutes >= 60 && minutes % 60 === 0) {
|
|
244
|
+
return `${minutes / 60}h`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return `${Math.round(minutes)}m`;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const usageWindow = (
|
|
251
|
+
label: string,
|
|
252
|
+
usedPercent: number,
|
|
253
|
+
resetAt: number | undefined
|
|
254
|
+
): UsageWindow => {
|
|
255
|
+
const window: UsageWindow = { label, usedPercent };
|
|
256
|
+
|
|
257
|
+
if (resetAt !== undefined) {
|
|
258
|
+
window.resetAt = resetAt;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return window;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
/** Header names arrive in any case. */
|
|
265
|
+
const signalReader = (signals: Signals) => {
|
|
266
|
+
const byLowerKey = new Map(
|
|
267
|
+
Object.entries(signals).map(([key, value]) => [key.toLowerCase(), value])
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
number: (key: string): number | undefined =>
|
|
272
|
+
finite(byLowerKey.get(key.toLowerCase())),
|
|
273
|
+
text: (key: string): string | undefined => {
|
|
274
|
+
const value = byLowerKey.get(key.toLowerCase());
|
|
275
|
+
|
|
276
|
+
return value === undefined ? undefined : String(value);
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/** Claude's unified limits, which CLIProxyAPI records from response headers. */
|
|
282
|
+
const claudeWindows = (signals: Signals): UsageWindow[] => {
|
|
283
|
+
const read = signalReader(signals);
|
|
284
|
+
const windows: UsageWindow[] = [];
|
|
285
|
+
|
|
286
|
+
for (const label of ["5h", "7d"]) {
|
|
287
|
+
const prefix = `Anthropic-Ratelimit-Unified-${label}`;
|
|
288
|
+
const utilization = read.number(`${prefix}-Utilization`);
|
|
289
|
+
const rejected = read.text(`${prefix}-Status`) === "rejected";
|
|
290
|
+
|
|
291
|
+
if (utilization === undefined && !rejected) {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const reset = read.number(`${prefix}-Reset`);
|
|
296
|
+
windows.push(
|
|
297
|
+
usageWindow(
|
|
298
|
+
label,
|
|
299
|
+
rejected ? FULL_PERCENT : (utilization ?? 0) * FULL_PERCENT,
|
|
300
|
+
reset === undefined ? undefined : reset * SECOND_MS
|
|
301
|
+
)
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return windows;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
/** Codex's primary and secondary limits, which CLIProxyAPI records from response headers. */
|
|
309
|
+
const codexWindows = (signals: Signals, observedAt: number): UsageWindow[] => {
|
|
310
|
+
const read = signalReader(signals);
|
|
311
|
+
const windows: UsageWindow[] = [];
|
|
312
|
+
|
|
313
|
+
for (const slot of ["Primary", "Secondary"]) {
|
|
314
|
+
const prefix = `X-Codex-${slot}`;
|
|
315
|
+
const used = read.number(`${prefix}-Used-Percent`);
|
|
316
|
+
const minutes = read.number(`${prefix}-Window-Minutes`);
|
|
317
|
+
|
|
318
|
+
// Plans without a secondary limit report it as a zero-minute window.
|
|
319
|
+
if (used === undefined || minutes === 0) {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const resetAt = read.number(`${prefix}-Reset-At`);
|
|
324
|
+
const resetAfter = read.number(`${prefix}-Reset-After-Seconds`);
|
|
325
|
+
let reset: number | undefined;
|
|
326
|
+
|
|
327
|
+
if (resetAt !== undefined) {
|
|
328
|
+
reset = resetAt * SECOND_MS;
|
|
329
|
+
} else if (resetAfter !== undefined) {
|
|
330
|
+
reset = observedAt + resetAfter * SECOND_MS;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
windows.push(
|
|
334
|
+
usageWindow(
|
|
335
|
+
minutes === undefined ? slot.toLowerCase() : windowLabel(minutes),
|
|
336
|
+
used,
|
|
337
|
+
reset
|
|
338
|
+
)
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return windows;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Devin's daily and weekly limits, which CLIProxyAPI records in quota signals
|
|
347
|
+
* as remaining percents such as `55%`, with RFC 3339 reset times.
|
|
348
|
+
*/
|
|
349
|
+
const devinWindows = (signals: Signals): UsageWindow[] => {
|
|
350
|
+
const read = signalReader(signals);
|
|
351
|
+
const windows: UsageWindow[] = [];
|
|
352
|
+
|
|
353
|
+
for (const [period, label] of DEVIN_WINDOWS) {
|
|
354
|
+
const remainingText = read.text(`${period}_quota_remaining_percent`);
|
|
355
|
+
|
|
356
|
+
const remaining =
|
|
357
|
+
remainingText === undefined
|
|
358
|
+
? undefined
|
|
359
|
+
: finite(remainingText.trim().replace(/%$/u, ""));
|
|
360
|
+
|
|
361
|
+
if (remaining === undefined) {
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
windows.push(
|
|
366
|
+
usageWindow(
|
|
367
|
+
label,
|
|
368
|
+
FULL_PERCENT - remaining,
|
|
369
|
+
isoTime(read.text(`${period}_quota_reset_at`))
|
|
370
|
+
)
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return windows;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
/** Windows from a quota plugin's normalized groups and buckets. */
|
|
378
|
+
const pluginWindows = (body: PluginQuota | undefined): UsageWindow[] => {
|
|
379
|
+
const windows: UsageWindow[] = [];
|
|
380
|
+
|
|
381
|
+
for (const group of body?.groups ?? []) {
|
|
382
|
+
for (const bucket of group.buckets ?? []) {
|
|
383
|
+
const fraction = finite(
|
|
384
|
+
bucket.remainingFraction ?? bucket.remaining_fraction
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
if (fraction === undefined) {
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const usedPercent = Math.min(
|
|
392
|
+
FULL_PERCENT,
|
|
393
|
+
Math.max(0, (1 - fraction) * FULL_PERCENT)
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
windows.push(
|
|
397
|
+
usageWindow(
|
|
398
|
+
bucket.window ?? group.displayName ?? group.display_name ?? "quota",
|
|
399
|
+
usedPercent,
|
|
400
|
+
isoTime(bucket.resetTime ?? bucket.reset_time)
|
|
401
|
+
)
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return windows;
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const kimiWindows = (body: KimiUsage | undefined): UsageWindow[] => {
|
|
410
|
+
const windows: UsageWindow[] = [];
|
|
411
|
+
|
|
412
|
+
for (const [key, label] of KIMI_WINDOWS) {
|
|
413
|
+
const usage = body?.usages[key];
|
|
414
|
+
|
|
415
|
+
if (usage) {
|
|
416
|
+
windows.push(
|
|
417
|
+
usageWindow(
|
|
418
|
+
label,
|
|
419
|
+
usage.used_ratio * FULL_PERCENT,
|
|
420
|
+
isoTime(usage.reset_time)
|
|
421
|
+
)
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return windows;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const xaiWindows = (body: XaiBilling | undefined): UsageWindow[] => {
|
|
430
|
+
if (!body) {
|
|
431
|
+
return [];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const period = body.config.currentPeriod;
|
|
435
|
+
const start = isoTime(period?.start);
|
|
436
|
+
const end = isoTime(period?.end);
|
|
437
|
+
|
|
438
|
+
const minutes =
|
|
439
|
+
start === undefined || end === undefined
|
|
440
|
+
? undefined
|
|
441
|
+
: (end - start) / MINUTE_MS;
|
|
442
|
+
|
|
443
|
+
return [
|
|
444
|
+
usageWindow(
|
|
445
|
+
minutes !== undefined && minutes > 0 ? windowLabel(minutes) : "period",
|
|
446
|
+
body.config.creditUsagePercent,
|
|
447
|
+
end
|
|
448
|
+
),
|
|
449
|
+
];
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
/** Latest reset of the windows that are full. Undefined when none is full. */
|
|
453
|
+
const fullUntil = (windows: readonly UsageWindow[]): number | undefined => {
|
|
454
|
+
let until: number | undefined;
|
|
455
|
+
|
|
456
|
+
for (const window of windows) {
|
|
457
|
+
if (window.usedPercent >= FULL_PERCENT && window.resetAt !== undefined) {
|
|
458
|
+
until = Math.max(until ?? 0, window.resetAt);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return until;
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
const parseJson = <Schema extends TSchema>(
|
|
466
|
+
schema: Schema,
|
|
467
|
+
text: string
|
|
468
|
+
): Static<Schema> | undefined => {
|
|
469
|
+
try {
|
|
470
|
+
const value: unknown = JSON.parse(text);
|
|
471
|
+
|
|
472
|
+
return Value.Check(schema, value) ? value : undefined;
|
|
473
|
+
} catch {
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
export const fetchUsage = async (
|
|
479
|
+
options: UsageFetchOptions
|
|
480
|
+
): Promise<ProviderUsage[]> => {
|
|
481
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
482
|
+
const now = options.now ?? Date.now();
|
|
483
|
+
const managementUrl = `${resolveEndpoints(options.baseUrl).rootUrl}/v8/management`;
|
|
484
|
+
|
|
485
|
+
const headers = {
|
|
486
|
+
Authorization: `Bearer ${options.managementKey}`,
|
|
487
|
+
"Content-Type": "application/json",
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
const request = async <Schema extends TSchema>(
|
|
491
|
+
route: string,
|
|
492
|
+
schema: Schema,
|
|
493
|
+
init: RequestInit = {}
|
|
494
|
+
): Promise<Static<Schema>> => {
|
|
495
|
+
const timeout = AbortSignal.timeout(
|
|
496
|
+
options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
const response = await fetchFn(`${managementUrl}${route}`, {
|
|
500
|
+
...init,
|
|
501
|
+
headers,
|
|
502
|
+
signal: options.signal
|
|
503
|
+
? AbortSignal.any([options.signal, timeout])
|
|
504
|
+
: timeout,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const [routePath] = route.split("?");
|
|
508
|
+
|
|
509
|
+
if (!response.ok) {
|
|
510
|
+
throw new Error(
|
|
511
|
+
`CLIProxyAPI management ${routePath} returned HTTP ${response.status}`
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const value = parseJson(schema, await response.text());
|
|
516
|
+
|
|
517
|
+
if (value === undefined) {
|
|
518
|
+
throw new Error(
|
|
519
|
+
`CLIProxyAPI management ${routePath} returned an unexpected shape`
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return value;
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
const upstream = async <Schema extends TSchema>(
|
|
527
|
+
credential: Credential,
|
|
528
|
+
url: string,
|
|
529
|
+
schema: Schema
|
|
530
|
+
): Promise<Static<Schema> | undefined> => {
|
|
531
|
+
const result = await request("/requests/api-call", ApiCallSchema, {
|
|
532
|
+
body: JSON.stringify({
|
|
533
|
+
auth_index: credential.auth_index,
|
|
534
|
+
header: TOKEN_HEADER,
|
|
535
|
+
method: "GET",
|
|
536
|
+
url,
|
|
537
|
+
}),
|
|
538
|
+
method: "POST",
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
const ok = result.status_code >= 200 && result.status_code < 300;
|
|
542
|
+
|
|
543
|
+
return ok && result.body !== undefined
|
|
544
|
+
? parseJson(schema, result.body)
|
|
545
|
+
: undefined;
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const windowsFor = async (credential: Credential): Promise<UsageWindow[]> => {
|
|
549
|
+
const signals = credential.quota?.signals ?? {};
|
|
550
|
+
|
|
551
|
+
if (credential.provider === "claude") {
|
|
552
|
+
return claudeWindows(signals);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (credential.provider === "codex") {
|
|
556
|
+
return codexWindows(
|
|
557
|
+
signals,
|
|
558
|
+
isoTime(credential.quota?.observed_at) ?? now
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (credential.provider === "devin") {
|
|
563
|
+
return devinWindows(signals);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (credential.provider === "xai") {
|
|
567
|
+
return xaiWindows(
|
|
568
|
+
await upstream(credential, XAI_BILLING_URL, XaiBillingSchema)
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (credential.provider.startsWith("kimi")) {
|
|
573
|
+
const url = KIMI_AI.test(`${credential.provider} ${credential.name}`)
|
|
574
|
+
? KIMI_AI_USAGE_URL
|
|
575
|
+
: KIMI_USAGE_URL;
|
|
576
|
+
|
|
577
|
+
return kimiWindows(await upstream(credential, url, KimiUsageSchema));
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Providers such as antigravity and gemini record no quota signals, so
|
|
581
|
+
// their windows come from a quota plugin when CLIProxyAPI names one for
|
|
582
|
+
// the credential. Without one there is no recorded format to parse.
|
|
583
|
+
if (credential.quota_plugin_id !== undefined) {
|
|
584
|
+
return pluginWindows(
|
|
585
|
+
await request(
|
|
586
|
+
`/plugins/${encodeURIComponent(credential.quota_plugin_id)}/quota`,
|
|
587
|
+
PluginQuotaSchema,
|
|
588
|
+
{
|
|
589
|
+
body: JSON.stringify({ auth_index: credential.auth_index }),
|
|
590
|
+
method: "POST",
|
|
591
|
+
}
|
|
592
|
+
)
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Meta's quota endpoint reports no usage and returns an API key, so it is never called.
|
|
597
|
+
return [];
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
const { files } = await request("/credentials", CredentialsSchema);
|
|
601
|
+
const credentials = files.filter((credential) => !credential.disabled);
|
|
602
|
+
|
|
603
|
+
return Promise.all(
|
|
604
|
+
credentials.map(async (credential): Promise<ProviderUsage> => {
|
|
605
|
+
const [modelList, windows] = await Promise.all([
|
|
606
|
+
request(
|
|
607
|
+
`/credentials/models?name=${encodeURIComponent(credential.name)}`,
|
|
608
|
+
CredentialModelsSchema
|
|
609
|
+
),
|
|
610
|
+
windowsFor(credential).catch((): UsageWindow[] => []),
|
|
611
|
+
]);
|
|
612
|
+
|
|
613
|
+
const retryAt = isoTime(credential.next_retry_after);
|
|
614
|
+
|
|
615
|
+
const cooling =
|
|
616
|
+
credential.unavailable === true &&
|
|
617
|
+
retryAt !== undefined &&
|
|
618
|
+
retryAt > now
|
|
619
|
+
? retryAt
|
|
620
|
+
: undefined;
|
|
621
|
+
|
|
622
|
+
const full = fullUntil(windows);
|
|
623
|
+
|
|
624
|
+
const account: ProviderUsage = {
|
|
625
|
+
models: modelList.models.map((model) => model.id),
|
|
626
|
+
name: FOOTER_NAMES.get(credential.provider) ?? credential.provider,
|
|
627
|
+
provider: credential.provider,
|
|
628
|
+
windows,
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
// Kimi, xAI, and plugin windows were just fetched live, so they are
|
|
632
|
+
// observed now; only signal-derived windows (Claude, Codex, Devin)
|
|
633
|
+
// carry the server's recorded observation time. Branch in the same
|
|
634
|
+
// order as windowsFor: a claude/codex/devin credential that also
|
|
635
|
+
// names a quota plugin still uses its recorded signals.
|
|
636
|
+
const signalsBased =
|
|
637
|
+
credential.provider === "claude" ||
|
|
638
|
+
credential.provider === "codex" ||
|
|
639
|
+
credential.provider === "devin";
|
|
640
|
+
|
|
641
|
+
const fetchedLive =
|
|
642
|
+
!signalsBased &&
|
|
643
|
+
(credential.provider === "xai" ||
|
|
644
|
+
credential.provider.startsWith("kimi") ||
|
|
645
|
+
credential.quota_plugin_id !== undefined);
|
|
646
|
+
|
|
647
|
+
const observedAt = fetchedLive
|
|
648
|
+
? now
|
|
649
|
+
: isoTime(credential.quota?.observed_at);
|
|
650
|
+
|
|
651
|
+
if (observedAt !== undefined) {
|
|
652
|
+
account.observedAt = observedAt;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (cooling !== undefined || full !== undefined) {
|
|
656
|
+
account.exhaustedUntil = Math.max(cooling ?? 0, full ?? 0);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
return account;
|
|
660
|
+
})
|
|
661
|
+
);
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Resets CLIProxyAPI routing cooldowns on the server and returns the model
|
|
666
|
+
* ids the server reports as affected. With a model id, only the credentials
|
|
667
|
+
* that serve that model are reset; without one, every credential is reset.
|
|
668
|
+
* Credential `auth_index` values stay inside this function: callers pass a
|
|
669
|
+
* model id, never an index.
|
|
670
|
+
*/
|
|
671
|
+
export const resetCooldowns = async (
|
|
672
|
+
options: ResetCooldownsOptions
|
|
673
|
+
): Promise<string[]> => {
|
|
674
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
675
|
+
const modelId = options.modelId?.trim() || undefined;
|
|
676
|
+
const managementUrl = `${resolveEndpoints(options.baseUrl).rootUrl}/v8/management`;
|
|
677
|
+
|
|
678
|
+
const headers = {
|
|
679
|
+
Authorization: `Bearer ${options.managementKey}`,
|
|
680
|
+
"Content-Type": "application/json",
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
const request = async <Schema extends TSchema>(
|
|
684
|
+
route: string,
|
|
685
|
+
schema: Schema,
|
|
686
|
+
init: RequestInit = {}
|
|
687
|
+
): Promise<Static<Schema>> => {
|
|
688
|
+
const timeout = AbortSignal.timeout(
|
|
689
|
+
options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
const response = await fetchFn(`${managementUrl}${route}`, {
|
|
693
|
+
...init,
|
|
694
|
+
headers,
|
|
695
|
+
signal: options.signal
|
|
696
|
+
? AbortSignal.any([options.signal, timeout])
|
|
697
|
+
: timeout,
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
const [routePath] = route.split("?");
|
|
701
|
+
|
|
702
|
+
if (!response.ok) {
|
|
703
|
+
throw new Error(
|
|
704
|
+
`CLIProxyAPI management ${routePath} returned HTTP ${response.status}`
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const value = parseJson(schema, await response.text());
|
|
709
|
+
|
|
710
|
+
if (value === undefined) {
|
|
711
|
+
throw new Error(
|
|
712
|
+
`CLIProxyAPI management ${routePath} returned an unexpected shape`
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
return value;
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
const { files } = await request("/credentials", CredentialsSchema);
|
|
720
|
+
const credentials = files.filter((credential) => !credential.disabled);
|
|
721
|
+
|
|
722
|
+
const served = await Promise.all(
|
|
723
|
+
credentials.map(async (credential) => {
|
|
724
|
+
const modelList = await request(
|
|
725
|
+
`/credentials/models?name=${encodeURIComponent(credential.name)}`,
|
|
726
|
+
CredentialModelsSchema
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
return {
|
|
730
|
+
credential,
|
|
731
|
+
models: modelList.models.map((model) => model.id),
|
|
732
|
+
};
|
|
733
|
+
})
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
const targets =
|
|
737
|
+
modelId === undefined
|
|
738
|
+
? served
|
|
739
|
+
: served.filter((entry) => entry.models.includes(modelId));
|
|
740
|
+
|
|
741
|
+
if (targets.length === 0) {
|
|
742
|
+
return [];
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const results = await Promise.all(
|
|
746
|
+
targets.map(async ({ credential }) => {
|
|
747
|
+
const result = await request(
|
|
748
|
+
"/routing/cooldown/reset",
|
|
749
|
+
CooldownResetSchema,
|
|
750
|
+
{
|
|
751
|
+
body: JSON.stringify({ auth_index: credential.auth_index }),
|
|
752
|
+
method: "POST",
|
|
753
|
+
}
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
if (result.status !== "ok") {
|
|
757
|
+
throw new Error(
|
|
758
|
+
`CLIProxyAPI management /routing/cooldown/reset returned status ${result.status}`
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
return result.models;
|
|
763
|
+
})
|
|
764
|
+
);
|
|
765
|
+
|
|
766
|
+
return [...new Set(results.flat())];
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Models that no account can serve right now, with the time each comes back.
|
|
771
|
+
* A model served by several accounts stays available until all of them run out.
|
|
772
|
+
*/
|
|
773
|
+
export const unavailableModels = (
|
|
774
|
+
usage: readonly ProviderUsage[],
|
|
775
|
+
exhausted: Readonly<Record<string, number>>,
|
|
776
|
+
now = Date.now(),
|
|
777
|
+
ignoreAccountExhaustion?: (account: ProviderUsage, model: string) => boolean
|
|
778
|
+
): Map<string, number> => {
|
|
779
|
+
const hidden = new Map<string, number>();
|
|
780
|
+
const accountsByModel = new Map<string, ProviderUsage[]>();
|
|
781
|
+
|
|
782
|
+
for (const account of usage) {
|
|
783
|
+
for (const model of account.models) {
|
|
784
|
+
const accounts = accountsByModel.get(model);
|
|
785
|
+
|
|
786
|
+
if (accounts) {
|
|
787
|
+
accounts.push(account);
|
|
788
|
+
} else {
|
|
789
|
+
accountsByModel.set(model, [account]);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
for (const [model, accounts] of accountsByModel) {
|
|
795
|
+
const until = accounts.map((account) =>
|
|
796
|
+
ignoreAccountExhaustion?.(account, model)
|
|
797
|
+
? 0
|
|
798
|
+
: (account.exhaustedUntil ?? 0)
|
|
799
|
+
);
|
|
800
|
+
|
|
801
|
+
if (until.every((time) => time > now)) {
|
|
802
|
+
hidden.set(model, Math.min(...until));
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
for (const [model, until] of Object.entries(exhausted)) {
|
|
807
|
+
if (until > now) {
|
|
808
|
+
hidden.set(model, Math.max(hidden.get(model) ?? 0, until));
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
return hidden;
|
|
813
|
+
};
|