pi-claude-billing-router 0.1.0 → 0.1.2
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 +4 -1
- package/lib/subscription-guard.ts +62 -0
- package/lib/usage-status.ts +38 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,7 +67,10 @@ and re-shaped on every request.
|
|
|
67
67
|
|
|
68
68
|
Percentages are **used** capacity. Colors: green < 70%, yellow 70–89%, red 90%+.
|
|
69
69
|
Error states (`login needed`, `⚠ HTTP 400`, `(stale)`, `usage n/a`) are
|
|
70
|
-
inherited from the upstream connector.
|
|
70
|
+
inherited from the upstream connector. A failed usage read (Anthropic's usage
|
|
71
|
+
endpoint intermittently answers 429) is retried automatically with backoff
|
|
72
|
+
(10s → 20s → 40s → 60s, or `Retry-After` when sent) instead of waiting for the
|
|
73
|
+
next model response. `⚠ extra usage` only appears when a
|
|
71
74
|
request billed extra usage **while the router intended the plan** — i.e. the
|
|
72
75
|
Claude Code shaping is broken, not when credits were chosen on purpose.
|
|
73
76
|
|
|
@@ -1347,6 +1347,54 @@ function stripAliasStubs(payload: Record<string, unknown>): Record<string, unkno
|
|
|
1347
1347
|
return { ...payload, tools: kept };
|
|
1348
1348
|
}
|
|
1349
1349
|
|
|
1350
|
+
// Project request-local transcript state BEFORE providers serialize/cache/seal it.
|
|
1351
|
+
// In particular pi-background-tasks protects both cache positions and lineage;
|
|
1352
|
+
// changing the serialized tool list in before_provider_request is too late.
|
|
1353
|
+
function transformTranscript(messages: unknown[], plan: boolean, disableFilter: boolean): unknown[] {
|
|
1354
|
+
const active = new Map<string, Record<string, unknown>>();
|
|
1355
|
+
let previous = new Map<string, Record<string, unknown>>();
|
|
1356
|
+
return messages.map((message) => {
|
|
1357
|
+
if (!isPlainObject(message)) return message;
|
|
1358
|
+
if (message.role !== "system") {
|
|
1359
|
+
if (!plan || message.role !== "assistant" || !Array.isArray(message.content)) return message;
|
|
1360
|
+
const content = remapBlockNames(message.content, "toolCall", (name) => {
|
|
1361
|
+
const alias = FLAT_TO_MCP.get(lower(name));
|
|
1362
|
+
return alias && registeredMcpAliases.has(lower(alias)) ? alias : undefined;
|
|
1363
|
+
});
|
|
1364
|
+
return content === message.content ? message : { ...message, content };
|
|
1365
|
+
}
|
|
1366
|
+
if (Array.isArray(message.toolsRemoved)) {
|
|
1367
|
+
for (const tool of message.toolsRemoved) {
|
|
1368
|
+
if (isPlainObject(tool) && typeof tool.name === "string") active.delete(lower(tool.name));
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
if (Array.isArray(message.toolsAdded)) {
|
|
1372
|
+
for (const tool of message.toolsAdded) {
|
|
1373
|
+
if (isPlainObject(tool) && typeof tool.name === "string") active.set(lower(tool.name), tool);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
const tools = [...active.values()];
|
|
1377
|
+
const projected = plan
|
|
1378
|
+
? filterAndRemapTools(tools, disableFilter)!
|
|
1379
|
+
: (stripAliasStubs({ tools }).tools as unknown[]);
|
|
1380
|
+
const next = collectToolsByName(projected);
|
|
1381
|
+
const toolsRemoved = [...previous.keys()].filter((name) => !next.has(name))
|
|
1382
|
+
.map((name) => ({ name: previous.get(name)!.name }));
|
|
1383
|
+
const toolsAdded = [...next.entries()].filter(([name, tool]) =>
|
|
1384
|
+
JSON.stringify(previous.get(name)) !== JSON.stringify(tool)).map(([, tool]) => tool);
|
|
1385
|
+
previous = next;
|
|
1386
|
+
const result: Record<string, unknown> = { ...message, toolsAdded, toolsRemoved };
|
|
1387
|
+
if (plan) {
|
|
1388
|
+
result.content = rewriteSystemField(message.content);
|
|
1389
|
+
if (isPlainObject(message.sections)) {
|
|
1390
|
+
result.sections = Object.fromEntries(Object.entries(message.sections).map(([key, value]) =>
|
|
1391
|
+
[key, typeof value === "string" ? rewritePromptText(value) : value]));
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
return result;
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1350
1398
|
// ============================================================================
|
|
1351
1399
|
// Extension entry point
|
|
1352
1400
|
// ============================================================================
|
|
@@ -1377,6 +1425,18 @@ export default async function piClaudeCodeUse(pi: ExtensionAPI): Promise<void> {
|
|
|
1377
1425
|
return { message: rewritten as typeof event.message };
|
|
1378
1426
|
});
|
|
1379
1427
|
|
|
1428
|
+
pi.on("context_with_system", (event, ctx) => {
|
|
1429
|
+
const model = ctx.model;
|
|
1430
|
+
if (model?.provider !== "anthropic" || !ctx.modelRegistry.isUsingOAuth(model)) return;
|
|
1431
|
+
// Refresh before serialization, including tools registered after our
|
|
1432
|
+
// before_agent_start handler. Keep the existing best-effort semantics.
|
|
1433
|
+
try {
|
|
1434
|
+
registerMcpAliases(pi, { cwd: typeof ctx.cwd === "string" ? ctx.cwd : undefined });
|
|
1435
|
+
} catch { /* The existing aliases remain usable. */ }
|
|
1436
|
+
return { messages: transformTranscript(event.messages, useSubscription(),
|
|
1437
|
+
process.env.PI_CLAUDE_CODE_USE_DISABLE_TOOL_FILTER === "1") as typeof event.messages };
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1380
1440
|
pi.on("before_provider_request", (event, ctx) => {
|
|
1381
1441
|
const model = ctx.model;
|
|
1382
1442
|
if (model?.provider !== "anthropic" || !ctx.modelRegistry.isUsingOAuth(model)) {
|
|
@@ -1452,6 +1512,8 @@ export const _test = {
|
|
|
1452
1512
|
},
|
|
1453
1513
|
syncAliasActivation,
|
|
1454
1514
|
transformPayload,
|
|
1515
|
+
transformTranscript,
|
|
1516
|
+
stripAliasStubs,
|
|
1455
1517
|
unaliasDisplayedProse,
|
|
1456
1518
|
unaliasToolCalls,
|
|
1457
1519
|
};
|
package/lib/usage-status.ts
CHANGED
|
@@ -34,6 +34,10 @@ export const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
|
34
34
|
export const OPENAI_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
35
35
|
export const POLL_MIN_INTERVAL_MS = 30_000;
|
|
36
36
|
export const IDLE_REFRESH_MS = 5 * 60_000;
|
|
37
|
+
// Anthropic's usage endpoint intermittently answers 429; retry quickly with
|
|
38
|
+
// backoff instead of showing "usage n/a" until the idle timer fires.
|
|
39
|
+
export const RETRY_BASE_MS = 10_000;
|
|
40
|
+
export const RETRY_MAX_MS = 60_000;
|
|
37
41
|
export const OVERAGE_HEADER = "anthropic-ratelimit-unified-overage-in-use";
|
|
38
42
|
const CONFLICT_PACKAGE = "@benvargas/pi-claude-code-use";
|
|
39
43
|
// PCBR: the package this one was forked from must not run alongside it either.
|
|
@@ -214,6 +218,13 @@ export function shouldPoll(lastPollMs: number | undefined, nowMs: number, minInt
|
|
|
214
218
|
return lastPollMs === undefined || nowMs - lastPollMs >= minIntervalMs;
|
|
215
219
|
}
|
|
216
220
|
|
|
221
|
+
/** Delay before retrying a failed usage fetch: honors Retry-After, else exponential backoff. */
|
|
222
|
+
export function retryDelayMs(attempt: number, retryAfterHeader?: string | null): number {
|
|
223
|
+
const retryAfter = retryAfterHeader ? Number(retryAfterHeader) : Number.NaN;
|
|
224
|
+
if (Number.isFinite(retryAfter) && retryAfter > 0) return Math.min(retryAfter * 1000, RETRY_MAX_MS);
|
|
225
|
+
return Math.min(RETRY_BASE_MS * 2 ** Math.max(0, attempt), RETRY_MAX_MS);
|
|
226
|
+
}
|
|
227
|
+
|
|
217
228
|
/** True when a settings `packages` array references the conflicting upstream package. */
|
|
218
229
|
export function hasConflictingPackage(settings: unknown): boolean {
|
|
219
230
|
if (!isRecord(settings) || !Array.isArray(settings.packages)) return false;
|
|
@@ -249,6 +260,8 @@ type UsageProvider = "anthropic" | "openai-codex" | "openai";
|
|
|
249
260
|
interface ProviderState extends StatusSnapshot {
|
|
250
261
|
lastPollMs: number | undefined;
|
|
251
262
|
inFlight: boolean;
|
|
263
|
+
retryAttempt: number;
|
|
264
|
+
retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
252
265
|
}
|
|
253
266
|
|
|
254
267
|
export default function usageStatus(pi: ExtensionAPI) {
|
|
@@ -267,6 +280,8 @@ export default function usageStatus(pi: ExtensionAPI) {
|
|
|
267
280
|
providerErrorStatus: undefined,
|
|
268
281
|
lastPollMs: undefined,
|
|
269
282
|
inFlight: false,
|
|
283
|
+
retryAttempt: 0,
|
|
284
|
+
retryTimer: undefined,
|
|
270
285
|
};
|
|
271
286
|
states.set(provider, state);
|
|
272
287
|
}
|
|
@@ -276,6 +291,23 @@ export default function usageStatus(pi: ExtensionAPI) {
|
|
|
276
291
|
const paint = (ui: StatusUi, color: string, text: string): string =>
|
|
277
292
|
ui.theme ? ui.theme.fg(color, text) : text;
|
|
278
293
|
|
|
294
|
+
function clearRetry(state: ProviderState): void {
|
|
295
|
+
if (state.retryTimer) clearTimeout(state.retryTimer);
|
|
296
|
+
state.retryTimer = undefined;
|
|
297
|
+
state.retryAttempt = 0;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function scheduleRetry(state: ProviderState, retryAfter?: string | null): void {
|
|
301
|
+
if (state.retryTimer) return;
|
|
302
|
+
const delay = retryDelayMs(state.retryAttempt, retryAfter);
|
|
303
|
+
state.retryAttempt += 1;
|
|
304
|
+
state.retryTimer = setTimeout(() => {
|
|
305
|
+
state.retryTimer = undefined;
|
|
306
|
+
if (currentCtx) void refresh(currentCtx, true);
|
|
307
|
+
}, delay);
|
|
308
|
+
state.retryTimer.unref?.();
|
|
309
|
+
}
|
|
310
|
+
|
|
279
311
|
function usageProvider(ctx: StatusCtx): UsageProvider | undefined {
|
|
280
312
|
const model = ctx.model;
|
|
281
313
|
if (!model) return undefined;
|
|
@@ -328,10 +360,12 @@ export default function usageStatus(pi: ExtensionAPI) {
|
|
|
328
360
|
if (response.status === 401 || response.status === 403) {
|
|
329
361
|
state.loginNeeded = true;
|
|
330
362
|
state.fetchFailed = false;
|
|
363
|
+
clearRetry(state);
|
|
331
364
|
return;
|
|
332
365
|
}
|
|
333
366
|
if (!response.ok) {
|
|
334
367
|
state.fetchFailed = true; // shown as "(stale)" / "usage n/a"
|
|
368
|
+
scheduleRetry(state, response.headers.get("retry-after"));
|
|
335
369
|
return;
|
|
336
370
|
}
|
|
337
371
|
const json: unknown = await response.json();
|
|
@@ -341,9 +375,12 @@ export default function usageStatus(pi: ExtensionAPI) {
|
|
|
341
375
|
state.loginNeeded = false;
|
|
342
376
|
// An unrecognized/empty payload counts as a failed read too.
|
|
343
377
|
state.fetchFailed = parsed.length === 0;
|
|
378
|
+
if (state.fetchFailed) scheduleRetry(state);
|
|
379
|
+
else clearRetry(state);
|
|
344
380
|
} catch {
|
|
345
381
|
// Keep last known data, but surface the failure in the status line.
|
|
346
382
|
state.fetchFailed = true;
|
|
383
|
+
scheduleRetry(state);
|
|
347
384
|
} finally {
|
|
348
385
|
state.inFlight = false;
|
|
349
386
|
// A different model may have been selected while the fetch was running.
|
|
@@ -471,6 +508,7 @@ export default function usageStatus(pi: ExtensionAPI) {
|
|
|
471
508
|
pi.on("session_shutdown", async () => {
|
|
472
509
|
if (idleTimer) clearInterval(idleTimer);
|
|
473
510
|
idleTimer = undefined;
|
|
511
|
+
for (const state of states.values()) clearRetry(state);
|
|
474
512
|
currentCtx = undefined;
|
|
475
513
|
});
|
|
476
514
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-claude-billing-router",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Route Claude Pro/Max OAuth requests in pi between your plan and extra-usage credits: plan first, automatic fallback to credits when the 5h window is exhausted, /billing override, live usage in the footer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|