@juvio15/pi-cliproxyapi-provider 0.1.1 → 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 +100 -0
- package/package.json +1 -1
- package/src/core/catalog.ts +7 -0
- package/src/core/index.ts +10 -2
- package/src/core/usage.ts +8 -6
- package/src/extension/index.ts +10 -5
- package/src/extension/usage.ts +99 -22
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
package/src/core/catalog.ts
CHANGED
|
@@ -368,6 +368,13 @@ export const fetchCatalog = async (
|
|
|
368
368
|
return "models" in payload ? payload.models : payload.data;
|
|
369
369
|
};
|
|
370
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Drops models the latest catalog no longer lists. mergeCatalog keeps them so
|
|
373
|
+
* a session that uses one still resolves, but nobody should pick one anew.
|
|
374
|
+
*/
|
|
375
|
+
export const withoutStale = <T extends object>(models: readonly T[]): T[] =>
|
|
376
|
+
models.filter((model) => !("stale" in model && model.stale === true));
|
|
377
|
+
|
|
371
378
|
export const mergeCatalog = (
|
|
372
379
|
previous: readonly CliproxyModel[],
|
|
373
380
|
fresh: readonly CliproxyModel[],
|
package/src/core/index.ts
CHANGED
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
* matchModelCost(modelId: string, catalog: CostCatalog, fast?: boolean): ModelCost
|
|
14
14
|
* mapCatalog(entries: readonly CatalogModel[], options: MappingOptions): MappedCatalog
|
|
15
15
|
* mergeCatalog(previous: readonly CliproxyModel[], fresh: readonly CliproxyModel[], options?: MergeOptions): CliproxyModel[]
|
|
16
|
+
* withoutStale<T extends object>(models: readonly T[]): T[]
|
|
16
17
|
* wrapCliproxyStreams(streams: ProviderStreams, settings: PayloadSettings): ProviderStreams
|
|
17
18
|
* createCliproxyProvider(options: CliproxyProviderOptions): Provider<CliproxyApi>
|
|
18
|
-
* quotaResetFromError(message: string, now?: number): number | undefined
|
|
19
|
+
* quotaResetFromError(message: string, now?: number, fallbackAt?: number): number | undefined
|
|
19
20
|
* startOAuth(options: StartOAuthOptions): Promise<OAuthStart>
|
|
20
21
|
* submitOAuthCallback(options: SubmitOAuthCallbackOptions): Promise<OAuthStatus>
|
|
21
22
|
* pollOAuthStatus(options: PollOAuthStatusOptions): Promise<OAuthStatus>
|
|
@@ -32,7 +33,12 @@ import {
|
|
|
32
33
|
import { createProvider } from "@earendil-works/pi-ai/models";
|
|
33
34
|
import type { Provider } from "@earendil-works/pi-ai/models";
|
|
34
35
|
|
|
35
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
fetchCatalog,
|
|
38
|
+
mapCatalog,
|
|
39
|
+
mergeCatalog,
|
|
40
|
+
withoutStale,
|
|
41
|
+
} from "./catalog.ts";
|
|
36
42
|
import type { CliproxyApi, CliproxyModel, MappingOptions } from "./catalog.ts";
|
|
37
43
|
import { resolveEndpoints } from "./connection.ts";
|
|
38
44
|
import { fetchCosts } from "./costs.ts";
|
|
@@ -46,6 +52,7 @@ export {
|
|
|
46
52
|
mapCatalog,
|
|
47
53
|
mergeCatalog,
|
|
48
54
|
selectModelApi,
|
|
55
|
+
withoutStale,
|
|
49
56
|
type CatalogFetchOptions,
|
|
50
57
|
type CatalogModel,
|
|
51
58
|
type CliproxyApi,
|
|
@@ -180,6 +187,7 @@ export const createCliproxyProvider = (
|
|
|
180
187
|
staleTtlMs: options.staleTtlMs,
|
|
181
188
|
});
|
|
182
189
|
},
|
|
190
|
+
filterModels: (models) => withoutStale(models),
|
|
183
191
|
id,
|
|
184
192
|
models: options.models ?? [],
|
|
185
193
|
name: options.providerName ?? "CLIProxyAPI",
|
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
|
|
package/src/extension/index.ts
CHANGED
|
@@ -4,7 +4,11 @@ import type {
|
|
|
4
4
|
ExtensionContext,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
resolveConnectionSources,
|
|
9
|
+
resolveEndpoints,
|
|
10
|
+
withoutStale,
|
|
11
|
+
} from "../core/index.ts";
|
|
8
12
|
import type { CliproxyModel } from "../core/index.ts";
|
|
9
13
|
import { completeFrom } from "./completions.ts";
|
|
10
14
|
import {
|
|
@@ -140,7 +144,7 @@ const registerCatalogCommands = (
|
|
|
140
144
|
const models = controller.getModels();
|
|
141
145
|
const stale = models.filter((model) => model.stale).length;
|
|
142
146
|
ctx.ui.notify(
|
|
143
|
-
`Refreshed ${models.length} CLIProxyAPI models${stale ?
|
|
147
|
+
`Refreshed ${models.length - stale} CLIProxyAPI models${stale ? `. Hid ${stale} that CLIProxyAPI no longer lists` : ""}.`,
|
|
144
148
|
"info"
|
|
145
149
|
);
|
|
146
150
|
} catch (error) {
|
|
@@ -229,7 +233,7 @@ export default async function cliproxyapiProvider(
|
|
|
229
233
|
configured: connection !== null,
|
|
230
234
|
connection: initialConnection,
|
|
231
235
|
fast,
|
|
232
|
-
filterAvailable: (models) => tracker.filter(models),
|
|
236
|
+
filterAvailable: (models) => tracker.filter(withoutStale(models)),
|
|
233
237
|
onModels: (models) => {
|
|
234
238
|
latestModels = models;
|
|
235
239
|
|
|
@@ -370,8 +374,9 @@ export default async function cliproxyapiProvider(
|
|
|
370
374
|
});
|
|
371
375
|
registerWebSearch(pi, {
|
|
372
376
|
agentDir,
|
|
373
|
-
getAvailableModels: () =>
|
|
374
|
-
|
|
377
|
+
getAvailableModels: () =>
|
|
378
|
+
tracker.filter(withoutStale(controller.getModels())),
|
|
379
|
+
getModels: () => withoutStale(controller.getModels()),
|
|
375
380
|
getStandardCost: controller.getStandardCost,
|
|
376
381
|
providerId,
|
|
377
382
|
});
|
package/src/extension/usage.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
526
|
-
|
|
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
|
|
591
|
+
await sync(ctx);
|
|
531
592
|
} catch (error) {
|
|
532
593
|
warn(
|
|
533
594
|
ctx,
|
|
534
|
-
`CLIProxyAPI
|
|
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
|
|
605
|
+
await tracker.refresh(force);
|
|
540
606
|
} catch (error) {
|
|
541
607
|
warn(
|
|
542
608
|
ctx,
|
|
543
|
-
`CLIProxyAPI
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
646
|
+
quotaError ||
|
|
647
|
+
(message.stopReason !== "error" &&
|
|
648
|
+
message.stopReason !== "aborted" &&
|
|
649
|
+
tracker.recordSuccess(message.model));
|
|
578
650
|
|
|
579
|
-
if (changed) {
|
|
580
|
-
|
|
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;
|