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 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. `⚠ extra usage` only appears when a
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
  };
@@ -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.0",
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",