@tokentop/ttop 0.5.0 → 0.6.1

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.
Files changed (60) hide show
  1. package/README.md +38 -15
  2. package/package.json +8 -6
  3. package/src/agents/aggregator.test.ts +306 -0
  4. package/src/agents/aggregator.ts +40 -5
  5. package/src/cli.ts +14 -0
  6. package/src/config/schema.ts +1 -1
  7. package/src/demo/simulator.ts +1 -1
  8. package/src/plugins/agents/index.ts +1 -0
  9. package/src/plugins/lifecycle.ts +1 -1
  10. package/src/plugins/notification-bus.ts +27 -0
  11. package/src/plugins/notifications/terminal-bell.ts +80 -16
  12. package/src/plugins/notifications/visual-flash.ts +5 -18
  13. package/src/plugins/plugin-host.test.ts +197 -0
  14. package/src/plugins/plugin-host.ts +45 -1
  15. package/src/plugins/providers/anthropic.test.ts +202 -0
  16. package/src/plugins/providers/anthropic.ts +19 -0
  17. package/src/plugins/providers/antigravity.ts +16 -0
  18. package/src/plugins/providers/chutes.ts +16 -0
  19. package/src/plugins/providers/codex.ts +16 -0
  20. package/src/plugins/providers/gemini.ts +16 -0
  21. package/src/plugins/providers/github-copilot.ts +16 -0
  22. package/src/plugins/providers/minimax.ts +16 -0
  23. package/src/plugins/providers/openai-api.ts +52 -0
  24. package/src/plugins/providers/perplexity.ts +10 -1
  25. package/src/plugins/providers/zai.ts +16 -0
  26. package/src/pricing/fallback.ts +4 -0
  27. package/src/tui/App.tsx +22 -8
  28. package/src/tui/components/HelpOverlay.tsx +1 -1
  29. package/src/tui/components/LimitGauge.tsx +12 -4
  30. package/src/tui/components/NotificationFlash.tsx +141 -0
  31. package/src/tui/components/ProviderCard.tsx +38 -22
  32. package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
  33. package/src/tui/components/SessionsTable.tsx +54 -36
  34. package/src/tui/components/SettingsModal.tsx +20 -4
  35. package/src/tui/components/Skeleton.tsx +9 -6
  36. package/src/tui/components/SortOverlay.tsx +167 -0
  37. package/src/tui/components/Spinner.tsx +16 -11
  38. package/src/tui/components/Toast.tsx +1 -1
  39. package/src/tui/contexts/AgentSessionContext.tsx +69 -16
  40. package/src/tui/contexts/PluginContext.tsx +72 -10
  41. package/src/tui/contexts/ThemeContext.tsx +4 -1
  42. package/src/tui/createApp.tsx +2 -0
  43. package/src/tui/hooks/index.ts +1 -0
  44. package/src/tui/hooks/useAnimatedValue.ts +29 -26
  45. package/src/tui/hooks/useAnimationTick.ts +93 -0
  46. package/src/tui/hooks/useDashboardKeyboard.ts +11 -4
  47. package/src/tui/hooks/useEntranceAnimation.ts +16 -10
  48. package/src/tui/hooks/useNotificationBridge.ts +29 -0
  49. package/src/tui/hooks/usePulse.ts +13 -7
  50. package/src/tui/hooks/useValueFlash.ts +25 -33
  51. package/src/tui/index.tsx +4 -0
  52. package/src/tui/types/projectSort.ts +35 -0
  53. package/src/tui/types/sort.ts +61 -0
  54. package/src/tui/views/ProjectsView.tsx +104 -38
  55. package/src/tui/views/RealTimeDashboard.tsx +91 -8
  56. package/src/tui/views/SettingsView.tsx +26 -8
  57. package/src/utils/__tests__/error-category.test.ts +147 -0
  58. package/src/utils/currency.ts +4 -2
  59. package/src/utils/error-category.ts +91 -0
  60. package/src/version.ts +1 -1
