@vanillagreen/pi-claude-bridge 1.9.0 → 3.2.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/src/rate-limit.ts CHANGED
@@ -1,15 +1,28 @@
1
+ import { USAGE_LIMIT_ERROR_PREFIXES } from "@anthropic-ai/claude-agent-sdk";
2
+
1
3
  export const RATE_LIMIT_AUTO_RESUME_EVENT = "vstack:rate-limit";
2
4
  export const RATE_LIMIT_TOKEN = "\x1b[31m[rate-limit]\x1b[39m";
3
5
 
4
- export function isExtraUsageRequiredMessage(value: unknown): boolean {
5
- let text: string;
6
- if (typeof value === "string") text = value;
7
- else if (value instanceof Error) text = value.message;
8
- else {
9
- try { text = JSON.stringify(value ?? ""); }
10
- catch { text = String(value); }
11
- }
12
- return /extra[-\s]?usage|overage|extra usage billing|extra usage credits|1M context/i.test(text);
6
+ // The SDK export is @alpha — degrade to "no match" (pre-0.3.220 behavior) if a
7
+ // future release drops it, instead of crashing message classification.
8
+ const USAGE_LIMIT_PREFIXES: readonly string[] = Array.isArray(USAGE_LIMIT_ERROR_PREFIXES as unknown)
9
+ ? USAGE_LIMIT_ERROR_PREFIXES
10
+ : [];
11
+
12
+ function coerceMessageText(value: unknown): string {
13
+ if (typeof value === "string") return value;
14
+ if (value instanceof Error) return value.message;
15
+ try { return JSON.stringify(value ?? ""); }
16
+ catch { return String(value); }
17
+ }
18
+
19
+ /** Broad test: any "a usage limit was genuinely reached" message, matched
20
+ * against the CLI's own copy (SDK `USAGE_LIMIT_ERROR_PREFIXES`, e.g. "You've
21
+ * hit your weekly limit…"). Substring rather than prefix match because the
22
+ * text usually arrives embedded in a result payload's errors array. */
23
+ export function isUsageLimitMessage(value: unknown): boolean {
24
+ const text = coerceMessageText(value);
25
+ return USAGE_LIMIT_PREFIXES.some((prefix) => text.includes(prefix));
13
26
  }
14
27
 
15
28
  export function uniqueNonEmptyLines(values: unknown[]): string[] {
@@ -24,9 +37,27 @@ export function uniqueNonEmptyLines(values: unknown[]): string[] {
24
37
  return out;
25
38
  }
26
39
 
40
+ /** Epoch milliseconds from an SDK reset timestamp, or undefined.
41
+ * `SDKRateLimitInfo.resetsAt` is a bare number in epoch SECONDS (measured:
42
+ * treating it as ms rendered "resets Jan 21, 1970" for a Jul 2026 reset).
43
+ * The unit is undocumented, so detect by magnitude — epoch seconds stay below
44
+ * 1e12 until the year 33658, epoch ms passed 1e12 in 2001. A numeric STRING
45
+ * gets the same magnitude treatment (rate_limit_event payloads have carried
46
+ * both), and anything else falls back to Date.parse for ISO strings. */
47
+ export function resetTimestampMs(value: unknown): number | undefined {
48
+ if (typeof value === "number" && Number.isFinite(value)) {
49
+ return Math.abs(value) < 1e12 ? value * 1000 : value;
50
+ }
51
+ if (typeof value !== "string" || !value.trim()) return undefined;
52
+ const numeric = Number(value);
53
+ if (Number.isFinite(numeric)) return Math.abs(numeric) < 1e12 ? numeric * 1000 : numeric;
54
+ const parsed = Date.parse(value);
55
+ return Number.isFinite(parsed) ? parsed : undefined;
56
+ }
57
+
27
58
  export function formatResetTimestamp(value: unknown): string {
28
- const parsed = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : Number.NaN;
29
- if (!Number.isFinite(parsed)) return "unknown";
59
+ const parsed = resetTimestampMs(value);
60
+ if (parsed === undefined) return "unknown";
30
61
  return new Date(parsed).toLocaleString(undefined, {
31
62
  day: "numeric",
32
63
  hour: "numeric",
@@ -44,8 +75,12 @@ export function normalizeRateLimitUtilization(value: unknown): number | undefine
44
75
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined;
45
76
  if (value === 0) return 0;
46
77
  // Claude SDK payloads have appeared as both fractions and percentages.
47
- // Exact 1 is unit-ambiguous (1% vs 100%), so do not use it for allowed-warning copy.
48
- if (value > 0 && value < 1) return value * 100;
78
+ // Exact 1 is unit-ambiguous (1% vs 100%); read it as the fractional form
79
+ // (100%) because that is the fail-closed direction under the fraction
80
+ // convention 1 is the fully-consumed case the warning exists to surface,
81
+ // while under the percent convention 1% sits below the threshold anyway,
82
+ // so nothing is lost by warning (VST-16).
83
+ if (value > 0 && value <= 1) return value * 100;
49
84
  if (value > 1 && value <= 100) return value;
50
85
  return undefined;
51
86
  }
@@ -0,0 +1,36 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ /**
4
+ * Selects the Pi agent session whose provider request is currently executing.
5
+ *
6
+ * Pi forwards a stable `SimpleStreamOptions.sessionId` on every model request,
7
+ * including tool-result continuations. The bridge may serve the parent agent
8
+ * and several in-process subagents concurrently, so process-global query state
9
+ * cannot identify the request that a callback belongs to. AsyncLocalStorage
10
+ * keeps that identity attached to every promise, SDK iterator, and timer born
11
+ * during the provider call without threading the id through every helper.
12
+ */
13
+ const REQUEST_LANE_SYMBOL = Symbol.for("vstack.pi.claude-bridge.request-lane.v1");
14
+
15
+ function requestLaneStorage(): AsyncLocalStorage<string> {
16
+ const host = globalThis as Record<symbol, unknown>;
17
+ let storage = host[REQUEST_LANE_SYMBOL] as AsyncLocalStorage<string> | undefined;
18
+ if (!storage) {
19
+ storage = new AsyncLocalStorage<string>();
20
+ host[REQUEST_LANE_SYMBOL] = storage;
21
+ }
22
+ return storage;
23
+ }
24
+
25
+ /** Run `callback` in the lane for `sessionId`. `undefined` selects the default
26
+ * (direct-host) lane even when a named lane is active — a listener that fires
27
+ * inside another request's context must not inherit that request's lane. */
28
+ export function runInRequestLane<T>(sessionId: string | undefined, callback: () => T): T {
29
+ const storage = requestLaneStorage();
30
+ if (sessionId !== undefined) return storage.run(sessionId, callback);
31
+ return storage.getStore() === undefined ? callback() : storage.exit(callback);
32
+ }
33
+
34
+ export function currentRequestLaneId(): string | undefined {
35
+ return requestLaneStorage().getStore();
36
+ }
@@ -0,0 +1,16 @@
1
+ // Shared mutable SDK query factory + its test seam. In its own module so both
2
+ // the provider entry (index.ts) and the account host spawn children through
3
+ // the same seam — tests swap the factory once and every spawn path honors it.
4
+
5
+ import { query } from "@anthropic-ai/claude-agent-sdk";
6
+
7
+ export type SdkQueryFactory = typeof query;
8
+
9
+ // ESM live binding: importers read the CURRENT factory at call time.
10
+ export let sdkQueryFactory: SdkQueryFactory = query;
11
+
12
+ /** Test seam for exercising the real bridge retry/session orchestration without
13
+ * spending Claude usage. Production never calls this. */
14
+ export function __testSetSdkQueryFactory(factory?: SdkQueryFactory): void {
15
+ sdkQueryFactory = factory ?? query;
16
+ }