@juvio15/pi-cliproxyapi-provider 0.1.2 → 0.1.3

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/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # pi CLIProxyAPI provider
2
+
3
+ The pi CLIProxyAPI provider is an extension for the [pi](https://github.com/earendil-works/pi) coding agent that lets pi use every model on a [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) server. CLIProxyAPI is a local server that puts your Claude, Codex, Kimi, Grok, and other accounts behind one API key.
4
+
5
+ The extension sends each model through the API that suits it. Claude and Kimi models use the Anthropic Messages API, so cache marks and Anthropic options reach the model. Every other model uses the OpenAI Responses API.
6
+
7
+ ## What you get
8
+
9
+ - A model list that comes from your CLIProxyAPI server, with the context windows and reasoning levels the server reports, and prices from [models.dev](https://models.dev)
10
+ - A `web_search` tool that runs searches through CLIProxyAPI and estimates what each search cost, when the server reports token usage
11
+ - A footer that shows tokens per second, and account usage if you add a management key
12
+ - Commands for Fast mode, pausing requests, and adding upstream accounts
13
+
14
+ ## Requirements
15
+
16
+ - Node.js 22.19 or later
17
+ - pi 0.82 or later
18
+ - A running CLIProxyAPI server and an API key for it
19
+
20
+ ## Install
21
+
22
+ Install the package with pi:
23
+
24
+ ```sh
25
+ pi install npm:@juvio15/pi-cliproxyapi-provider
26
+ ```
27
+
28
+ Then start pi and run `/login cliproxyapi`. Pi asks for the server address and the API key, and the extension checks them by loading the model list. If you leave the address empty, the extension uses `http://127.0.0.1:8317`, which is the CLIProxyAPI default.
29
+
30
+ ## Connect without logging in
31
+
32
+ You can set the connection in environment variables instead, which is useful when you run pi in scripts or other programs that have no login screen:
33
+
34
+ ```sh
35
+ export CLIPROXYAPI_BASE_URL=http://127.0.0.1:8317
36
+ export CLIPROXYAPI_API_KEY=your-key
37
+ ```
38
+
39
+ The extension looks for each value in the environment first, then in `~/.pi/agent/cliproxyapi.json`, and then in the `/login` credentials. The address can be the server root or any of its API paths, e.g., `http://host:8317/v1`.
40
+
41
+ ## Commands
42
+
43
+ | Command | What it does |
44
+ | --- | --- |
45
+ | `/login cliproxyapi` | Saves the server address and API key |
46
+ | `/cliproxyapi-status` | Shows the connection, model count, prices, and settings |
47
+ | `/cliproxyapi-refresh` | Loads the model list from the server again |
48
+ | `/cliproxyapi-login <provider>` | Adds an upstream account to CLIProxyAPI, e.g., `claude` or `codex` |
49
+ | `/cliproxyapi-reset [model-id]` | Clears CLIProxyAPI cooldowns for every account, or only for the accounts that serve one model |
50
+ | `/fast` | Turns Fast mode on or off for models that support it |
51
+ | `/pause` and `/continue` | Holds provider requests until you continue |
52
+ | `/websearch-model [model-id\|auto]` | Picks the model that `web_search` uses |
53
+
54
+ The extension caches the model list for 10 minutes. It hides models that the server no longer lists, and models whose accounts have hit their usage limits.
55
+
56
+ ## Settings file
57
+
58
+ You can put settings in `~/.pi/agent/cliproxyapi.json`. Every field is optional:
59
+
60
+ ```json
61
+ {
62
+ "baseUrl": "http://127.0.0.1:8317",
63
+ "apiKey": "your-key",
64
+ "managementKey": "your-management-key",
65
+ "fast": false,
66
+ "pause": false,
67
+ "searchModel": "<model-id>",
68
+ "thinkingDisplay": { "<model-id>": "summarized" },
69
+ "providerId": "cliproxyapi",
70
+ "providerName": "CLIProxyAPI"
71
+ }
72
+ ```
73
+
74
+ The fields work as follows:
75
+
76
+ - `managementKey` is the CLIProxyAPI management key. It turns on the usage footer, `/cliproxyapi-login`, and `/cliproxyapi-reset`. You can also set it with the `CLIPROXY_MANAGEMENT_KEY` environment variable.
77
+ - `thinkingDisplay` sets, per model, whether the model sends thought summaries. It applies to models that use the Anthropic Messages API with thinking turned on. The default is `omitted`, because Claude streams its answer only when summaries are off. Set a model to `summarized` to see its summaries.
78
+ - `searchModel` sets the model for `web_search`. When it's empty, the extension picks one.
79
+ - `providerId` is the provider ID that pi uses in model names, e.g., `cliproxyapi/<model-id>`, and in `/login`. If you change it, pi looks for the login under the new ID, so you need to log in again. `providerName` is the name pi shows. The environment variables `CLIPROXYAPI_PROVIDER_ID` and `CLIPROXYAPI_PROVIDER_NAME` do the same.
80
+
81
+ ## Use the model mapping outside pi
82
+
83
+ The files in `src/core` don't import `pi-coding-agent`, and they don't read files or environment variables on their own. Programs built on `pi-ai` can import them to load the catalog, pick an API per model, and build a provider. The list of exported functions is at the top of `src/core/index.ts`.
84
+
85
+ ## Development
86
+
87
+ Install the dependencies and run the checks:
88
+
89
+ ```sh
90
+ npm install
91
+ npm run typecheck
92
+ npm run check
93
+ npm test
94
+ ```
95
+
96
+ The scripts in `test/e2e` call a live CLIProxyAPI server, and `npm test` doesn't run them. Run one by hand, e.g., `node test/e2e/catalog.ts`. They use `CLIPROXYAPI_BASE_URL` and `CLIPROXYAPI_API_KEY`, or your pi login if those aren't set.
97
+
98
+ ## License
99
+
100
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juvio15/pi-cliproxyapi-provider",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "pi provider for CLIProxyAPI: per-family APIs, model catalog, costs, web search, usage footer",
5
5
  "keywords": [
6
6
  "cliproxyapi",
package/src/core/index.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * withoutStale<T extends object>(models: readonly T[]): T[]
17
17
  * wrapCliproxyStreams(streams: ProviderStreams, settings: PayloadSettings): ProviderStreams
18
18
  * createCliproxyProvider(options: CliproxyProviderOptions): Provider<CliproxyApi>
19
- * quotaResetFromError(message: string, now?: number): number | undefined
19
+ * quotaResetFromError(message: string, now?: number, fallbackAt?: number): number | undefined
20
20
  * startOAuth(options: StartOAuthOptions): Promise<OAuthStart>
21
21
  * submitOAuthCallback(options: SubmitOAuthCallbackOptions): Promise<OAuthStatus>
22
22
  * pollOAuthStatus(options: PollOAuthStatusOptions): Promise<OAuthStatus>
package/src/core/usage.ts CHANGED
@@ -46,11 +46,11 @@ const SECOND_MS = 1000;
46
46
  const MINUTE_MS = 60 * SECOND_MS;
47
47
  const FULL_PERCENT = 100;
48
48
 
49
- /** Wait this long when a quota error names no reset time. */
49
+ /** Wait this long when a quota error names no reset time and nothing else knows it. */
50
50
  export const QUOTA_FALLBACK_MS = 15 * MINUTE_MS;
51
51
 
52
52
  const QUOTA_ERROR =
53
- /quota exhausted|subscription quota|usage[ _]limit|usage window|insufficient_quota|rate limit reached/iu;
53
+ /quota exhausted|subscription quota|usage[ _]limit|usage window|insufficient_quota|rate limit reached|exceed your account's rate limit/iu;
54
54
 
55
55
  const RESETS_AT_EPOCH = /"resets_at"\s*:\s*(?<seconds>\d{9,})/u;
56
56
  const RESETS_AT_ISO = /resets at (?<time>\d{4}-\d{2}-\d{2}T[\d:.]+Z)/iu;
@@ -77,11 +77,14 @@ const isoTime = (value: string | undefined): number | undefined => {
77
77
 
78
78
  /**
79
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.
80
+ * epoch milliseconds when it resets. Undefined for every other error. A message
81
+ * that names no reset time resets at `fallbackAt`, such as the reset of the
82
+ * account's full usage window.
81
83
  */
82
84
  export const quotaResetFromError = (
83
85
  message: string,
84
- now = Date.now()
86
+ now = Date.now(),
87
+ fallbackAt = now + QUOTA_FALLBACK_MS
85
88
  ): number | undefined => {
86
89
  if (!QUOTA_ERROR.test(message)) {
87
90
  return undefined;
@@ -99,8 +102,7 @@ export const quotaResetFromError = (
99
102
  ];
100
103
 
101
104
  return (
102
- candidates.find((time) => time !== undefined && time > now) ??
103
- now + QUOTA_FALLBACK_MS
105
+ candidates.find((time) => time !== undefined && time > now) ?? fallbackAt
104
106
  );
105
107
  };
106
108
 
@@ -8,6 +8,7 @@ import { Type } from "typebox";
8
8
  import type { Static } from "typebox";
9
9
 
10
10
  import {
11
+ QUOTA_FALLBACK_MS,
11
12
  fetchUsage,
12
13
  quotaResetFromError,
13
14
  resetCooldowns as resetServerCooldowns,
@@ -86,6 +87,11 @@ const formatTime = (time: number, now: number): string => {
86
87
  export class UsageTracker {
87
88
  private readonly filePath: string;
88
89
  private readonly options: UsageTrackerOptions;
90
+ /** Quota marks this tracker guessed, by model id, until a usage fetch names the real reset. */
91
+ private readonly guessedMarks = new Map<
92
+ string,
93
+ { recordedAt: number; until: number }
94
+ >();
89
95
 
90
96
  constructor(options: UsageTrackerOptions) {
91
97
  this.options = options;
@@ -226,14 +232,28 @@ export class UsageTracker {
226
232
  * reset. Like CLIProxyAPI, a quota error covers the whole account, so its
227
233
  * other models hide too. Claude is the exception: it also has per-model weekly
228
234
  * limits, and its account-wide windows come from the usage fetch instead.
235
+ *
236
+ * The mark is written at once, without a network call. When the message
237
+ * names no reset time, as Claude's account rate limit does, the mark is a
238
+ * guess: the reset of the account's full window in the snapshot on disk,
239
+ * else 15 minutes. The next usage fetch replaces the guess with the reset
240
+ * it reports, unless the mark changed in between, such as by a reset.
229
241
  */
230
242
  recordError(modelId: string, message: string): boolean {
231
- const until = quotaResetFromError(message);
243
+ const now = Date.now();
244
+
245
+ const guess =
246
+ this.accountResets(this.read(), now).get(modelId) ??
247
+ now + QUOTA_FALLBACK_MS;
248
+
249
+ const until = quotaResetFromError(message, now, guess);
232
250
 
233
251
  if (until === undefined) {
234
252
  return false;
235
253
  }
236
254
 
255
+ const marked: string[] = [];
256
+
237
257
  this.write((file) => {
238
258
  const siblings = this.providers(file)
239
259
  .filter(
@@ -245,15 +265,32 @@ export class UsageTracker {
245
265
  const exhausted = { ...file.exhausted };
246
266
 
247
267
  for (const id of [modelId, ...siblings]) {
248
- exhausted[id] = Math.max(exhausted[id] ?? 0, until);
268
+ if ((exhausted[id] ?? 0) < until) {
269
+ exhausted[id] = until;
270
+ marked.push(id);
271
+ }
249
272
  }
250
273
 
251
274
  return { ...file, exhausted };
252
275
  });
253
276
 
277
+ if (until === guess) {
278
+ for (const id of marked) {
279
+ this.guessedMarks.set(id, { recordedAt: now, until });
280
+ }
281
+ }
282
+
254
283
  return true;
255
284
  }
256
285
 
286
+ /**
287
+ * Each model whose accounts are all full, with the time the first comes
288
+ * back. Local quota marks don't count.
289
+ */
290
+ private accountResets(file: UsageFile, now: number): Map<string, number> {
291
+ return this.hiddenFrom({ ...file, exhausted: {} }, now);
292
+ }
293
+
257
294
  /**
258
295
  * Clears local quota marks from the shared exhausted map: one model, or
259
296
  * every model when no id is given. Returns the model ids that were cleared.
@@ -372,6 +409,8 @@ export class UsageTracker {
372
409
  return;
373
410
  }
374
411
 
412
+ const startedAt = Date.now();
413
+
375
414
  const providers = await fetchUsage({
376
415
  baseUrl: connection.baseUrl,
377
416
  fetch: this.options.fetchFn,
@@ -379,12 +418,36 @@ export class UsageTracker {
379
418
  signal,
380
419
  });
381
420
 
382
- this.write((file) => ({
383
- ...file,
384
- baseUrl: connection.baseUrl,
385
- fetchedAt: Date.now(),
386
- providers,
387
- }));
421
+ this.write((file) => {
422
+ const fetchedAt = Date.now();
423
+
424
+ const next: UsageFile = {
425
+ ...file,
426
+ baseUrl: connection.baseUrl,
427
+ fetchedAt,
428
+ providers,
429
+ };
430
+
431
+ const resets = this.accountResets(next, fetchedAt);
432
+ const exhausted = { ...next.exhausted };
433
+
434
+ // A fetch that started before a guess was made can't know its reset.
435
+ for (const [id, guessed] of this.guessedMarks) {
436
+ if (guessed.recordedAt > startedAt) {
437
+ continue;
438
+ }
439
+
440
+ this.guessedMarks.delete(id);
441
+ const reset = resets.get(id);
442
+
443
+ // A mark that changed since the guess, or was cleared, is not ours to move.
444
+ if (reset !== undefined && exhausted[id] === guessed.until) {
445
+ exhausted[id] = reset;
446
+ }
447
+ }
448
+
449
+ return { ...next, exhausted };
450
+ });
388
451
  }
389
452
 
390
453
  /** When the next hidden model comes back or the snapshot goes stale, whichever is first. */
@@ -522,28 +585,33 @@ export const registerUsage = (
522
585
  ctx.ui.setStatus(STATUS_KEY, tracker.footer(options.getCatalogIds()));
523
586
  };
524
587
 
525
- const update = async (
526
- ctx: ExtensionContext,
527
- force: boolean
528
- ): Promise<void> => {
588
+ /** Applies the usage file to pi without a network call. */
589
+ const syncNow = async (ctx: ExtensionContext): Promise<void> => {
529
590
  try {
530
- await tracker.refresh(force);
591
+ await sync(ctx);
531
592
  } catch (error) {
532
593
  warn(
533
594
  ctx,
534
- `CLIProxyAPI usage: ${error instanceof Error ? error.message : String(error)}`
595
+ `CLIProxyAPI model list: ${error instanceof Error ? error.message : String(error)}`
535
596
  );
536
597
  }
598
+ };
537
599
 
600
+ const update = async (
601
+ ctx: ExtensionContext,
602
+ force: boolean
603
+ ): Promise<void> => {
538
604
  try {
539
- await sync(ctx);
605
+ await tracker.refresh(force);
540
606
  } catch (error) {
541
607
  warn(
542
608
  ctx,
543
- `CLIProxyAPI model list: ${error instanceof Error ? error.message : String(error)}`
609
+ `CLIProxyAPI usage: ${error instanceof Error ? error.message : String(error)}`
544
610
  );
545
611
  }
546
612
 
613
+ await syncNow(ctx);
614
+
547
615
  if (!ctx.hasUI || !active) {
548
616
  return;
549
617
  }
@@ -570,15 +638,24 @@ export const registerUsage = (
570
638
  return;
571
639
  }
572
640
 
641
+ const quotaError =
642
+ message.stopReason === "error" &&
643
+ tracker.recordError(message.model, message.errorMessage ?? "");
644
+
573
645
  const changed =
574
- message.stopReason === "error"
575
- ? tracker.recordError(message.model, message.errorMessage ?? "")
576
- : message.stopReason !== "aborted" &&
577
- tracker.recordSuccess(message.model);
646
+ quotaError ||
647
+ (message.stopReason !== "error" &&
648
+ message.stopReason !== "aborted" &&
649
+ tracker.recordSuccess(message.model));
578
650
 
579
- if (changed) {
580
- await update(ctx, false);
651
+ if (!changed) {
652
+ return;
581
653
  }
654
+
655
+ // pi awaits this handler before it retries, so only the local change
656
+ // waits. The usage fetch runs after it and names a guessed reset.
657
+ await syncNow(ctx);
658
+ void update(ctx, quotaError);
582
659
  });
583
660
  pi.on("session_start", async (_event, ctx) => {
584
661
  active = ctx;