@juvio15/pi-cliproxyapi-provider 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,609 @@
1
+ import path from "node:path";
2
+
3
+ import type {
4
+ ExtensionAPI,
5
+ ExtensionContext,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { Type } from "typebox";
8
+ import type { Static } from "typebox";
9
+
10
+ import {
11
+ fetchUsage,
12
+ quotaResetFromError,
13
+ resetCooldowns as resetServerCooldowns,
14
+ unavailableModels,
15
+ } from "../core/index.ts";
16
+ import type { ProviderUsage } from "../core/index.ts";
17
+ import { loadCliproxyConfig, resolveExtensionConnection } from "./config.ts";
18
+ import { readJsonMemo, writeJsonAtomic } from "./files.ts";
19
+
20
+ /** Shared by every pi process, so a quota error in one agent hides the model in all of them. */
21
+ export const USAGE_FILE_NAME = "cliproxyapi-usage.json";
22
+ export const MANAGEMENT_KEY_ENV = "CLIPROXY_MANAGEMENT_KEY";
23
+
24
+ /** How old the shared usage snapshot may get before a session fetches a new one. */
25
+ const USAGE_TTL_MS = 2 * 60_000;
26
+ const STATUS_KEY = "cliproxyapi-usage";
27
+ const SAME_DAY_MS = 20 * 60 * 60_000;
28
+
29
+ const UsageWindowSchema = Type.Object({
30
+ label: Type.String(),
31
+ resetAt: Type.Optional(Type.Number()),
32
+ usedPercent: Type.Number(),
33
+ });
34
+
35
+ const ProviderUsageSchema = Type.Object({
36
+ exhaustedUntil: Type.Optional(Type.Number()),
37
+ models: Type.Array(Type.String()),
38
+ name: Type.String(),
39
+ observedAt: Type.Optional(Type.Number()),
40
+ provider: Type.String(),
41
+ windows: Type.Array(UsageWindowSchema),
42
+ });
43
+
44
+ const UsageFileSchema = Type.Object({
45
+ baseUrl: Type.Optional(Type.String()),
46
+ exhausted: Type.Record(Type.String(), Type.Number()),
47
+ fetchedAt: Type.Optional(Type.Number()),
48
+ providers: Type.Array(ProviderUsageSchema),
49
+ resets: Type.Optional(Type.Record(Type.String(), Type.Number())),
50
+ });
51
+
52
+ type UsageFile = Static<typeof UsageFileSchema>;
53
+
54
+ const EMPTY: UsageFile = { exhausted: {}, providers: [] };
55
+
56
+ export type ManagementKeySource = "env" | "cliproxyapi.json";
57
+
58
+ /** For /cliproxyapi-status. Holds where the key comes from, never the key. */
59
+ export interface ManagementStatus {
60
+ source: ManagementKeySource;
61
+ /** When usage was last fetched for the current base URL. */
62
+ usageFetchedAt?: number;
63
+ }
64
+
65
+ export interface UsageTrackerOptions {
66
+ agentDir: string;
67
+ providerId: string;
68
+ env?: Readonly<Record<string, string | undefined>>;
69
+ fetchFn?: typeof fetch;
70
+ }
71
+
72
+ const formatTime = (time: number, now: number): string => {
73
+ const options: Intl.DateTimeFormatOptions =
74
+ time - now < SAME_DAY_MS
75
+ ? { hour: "2-digit", hourCycle: "h23", minute: "2-digit" }
76
+ : {
77
+ hour: "2-digit",
78
+ hourCycle: "h23",
79
+ minute: "2-digit",
80
+ weekday: "short",
81
+ };
82
+
83
+ return new Intl.DateTimeFormat(undefined, options).format(time);
84
+ };
85
+
86
+ export class UsageTracker {
87
+ private readonly filePath: string;
88
+ private readonly options: UsageTrackerOptions;
89
+
90
+ constructor(options: UsageTrackerOptions) {
91
+ this.options = options;
92
+ this.filePath = path.join(options.agentDir, USAGE_FILE_NAME);
93
+ }
94
+
95
+ private connection() {
96
+ return resolveExtensionConnection({
97
+ agentDir: this.options.agentDir,
98
+ env: this.options.env ?? process.env,
99
+ providerId: this.options.providerId,
100
+ });
101
+ }
102
+
103
+ private managementKeyWithSource():
104
+ | { key: string; source: ManagementKeySource }
105
+ | undefined {
106
+ const env = this.options.env ?? process.env;
107
+ const fromEnv = env[MANAGEMENT_KEY_ENV]?.trim();
108
+
109
+ if (fromEnv) {
110
+ return { key: fromEnv, source: "env" };
111
+ }
112
+
113
+ const fromFile = loadCliproxyConfig(this.options.agentDir).managementKey;
114
+
115
+ return fromFile ? { key: fromFile, source: "cliproxyapi.json" } : undefined;
116
+ }
117
+
118
+ private managementKey(): string | undefined {
119
+ return this.managementKeyWithSource()?.key;
120
+ }
121
+
122
+ /**
123
+ * A missing or broken file counts as empty: it only holds a cache. The
124
+ * result is shared through the file memo, so callers build new objects
125
+ * instead of changing it.
126
+ */
127
+ private read(): UsageFile {
128
+ try {
129
+ return readJsonMemo(this.filePath, UsageFileSchema) ?? EMPTY;
130
+ } catch {
131
+ return EMPTY;
132
+ }
133
+ }
134
+
135
+ private write(update: (current: UsageFile) => UsageFile): void {
136
+ const now = Date.now();
137
+ const next = update(this.read());
138
+
139
+ const exhausted = Object.fromEntries(
140
+ Object.entries(next.exhausted).filter(([, until]) => until > now)
141
+ );
142
+
143
+ writeJsonAtomic(this.filePath, { ...next, exhausted });
144
+ }
145
+
146
+ /** Accounts from the last usage fetch for the current base URL. */
147
+ private providers(file: UsageFile): ProviderUsage[] {
148
+ return file.baseUrl === this.connection()?.baseUrl ? file.providers : [];
149
+ }
150
+
151
+ hasManagementKey(): boolean {
152
+ return this.managementKey() !== undefined;
153
+ }
154
+
155
+ /** The management key's source and the usage snapshot's time, or undefined without a key. */
156
+ managementStatus(): ManagementStatus | undefined {
157
+ const managed = this.managementKeyWithSource();
158
+
159
+ if (!managed) {
160
+ return undefined;
161
+ }
162
+
163
+ const file = this.read();
164
+
165
+ const usageFetchedAt =
166
+ file.baseUrl === this.connection()?.baseUrl ? file.fetchedAt : undefined;
167
+
168
+ return usageFetchedAt === undefined
169
+ ? { source: managed.source }
170
+ : { source: managed.source, usageFetchedAt };
171
+ }
172
+
173
+ /** Base URL and management key for management commands, or undefined when either is unavailable. */
174
+ managementAccess(): { baseUrl: string; managementKey: string } | undefined {
175
+ const managementKey = this.managementKey();
176
+ const connection = this.connection();
177
+
178
+ if (!managementKey || !connection) {
179
+ return undefined;
180
+ }
181
+
182
+ return { baseUrl: connection.baseUrl, managementKey };
183
+ }
184
+
185
+ /**
186
+ * Hidden models from one usage file. A reset clears the server cooldown
187
+ * but not its preserved quota observations, so an observation recorded
188
+ * before the reset must not hide the model again; a local quota-error
189
+ * mark (re)recorded after the reset still does.
190
+ */
191
+ private hiddenFrom(file: UsageFile, now: number): Map<string, number> {
192
+ const resets = file.resets ?? {};
193
+
194
+ // Discard each account's stale exhaustion on its own: one account
195
+ // observed after the reset must not let another account's pre-reset
196
+ // exhaustion hide a model the reset account can serve again. Local
197
+ // quota-error marks in file.exhausted are merged separately and still hide.
198
+ return unavailableModels(
199
+ this.providers(file),
200
+ file.exhausted,
201
+ now,
202
+ (account, model) => {
203
+ const resetAt = resets[model];
204
+
205
+ return (
206
+ resetAt !== undefined &&
207
+ (account.observedAt ?? file.fetchedAt ?? 0) <= resetAt
208
+ );
209
+ }
210
+ );
211
+ }
212
+
213
+ /** Model ids that no account can serve now, each with the time it comes back. */
214
+ hidden(now = Date.now()): Map<string, number> {
215
+ return this.hiddenFrom(this.read(), now);
216
+ }
217
+
218
+ filter<T extends { id: string }>(models: readonly T[]): T[] {
219
+ const hidden = this.hidden();
220
+
221
+ return models.filter((model) => !hidden.has(model.id));
222
+ }
223
+
224
+ /**
225
+ * True when the message was a quota error, which hides the model until its
226
+ * reset. Like CLIProxyAPI, a quota error covers the whole account, so its
227
+ * other models hide too. Claude is the exception: it also has per-model weekly
228
+ * limits, and its account-wide windows come from the usage fetch instead.
229
+ */
230
+ recordError(modelId: string, message: string): boolean {
231
+ const until = quotaResetFromError(message);
232
+
233
+ if (until === undefined) {
234
+ return false;
235
+ }
236
+
237
+ this.write((file) => {
238
+ const siblings = this.providers(file)
239
+ .filter(
240
+ (account) =>
241
+ account.provider !== "claude" && account.models.includes(modelId)
242
+ )
243
+ .flatMap((account) => account.models);
244
+
245
+ const exhausted = { ...file.exhausted };
246
+
247
+ for (const id of [modelId, ...siblings]) {
248
+ exhausted[id] = Math.max(exhausted[id] ?? 0, until);
249
+ }
250
+
251
+ return { ...file, exhausted };
252
+ });
253
+
254
+ return true;
255
+ }
256
+
257
+ /**
258
+ * Clears local quota marks from the shared exhausted map: one model, or
259
+ * every model when no id is given. Returns the model ids that were cleared.
260
+ */
261
+ clearExhausted(modelId?: string): string[] {
262
+ const cleared = Object.keys(this.read().exhausted).filter(
263
+ (id) => modelId === undefined || id === modelId
264
+ );
265
+
266
+ if (cleared.length === 0) {
267
+ return cleared;
268
+ }
269
+
270
+ this.write((file) => ({
271
+ ...file,
272
+ exhausted:
273
+ modelId === undefined
274
+ ? {}
275
+ : Object.fromEntries(
276
+ Object.entries(file.exhausted).filter(([id]) => id !== modelId)
277
+ ),
278
+ }));
279
+
280
+ return cleared;
281
+ }
282
+
283
+ /**
284
+ * Resets cooldowns on the server and clears the matching local quota
285
+ * marks, so pi stops hiding the models too. Returns the model ids the
286
+ * server reports as affected.
287
+ */
288
+ async resetCooldowns(modelId?: string): Promise<string[]> {
289
+ const managementKey = this.managementKey();
290
+ const connection = this.connection();
291
+
292
+ if (!managementKey) {
293
+ throw new Error(
294
+ `No CLIProxyAPI management key is configured. Set ${MANAGEMENT_KEY_ENV} in the environment or "managementKey" in cliproxyapi.json, then try again.`
295
+ );
296
+ }
297
+
298
+ if (!connection) {
299
+ throw new Error(
300
+ "CLIProxyAPI is not configured, so its cooldowns cannot be reset."
301
+ );
302
+ }
303
+
304
+ const affected = await resetServerCooldowns({
305
+ baseUrl: connection.baseUrl,
306
+ fetch: this.options.fetchFn,
307
+ managementKey,
308
+ modelId,
309
+ });
310
+
311
+ if (modelId === undefined) {
312
+ this.clearExhausted();
313
+ } else {
314
+ // A server reset clears the whole credential, so its other models
315
+ // come back too: recordError hid them locally alongside this one.
316
+ for (const id of new Set([modelId, ...affected])) {
317
+ this.clearExhausted(id);
318
+ }
319
+ }
320
+
321
+ // The server preserves quota observations across a reset; remember when
322
+ // each model was reset so hiddenFrom can ignore the older observations.
323
+ const resetAt = Date.now();
324
+ const resetIds = modelId === undefined ? affected : [modelId, ...affected];
325
+
326
+ if (resetIds.length > 0) {
327
+ this.write((file) => ({
328
+ ...file,
329
+ resets: {
330
+ ...file.resets,
331
+ ...Object.fromEntries(resetIds.map((id) => [id, resetAt])),
332
+ },
333
+ }));
334
+ }
335
+
336
+ return affected;
337
+ }
338
+
339
+ /** A reply proves the model works again. True when that cleared a quota mark. */
340
+ recordSuccess(modelId: string): boolean {
341
+ if (this.read().exhausted[modelId] === undefined) {
342
+ return false;
343
+ }
344
+
345
+ this.write((file) => ({
346
+ ...file,
347
+ exhausted: Object.fromEntries(
348
+ Object.entries(file.exhausted).filter(([id]) => id !== modelId)
349
+ ),
350
+ }));
351
+
352
+ return true;
353
+ }
354
+
355
+ /** Fetches usage when the shared snapshot is older than its TTL. Needs the management key. */
356
+ async refresh(force = false, signal?: AbortSignal): Promise<void> {
357
+ const managementKey = this.managementKey();
358
+ const connection = this.connection();
359
+
360
+ if (!managementKey || !connection) {
361
+ return;
362
+ }
363
+
364
+ const current = this.read();
365
+
366
+ if (
367
+ !force &&
368
+ current.baseUrl === connection.baseUrl &&
369
+ current.fetchedAt !== undefined &&
370
+ Date.now() - current.fetchedAt < USAGE_TTL_MS
371
+ ) {
372
+ return;
373
+ }
374
+
375
+ const providers = await fetchUsage({
376
+ baseUrl: connection.baseUrl,
377
+ fetch: this.options.fetchFn,
378
+ managementKey,
379
+ signal,
380
+ });
381
+
382
+ this.write((file) => ({
383
+ ...file,
384
+ baseUrl: connection.baseUrl,
385
+ fetchedAt: Date.now(),
386
+ providers,
387
+ }));
388
+ }
389
+
390
+ /** When the next hidden model comes back or the snapshot goes stale, whichever is first. */
391
+ nextChangeAt(now = Date.now()): number {
392
+ const returns = [...this.hidden(now).values()];
393
+ const fetchedAt = this.read().fetchedAt ?? now;
394
+ const stale = this.hasManagementKey() ? fetchedAt + USAGE_TTL_MS : Infinity;
395
+
396
+ return Math.max(now + 1000, Math.min(stale, ...returns));
397
+ }
398
+
399
+ /** One footer line for the accounts behind the given catalog, or undefined when nothing is known. */
400
+ footer(catalogIds: readonly string[], now = Date.now()): string | undefined {
401
+ const file = this.read();
402
+ const hidden = this.hiddenFrom(file, now);
403
+ const catalog = new Set(catalogIds);
404
+ const covered = new Set<string>();
405
+ const parts: string[] = [];
406
+
407
+ for (const account of this.providers(file)) {
408
+ const models = account.models.filter((model) => catalog.has(model));
409
+
410
+ if (models.length === 0) {
411
+ continue;
412
+ }
413
+
414
+ for (const model of models) {
415
+ covered.add(model);
416
+ }
417
+
418
+ const returns = models.map((model) => hidden.get(model));
419
+
420
+ if (returns.every((time) => time !== undefined)) {
421
+ parts.push(
422
+ `${account.name} out until ${formatTime(Math.min(...returns), now)}`
423
+ );
424
+ continue;
425
+ }
426
+
427
+ const windows = account.windows.map(
428
+ (window) => `${Math.round(window.usedPercent)}% ${window.label}`
429
+ );
430
+
431
+ parts.push(
432
+ `${account.name} ${windows.length > 0 ? windows.join(", ") : "n/a"}`
433
+ );
434
+ }
435
+
436
+ for (const [model, until] of hidden) {
437
+ if (catalog.has(model) && !covered.has(model)) {
438
+ parts.push(`${model} out until ${formatTime(until, now)}`);
439
+ }
440
+ }
441
+
442
+ return parts.length > 0 ? parts.join(" │ ") : undefined;
443
+ }
444
+ }
445
+
446
+ export interface RegisterUsageOptions {
447
+ providerId: string;
448
+ tracker: UsageTracker;
449
+ getCatalogIds: () => readonly string[];
450
+ }
451
+
452
+ /**
453
+ * Keeps pi's model list and the usage footer in step with the shared usage
454
+ * file: quota errors hide a model, replies and resets bring it back.
455
+ */
456
+ export interface UsageHandle {
457
+ /** Fetches usage now and applies it, for /cliproxyapi-refresh. */
458
+ refresh: (ctx: ExtensionContext) => Promise<void>;
459
+ }
460
+
461
+ export const registerUsage = (
462
+ pi: ExtensionAPI,
463
+ options: RegisterUsageOptions
464
+ ): UsageHandle => {
465
+ const { providerId, tracker } = options;
466
+ let active: ExtensionContext | undefined;
467
+ let timer: ReturnType<typeof setTimeout> | undefined;
468
+ let lastHidden = "";
469
+ let notifiedModel: string | undefined;
470
+ let lastWarning: string | undefined;
471
+
472
+ const warn = (ctx: ExtensionContext, message: string): void => {
473
+ if (ctx.hasUI && message !== lastWarning) {
474
+ lastWarning = message;
475
+ ctx.ui.notify(message, "warning");
476
+ }
477
+ };
478
+
479
+ const noticeCurrentModel = (
480
+ ctx: ExtensionContext,
481
+ hidden: ReadonlyMap<string, number>
482
+ ): void => {
483
+ const current = ctx.model;
484
+
485
+ const until =
486
+ current?.provider === providerId ? hidden.get(current.id) : undefined;
487
+
488
+ if (until === undefined || !current) {
489
+ notifiedModel = undefined;
490
+
491
+ return;
492
+ }
493
+
494
+ if (notifiedModel !== current.id) {
495
+ notifiedModel = current.id;
496
+ warn(
497
+ ctx,
498
+ `${current.id} is out of quota until ${formatTime(until, Date.now())}. Pick another model with /model.`
499
+ );
500
+ }
501
+ };
502
+
503
+ const sync = async (ctx: ExtensionContext): Promise<void> => {
504
+ const hidden = tracker.hidden();
505
+ const key = [...hidden.keys()].toSorted().join(",");
506
+
507
+ if (key !== lastHidden) {
508
+ lastHidden = key;
509
+ // Recomputes pi's available list through filterModels without a network call.
510
+ await ctx.modelRegistry.refresh({
511
+ allowNetwork: false,
512
+ providers: [providerId],
513
+ });
514
+ }
515
+
516
+ noticeCurrentModel(ctx, hidden);
517
+
518
+ if (!ctx.hasUI) {
519
+ return;
520
+ }
521
+
522
+ ctx.ui.setStatus(STATUS_KEY, tracker.footer(options.getCatalogIds()));
523
+ };
524
+
525
+ const update = async (
526
+ ctx: ExtensionContext,
527
+ force: boolean
528
+ ): Promise<void> => {
529
+ try {
530
+ await tracker.refresh(force);
531
+ } catch (error) {
532
+ warn(
533
+ ctx,
534
+ `CLIProxyAPI usage: ${error instanceof Error ? error.message : String(error)}`
535
+ );
536
+ }
537
+
538
+ try {
539
+ await sync(ctx);
540
+ } catch (error) {
541
+ warn(
542
+ ctx,
543
+ `CLIProxyAPI model list: ${error instanceof Error ? error.message : String(error)}`
544
+ );
545
+ }
546
+
547
+ if (!ctx.hasUI || !active) {
548
+ return;
549
+ }
550
+
551
+ if (timer) {
552
+ clearTimeout(timer);
553
+ }
554
+
555
+ // Wakes when a hidden model comes back or the usage snapshot goes stale.
556
+ timer = setTimeout(() => {
557
+ timer = undefined;
558
+
559
+ if (active) {
560
+ void update(active, false);
561
+ }
562
+ }, tracker.nextChangeAt() - Date.now());
563
+ timer.unref();
564
+ };
565
+
566
+ pi.on("message_end", async (event, ctx) => {
567
+ const { message } = event;
568
+
569
+ if (message.role !== "assistant" || message.provider !== providerId) {
570
+ return;
571
+ }
572
+
573
+ const changed =
574
+ message.stopReason === "error"
575
+ ? tracker.recordError(message.model, message.errorMessage ?? "")
576
+ : message.stopReason !== "aborted" &&
577
+ tracker.recordSuccess(message.model);
578
+
579
+ if (changed) {
580
+ await update(ctx, false);
581
+ }
582
+ });
583
+ pi.on("session_start", async (_event, ctx) => {
584
+ active = ctx;
585
+ await update(ctx, false);
586
+ });
587
+ pi.on("agent_end", async (_event, ctx) => {
588
+ await update(ctx, false);
589
+ });
590
+ pi.on("model_select", (_event, ctx) => {
591
+ noticeCurrentModel(ctx, tracker.hidden());
592
+ });
593
+ pi.on("session_shutdown", (_event, ctx) => {
594
+ active = undefined;
595
+ lastHidden = "";
596
+ notifiedModel = undefined;
597
+
598
+ if (timer) {
599
+ clearTimeout(timer);
600
+ timer = undefined;
601
+ }
602
+
603
+ if (ctx.hasUI) {
604
+ ctx.ui.setStatus(STATUS_KEY, undefined);
605
+ }
606
+ });
607
+
608
+ return { refresh: (ctx) => update(ctx, true) };
609
+ };