@@ -4,6 +4,80 @@ import type {
4
4
  NotificationPlugin,
5
5
  } from "@tokentop/plugin-sdk";
6
6
 
7
+ const SEVERITY_ORDER = ["info", "warning", "critical"];
8
+
9
+ /**
10
+ * Write directly to stderr so the escape sequences bypass OpenTUI's
11
+ * stdout render pipeline. Most terminals still interpret BEL and OSC
12
+ * sequences arriving on fd 2.
13
+ */
14
+ function writeEsc(data: string): void {
15
+ process.stderr.write(data);
16
+ }
17
+
18
+ /**
19
+ * OSC 777 (notify;title;body) — supported by iTerm2, Kitty, WezTerm, rxvt-unicode.
20
+ * OSC 9 (notify;body) — supported by Windows Terminal, ConEmu.
21
+ * BEL (\x07) — universal fallback (if terminal has bell enabled).
22
+ *
23
+ * We send all three; unsupported sequences are silently ignored.
24
+ */
25
+ function sendDesktopNotification(title: string, body: string): void {
26
+ writeEsc(`\x1b]777;notify;${title};${body}\x07`);
27
+ writeEsc(`\x1b]9;${title}: ${body}\x07`);
28
+ writeEsc("\x07");
29
+ }
30
+
31
+ /**
32
+ * macOS: afplay with system sounds (Ping, Funk, Glass, etc.)
33
+ * Linux: paplay → aplay → canberra-gtk-play (first available)
34
+ *
35
+ * Fire-and-forget — errors are silently ignored so a missing
36
+ * sound binary never breaks notifications.
37
+ */
38
+ const SEVERITY_SOUNDS: Record<string, string> = {
39
+ critical: "Funk.aiff",
40
+ warning: "Ping.aiff",
41
+ info: "Pop.aiff",
42
+ };
43
+
44
+ function playSystemSound(severity: string): void {
45
+ const platform = process.platform;
46
+
47
+ if (platform === "darwin") {
48
+ const sound = SEVERITY_SOUNDS[severity] ?? "Ping.aiff";
49
+ Bun.spawn(["afplay", `/System/Library/Sounds/${sound}`], {
50
+ stdout: "ignore",
51
+ stderr: "ignore",
52
+ });
53
+ return;
54
+ }
55
+
56
+ if (platform === "linux") {
57
+ const xdgSound =
58
+ severity === "critical"
59
+ ? "dialog-error"
60
+ : severity === "warning"
61
+ ? "dialog-warning"
62
+ : "dialog-information";
63
+
64
+ Bun.spawn(["canberra-gtk-play", "-i", xdgSound], {
65
+ stdout: "ignore",
66
+ stderr: "ignore",
67
+ }).exited.then((code) => {
68
+ if (code !== 0) {
69
+ Bun.spawn(["paplay", "/usr/share/sounds/freedesktop/stereo/bell.oga"], {
70
+ stdout: "ignore",
71
+ stderr: "ignore",
72
+ });
73
+ }
74
+ });
75
+ return;
76
+ }
77
+
78
+ // Windows / other — BEL only (already sent via sendDesktopNotification)
79
+ }
80
+
7
81
  export const terminalBellPlugin: NotificationPlugin = {
8
82
  apiVersion: 2,
9
83
  id: "terminal-bell",
@@ -12,7 +86,7 @@ export const terminalBellPlugin: NotificationPlugin = {
12
86
  version: "1.0.0",
13
87
 
14
88
  meta: {
15
- description: "Simple terminal bell (BEL character) for alerts",
89
+ description: "Desktop notification + system sound alerts",
16
90
  },
17
91
 
18
92
  permissions: {},
@@ -50,30 +124,20 @@ export const terminalBellPlugin: NotificationPlugin = {
50
124
  },
51
125
 
52
126
  async notify(ctx: NotificationContext, event: NotificationEvent): Promise<void> {
53
- const severityOrder = ["info", "warning", "critical"];
54
127
  const minSeverity = (ctx.config.minSeverity as string) ?? "warning";
55
128
 
56
- if (severityOrder.indexOf(event.severity) < severityOrder.indexOf(minSeverity)) {
129
+ if (SEVERITY_ORDER.indexOf(event.severity) < SEVERITY_ORDER.indexOf(minSeverity)) {
57
130
  return;
58
131
  }
59
132
 
60
- const bellCount = event.severity === "critical" ? 3 : 1;
61
-
62
- for (let i = 0; i < bellCount; i++) {
63
- process.stdout.write("\x07");
64
- if (i < bellCount - 1) {
65
- await sleep(200);
66
- }
67
- }
133
+ sendDesktopNotification(event.title, event.message);
134
+ playSystemSound(event.severity);
68
135
  },
69
136
 
70
137
  async test(ctx: NotificationContext): Promise<boolean> {
71
138
  ctx.logger.info("Testing terminal bell...");
72
- process.stdout.write("\x07");
139
+ sendDesktopNotification("tokentop", "Test notification");
140
+ playSystemSound("warning");
73
141
  return true;
74
142
  },
75
143
  };
76
-
77
- function sleep(ms: number): Promise<void> {
78
- return new Promise((resolve) => setTimeout(resolve, ms));
79
- }
@@ -12,7 +12,7 @@ export const visualFlashPlugin: NotificationPlugin = {
12
12
  version: "1.0.0",
13
13
 
14
14
  meta: {
15
- description: "Visual screen flash using ANSI escape sequences",
15
+ description: "Visual screen flash for alerts (rendered via TUI overlay)",
16
16
  },
17
17
 
18
18
  permissions: {},
@@ -27,7 +27,7 @@ export const visualFlashPlugin: NotificationPlugin = {
27
27
  duration: {
28
28
  type: "number",
29
29
  label: "Flash duration (ms)",
30
- default: 100,
30
+ default: 150,
31
31
  description: "Flash duration in milliseconds",
32
32
  },
33
33
  },
@@ -44,26 +44,13 @@ export const visualFlashPlugin: NotificationPlugin = {
44
44
  ctx.logger.debug("Visual flash notification plugin initialized");
45
45
  },
46
46
 
47
- async notify(ctx: NotificationContext, event: NotificationEvent): Promise<void> {
48
- const duration = (ctx.config.duration as number) ?? 100;
49
-
50
- const colorCode =
51
- event.severity === "critical" ? "41" : event.severity === "warning" ? "43" : "42";
52
-
53
- process.stdout.write(`\x1b[${colorCode}m`);
54
- await sleep(duration);
55
- process.stdout.write("\x1b[0m");
47
+ async notify(_ctx: NotificationContext, _event: NotificationEvent): Promise<void> {
48
+ // Flash is handled by the NotificationFlash React component via
49
+ // notificationBus.flashHandler — raw ANSI escapes conflict with OpenTUI.
56
50
  },
57
51
 
58
52
  async test(ctx: NotificationContext): Promise<boolean> {
59
53
  ctx.logger.info("Testing visual flash...");
60
- process.stdout.write("\x1b[44m");
61
- await sleep(100);
62
- process.stdout.write("\x1b[0m");
63
54
  return true;
64
55
  },
65
56
  };
66
-
67
- function sleep(ms: number): Promise<void> {
68
- return new Promise((resolve) => setTimeout(resolve, ms));
69
- }
@@ -0,0 +1,197 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { getPluginHealth, resetAllCircuits, safeInvoke } from "./plugin-host.ts";
3
+
4
+ afterEach(() => {
5
+ resetAllCircuits();
6
+ });
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Baseline behaviour (no isFailure)
10
+ // ---------------------------------------------------------------------------
11
+
12
+ describe("safeInvoke — baseline", () => {
13
+ test("returns ok:true on success", async () => {
14
+ const result = await safeInvoke("test-plugin", "test", async () => 42);
15
+ expect(result).toEqual({ ok: true, value: 42 });
16
+ });
17
+
18
+ test("returns ok:false on thrown error", async () => {
19
+ const result = await safeInvoke("test-plugin", "test", async () => {
20
+ throw new Error("boom");
21
+ });
22
+ expect(result.ok).toBe(false);
23
+ if (!result.ok) {
24
+ expect(result.error.message).toBe("boom");
25
+ expect(result.circuitOpen).toBe(false);
26
+ }
27
+ });
28
+
29
+ test("increments failure count on thrown errors", async () => {
30
+ for (let i = 0; i < 3; i++) {
31
+ await safeInvoke("fail-plugin", "test", async () => {
32
+ throw new Error("oops");
33
+ });
34
+ }
35
+ const health = getPluginHealth("fail-plugin");
36
+ expect(health.consecutiveFailures).toBe(3);
37
+ expect(health.totalFailures).toBe(3);
38
+ });
39
+
40
+ test("resets failure count on success", async () => {
41
+ // Fail twice
42
+ for (let i = 0; i < 2; i++) {
43
+ await safeInvoke("reset-plugin", "test", async () => {
44
+ throw new Error("fail");
45
+ });
46
+ }
47
+ expect(getPluginHealth("reset-plugin").consecutiveFailures).toBe(2);
48
+
49
+ // Succeed once — should reset
50
+ await safeInvoke("reset-plugin", "test", async () => "ok");
51
+ expect(getPluginHealth("reset-plugin").consecutiveFailures).toBe(0);
52
+ });
53
+
54
+ test("trips circuit after 5 consecutive failures", async () => {
55
+ for (let i = 0; i < 5; i++) {
56
+ await safeInvoke("trip-plugin", "test", async () => {
57
+ throw new Error("fail");
58
+ });
59
+ }
60
+ const health = getPluginHealth("trip-plugin");
61
+ expect(health.healthy).toBe(false);
62
+ expect(health.disabledUntil).not.toBeNull();
63
+
64
+ // Next call should be short-circuited
65
+ const result = await safeInvoke("trip-plugin", "test", async () => "ok");
66
+ expect(result.ok).toBe(false);
67
+ if (!result.ok) {
68
+ expect(result.circuitOpen).toBe(true);
69
+ }
70
+ });
71
+ });
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // isFailure predicate (the new feature)
75
+ // ---------------------------------------------------------------------------
76
+
77
+ interface MockUsage {
78
+ value: number;
79
+ error?: string;
80
+ rateLimited?: boolean;
81
+ }
82
+
83
+ describe("safeInvoke — isFailure predicate", () => {
84
+ test("counts logical failures when isFailure returns true", async () => {
85
+ const isFailure = (v: MockUsage) => !!v.error && !v.rateLimited;
86
+
87
+ const result = await safeInvoke(
88
+ "logical-fail",
89
+ "fetchUsage",
90
+ async () => ({ value: 0, error: "API error: 500 Internal Server Error" }),
91
+ { isFailure },
92
+ );
93
+
94
+ // Still returns ok:true so caller can inspect the error details
95
+ expect(result.ok).toBe(true);
96
+ if (result.ok) {
97
+ expect(result.value.error).toContain("500");
98
+ }
99
+
100
+ // But the circuit breaker counted it as a failure
101
+ const health = getPluginHealth("logical-fail");
102
+ expect(health.consecutiveFailures).toBe(1);
103
+ expect(health.totalFailures).toBe(1);
104
+ });
105
+
106
+ test("does NOT count rate-limited responses as failures", async () => {
107
+ const isFailure = (v: MockUsage) => !!v.error && !v.rateLimited;
108
+
109
+ const result = await safeInvoke(
110
+ "rate-limit",
111
+ "fetchUsage",
112
+ async () => ({
113
+ value: 0,
114
+ error: "Rate limited. Retry after 300s.",
115
+ rateLimited: true,
116
+ }),
117
+ { isFailure },
118
+ );
119
+
120
+ expect(result.ok).toBe(true);
121
+ const health = getPluginHealth("rate-limit");
122
+ expect(health.consecutiveFailures).toBe(0);
123
+ expect(health.totalFailures).toBe(0);
124
+ });
125
+
126
+ test("resets failures after a true success", async () => {
127
+ const isFailure = (v: MockUsage) => !!v.error;
128
+
129
+ // Fail 3 times logically
130
+ for (let i = 0; i < 3; i++) {
131
+ await safeInvoke(
132
+ "reset-after-logical",
133
+ "fetchUsage",
134
+ async () => ({ value: 0, error: "fail" }),
135
+ { isFailure },
136
+ );
137
+ }
138
+ expect(getPluginHealth("reset-after-logical").consecutiveFailures).toBe(3);
139
+
140
+ // Succeed — should reset
141
+ await safeInvoke("reset-after-logical", "fetchUsage", async () => ({ value: 42 }), {
142
+ isFailure,
143
+ });
144
+ expect(getPluginHealth("reset-after-logical").consecutiveFailures).toBe(0);
145
+ });
146
+
147
+ test("trips circuit after 5 consecutive logical failures", async () => {
148
+ const isFailure = (v: MockUsage) => !!v.error;
149
+
150
+ for (let i = 0; i < 5; i++) {
151
+ await safeInvoke(
152
+ "trip-logical",
153
+ "fetchUsage",
154
+ async () => ({ value: 0, error: `fail ${i}` }),
155
+ { isFailure },
156
+ );
157
+ }
158
+
159
+ const health = getPluginHealth("trip-logical");
160
+ expect(health.healthy).toBe(false);
161
+ expect(health.consecutiveFailures).toBe(5);
162
+ expect(health.disabledUntil).not.toBeNull();
163
+ });
164
+
165
+ test("mixes thrown errors and logical failures for circuit breaker", async () => {
166
+ const isFailure = (v: MockUsage) => !!v.error;
167
+
168
+ // 3 logical failures
169
+ for (let i = 0; i < 3; i++) {
170
+ await safeInvoke("mixed-fail", "fetchUsage", async () => ({ value: 0, error: "logical" }), {
171
+ isFailure,
172
+ });
173
+ }
174
+
175
+ // 2 thrown errors
176
+ for (let i = 0; i < 2; i++) {
177
+ await safeInvoke("mixed-fail", "fetchUsage", async () => {
178
+ throw new Error("thrown");
179
+ });
180
+ }
181
+
182
+ // Total: 5 consecutive failures → circuit should trip
183
+ const health = getPluginHealth("mixed-fail");
184
+ expect(health.consecutiveFailures).toBe(5);
185
+ expect(health.healthy).toBe(false);
186
+ });
187
+
188
+ test("does not treat successful value as failure when isFailure returns false", async () => {
189
+ const result = await safeInvoke("success-check", "fetchUsage", async () => ({ value: 42 }), {
190
+ isFailure: (v: MockUsage) => !!v.error,
191
+ });
192
+
193
+ expect(result.ok).toBe(true);
194
+ const health = getPluginHealth("success-check");
195
+ expect(health.consecutiveFailures).toBe(0);
196
+ });
197
+ });
@@ -52,18 +52,41 @@ export type SafeInvokeResult<T> =
52
52
  | { ok: true; value: T }
53
53
  | { ok: false; error: Error; circuitOpen: boolean };
54
54
 
55
+ /**
56
+ * Options for `safeInvoke`.
57
+ *
58
+ * @template T The return type of the plugin method.
59
+ */
60
+ export interface SafeInvokeOptions<T> {
61
+ /**
62
+ * Optional predicate that inspects a *successful* return value and decides
63
+ * whether it should count as a logical failure for the circuit breaker.
64
+ *
65
+ * Use this when plugins return error-carrying objects instead of throwing
66
+ * (e.g. `{ error: "429 Too Many Requests" }`).
67
+ *
68
+ * When the predicate returns `true`:
69
+ * - The circuit breaker's failure counter is incremented
70
+ * - The value is still returned as `{ ok: true, value }` so the caller
71
+ * can inspect the error details
72
+ */
73
+ isFailure?: (value: T) => boolean;
74
+ }
75
+
55
76
  /**
56
77
  * Safely invoke a plugin method with circuit breaker protection.
57
78
  *
58
79
  * @param pluginId Unique plugin identifier (for circuit tracking + logging)
59
80
  * @param method Human-readable method name (for logging, e.g. "fetchUsage")
60
81
  * @param fn The actual async function to call
82
+ * @param options Optional configuration (e.g. `isFailure` predicate)
61
83
  * @returns `{ ok: true, value }` or `{ ok: false, error, circuitOpen }`
62
84
  */
63
85
  export async function safeInvoke<T>(
64
86
  pluginId: string,
65
87
  method: string,
66
88
  fn: () => Promise<T>,
89
+ options?: SafeInvokeOptions<T>,
67
90
  ): Promise<SafeInvokeResult<T>> {
68
91
  const circuit = getCircuit(pluginId);
69
92
  const log = createPluginLogger(pluginId);
@@ -91,7 +114,28 @@ export async function safeInvoke<T>(
91
114
  try {
92
115
  const value = await fn();
93
116
 
94
- // Success — reset consecutive failures
117
+ // Check for logical failures (e.g. provider returned { error: "..." })
118
+ if (options?.isFailure?.(value)) {
119
+ circuit.consecutiveFailures++;
120
+ circuit.totalFailures++;
121
+ circuit.lastFailureAt = Date.now();
122
+
123
+ log.warn(
124
+ `${method} returned logical failure (${circuit.consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})`,
125
+ );
126
+
127
+ if (circuit.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
128
+ circuit.disabledUntil = Date.now() + COOLDOWN_MS;
129
+ log.error(
130
+ `Circuit OPEN — plugin disabled for ${COOLDOWN_MS / 1000}s after ${circuit.consecutiveFailures} consecutive failures`,
131
+ );
132
+ }
133
+
134
+ // Still return the value — caller needs the error details
135
+ return { ok: true, value };
136
+ }
137
+
138
+ // True success — reset consecutive failures
95
139
  if (circuit.consecutiveFailures > 0) {
96
140
  log.info(
97
141
  `${method} succeeded — resetting circuit breaker (was at ${circuit.consecutiveFailures} failures)`,
@@ -0,0 +1,202 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type {
3
+ Credentials,
4
+ PluginHttpClient,
5
+ PluginLogger,
6
+ ProviderFetchContext,
7
+ } from "@tokentop/plugin-sdk";
8
+ import { anthropicPlugin } from "./anthropic.ts";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Helpers
12
+ // ---------------------------------------------------------------------------
13
+
14
+ function createMockLogger(): PluginLogger {
15
+ return {
16
+ debug: () => {},
17
+ info: () => {},
18
+ warn: () => {},
19
+ error: () => {},
20
+ };
21
+ }
22
+
23
+ function createMockHttpClient(response: {
24
+ status: number;
25
+ statusText?: string;
26
+ headers?: Record<string, string>;
27
+ body?: unknown;
28
+ }): PluginHttpClient {
29
+ return {
30
+ fetch: async () =>
31
+ new Response(JSON.stringify(response.body ?? {}), {
32
+ status: response.status,
33
+ statusText: response.statusText ?? "",
34
+ headers: new Headers(response.headers ?? {}),
35
+ }),
36
+ };
37
+ }
38
+
39
+ function createFetchContext(
40
+ overrides: { http?: PluginHttpClient; credentials?: Credentials } = {},
41
+ ): ProviderFetchContext {
42
+ return {
43
+ credentials: overrides.credentials ?? {
44
+ oauth: {
45
+ accessToken: "test-token",
46
+ expiresAt: Date.now() + 3_600_000, // 1 hour from now
47
+ },
48
+ source: "external",
49
+ },
50
+ http: overrides.http ?? createMockHttpClient({ status: 200, body: {} }),
51
+ logger: createMockLogger(),
52
+ config: {},
53
+ signal: AbortSignal.timeout(5000),
54
+ };
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // 429 Rate limit handling
59
+ // ---------------------------------------------------------------------------
60
+
61
+ describe("anthropic fetchUsage — 429 rate limit", () => {
62
+ test("returns rateLimited:true with default 300s when retry-after is 0", async () => {
63
+ const http = createMockHttpClient({
64
+ status: 429,
65
+ statusText: "Too Many Requests",
66
+ headers: { "retry-after": "0" },
67
+ body: { error: { message: "Rate limited", type: "rate_limit_error" } },
68
+ });
69
+
70
+ const result = await anthropicPlugin.fetchUsage(createFetchContext({ http }));
71
+
72
+ expect(result.rateLimited).toBe(true);
73
+ expect(result.retryAfterMs).toBe(300_000); // 300s default when retry-after is 0
74
+ expect(result.error).toContain("Rate limited");
75
+ expect(result.error).toContain("300s");
76
+ });
77
+
78
+ test("returns rateLimited:true with parsed retry-after when positive", async () => {
79
+ const http = createMockHttpClient({
80
+ status: 429,
81
+ statusText: "Too Many Requests",
82
+ headers: { "retry-after": "60" },
83
+ body: { error: { message: "Rate limited", type: "rate_limit_error" } },
84
+ });
85
+
86
+ const result = await anthropicPlugin.fetchUsage(createFetchContext({ http }));
87
+
88
+ expect(result.rateLimited).toBe(true);
89
+ expect(result.retryAfterMs).toBe(60_000); // 60s parsed from header
90
+ expect(result.error).toContain("60s");
91
+ });
92
+
93
+ test("returns rateLimited:true with default 300s when no retry-after header", async () => {
94
+ const http = createMockHttpClient({
95
+ status: 429,
96
+ statusText: "Too Many Requests",
97
+ body: { error: { message: "Rate limited", type: "rate_limit_error" } },
98
+ });
99
+
100
+ const result = await anthropicPlugin.fetchUsage(createFetchContext({ http }));
101
+
102
+ expect(result.rateLimited).toBe(true);
103
+ expect(result.retryAfterMs).toBe(300_000); // 300s default
104
+ });
105
+
106
+ test("429 response always includes fetchedAt", async () => {
107
+ const before = Date.now();
108
+ const http = createMockHttpClient({
109
+ status: 429,
110
+ statusText: "Too Many Requests",
111
+ headers: { "retry-after": "10" },
112
+ });
113
+
114
+ const result = await anthropicPlugin.fetchUsage(createFetchContext({ http }));
115
+
116
+ expect(result.fetchedAt).toBeGreaterThanOrEqual(before);
117
+ expect(result.fetchedAt).toBeLessThanOrEqual(Date.now());
118
+ });
119
+ });
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Other HTTP error codes (regression — should NOT have rateLimited)
123
+ // ---------------------------------------------------------------------------
124
+
125
+ describe("anthropic fetchUsage — non-429 errors", () => {
126
+ test("401 returns error without rateLimited", async () => {
127
+ const http = createMockHttpClient({
128
+ status: 401,
129
+ statusText: "Unauthorized",
130
+ });
131
+
132
+ const result = await anthropicPlugin.fetchUsage(createFetchContext({ http }));
133
+
134
+ expect(result.error).toContain("expired or invalid");
135
+ expect(result.rateLimited).toBeUndefined();
136
+ expect(result.retryAfterMs).toBeUndefined();
137
+ });
138
+
139
+ test("403 returns error without rateLimited", async () => {
140
+ const http = createMockHttpClient({
141
+ status: 403,
142
+ statusText: "Forbidden",
143
+ });
144
+
145
+ const result = await anthropicPlugin.fetchUsage(createFetchContext({ http }));
146
+
147
+ expect(result.error).toContain("lacks required scope");
148
+ expect(result.rateLimited).toBeUndefined();
149
+ });
150
+
151
+ test("500 returns generic error without rateLimited", async () => {
152
+ const http = createMockHttpClient({
153
+ status: 500,
154
+ statusText: "Internal Server Error",
155
+ });
156
+
157
+ const result = await anthropicPlugin.fetchUsage(createFetchContext({ http }));
158
+
159
+ expect(result.error).toContain("500");
160
+ expect(result.rateLimited).toBeUndefined();
161
+ });
162
+ });
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Successful responses
166
+ // ---------------------------------------------------------------------------
167
+
168
+ describe("anthropic fetchUsage — success", () => {
169
+ test("parses usage data correctly", async () => {
170
+ const http = createMockHttpClient({
171
+ status: 200,
172
+ body: {
173
+ five_hour: { utilization: 45.5, resets_at: "2026-03-08T15:00:00Z" },
174
+ seven_day: { utilization: 72.3, resets_at: "2026-03-10T00:00:00Z" },
175
+ },
176
+ });
177
+
178
+ const result = await anthropicPlugin.fetchUsage(createFetchContext({ http }));
179
+
180
+ expect(result.error).toBeUndefined();
181
+ expect(result.rateLimited).toBeUndefined();
182
+ expect(result.limits?.primary?.usedPercent).toBe(46); // rounded from 45.5
183
+ expect(result.limits?.primary?.label).toBe("5-hour window");
184
+ expect(result.limits?.secondary?.usedPercent).toBe(72); // rounded from 72.3
185
+ expect(result.limits?.secondary?.label).toBe("7-day window");
186
+ });
187
+
188
+ test("returns API plan type for apiKey-only credentials", async () => {
189
+ const result = await anthropicPlugin.fetchUsage(
190
+ createFetchContext({
191
+ credentials: {
192
+ apiKey: "sk-test-key",
193
+ source: "env",
194
+ },
195
+ }),
196
+ );
197
+
198
+ expect(result.planType).toBe("API");
199
+ expect(result.allowed).toBe(true);
200
+ expect(result.error).toBeUndefined();
201
+ });
202
+ });
@@ -234,6 +234,25 @@ export const anthropicPlugin: ProviderPlugin = {
234
234
  if (!response.ok) {
235
235
  log.warn("Failed to fetch Anthropic usage", { status: response.status });
236
236
 
237
+ if (response.status === 429) {
238
+ const retryAfterHeader = response.headers.get("retry-after");
239
+ const parsedRetryAfter = retryAfterHeader
240
+ ? Number.parseInt(retryAfterHeader, 10)
241
+ : Number.NaN;
242
+ const retrySec =
243
+ Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0 ? parsedRetryAfter : 300;
244
+ log.warn("Rate limited by Anthropic API", {
245
+ retryAfterHeader,
246
+ retryAfterSec: retrySec,
247
+ });
248
+ return {
249
+ fetchedAt: Date.now(),
250
+ error: `Rate limited. Retry after ${retrySec}s.`,
251
+ rateLimited: true,
252
+ retryAfterMs: retrySec * 1000,
253
+ };
254
+ }
255
+
237
256
  if (response.status === 401) {
238
257
  return {
239
258
  fetchedAt: Date.now(),
@@ -241,6 +241,22 @@ export const antigravityPlugin: ProviderPlugin = {
241
241
  const errorText = await response.text().catch(() => "");
242
242
  lastError = `${response.status} ${response.statusText}: ${errorText.slice(0, 100)}`;
243
243
 
244
+ if (response.status === 429) {
245
+ const retryAfterHeader = response.headers.get("retry-after");
246
+ const parsedRetryAfter = retryAfterHeader
247
+ ? Number.parseInt(retryAfterHeader, 10)
248
+ : Number.NaN;
249
+ const retrySec =
250
+ Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0 ? parsedRetryAfter : 300;
251
+ log.warn("Rate limited by Google API", { retryAfterHeader, retryAfterSec: retrySec });
252
+ return {
253
+ fetchedAt: Date.now(),
254
+ error: `Rate limited. Retry after ${retrySec}s.`,
255
+ rateLimited: true,
256
+ retryAfterMs: retrySec * 1000,
257
+ };
258
+ }
259
+
244
260
  if (response.status === 401) {
245
261
  return {
246
262
  fetchedAt: Date.now(),