@tokentop/ttop 0.4.0 → 0.6.0

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 (102) hide show
  1. package/README.md +7 -6
  2. package/package.json +12 -5
  3. package/src/agents/aggregator.test.ts +306 -0
  4. package/src/agents/aggregator.ts +44 -2
  5. package/src/agents/types.ts +3 -0
  6. package/src/cli.ts +16 -2
  7. package/src/demo/simulator.ts +2 -2
  8. package/src/plugins/agents/index.ts +3 -0
  9. package/src/plugins/auth-sources.ts +2 -3
  10. package/src/plugins/lifecycle.ts +2 -2
  11. package/src/plugins/loader.test.ts +1 -1
  12. package/src/plugins/loader.ts +15 -7
  13. package/src/plugins/notification-bus.ts +5 -2
  14. package/src/plugins/notifications/terminal-bell.ts +1 -1
  15. package/src/plugins/notifications/visual-flash.ts +1 -1
  16. package/src/plugins/plugin-context-factory.ts +4 -19
  17. package/src/plugins/plugin-host.test.ts +197 -0
  18. package/src/plugins/plugin-host.ts +45 -1
  19. package/src/plugins/providers/anthropic.test.ts +202 -0
  20. package/src/plugins/providers/anthropic.ts +21 -2
  21. package/src/plugins/providers/antigravity.ts +17 -1
  22. package/src/plugins/providers/chutes.ts +205 -0
  23. package/src/plugins/providers/codex.ts +17 -1
  24. package/src/plugins/providers/gemini.ts +17 -1
  25. package/src/plugins/providers/github-copilot.ts +17 -1
  26. package/src/plugins/providers/index.ts +1 -0
  27. package/src/plugins/providers/minimax.ts +17 -1
  28. package/src/plugins/providers/openai-api.ts +53 -1
  29. package/src/plugins/providers/opencode-zen.ts +1 -1
  30. package/src/plugins/providers/perplexity.ts +11 -2
  31. package/src/plugins/providers/zai.ts +17 -1
  32. package/src/plugins/registry.ts +9 -9
  33. package/src/plugins/sandbox-guard.test.ts +1 -1
  34. package/src/plugins/sandbox-guard.ts +1 -1
  35. package/src/plugins/sandbox.ts +1 -1
  36. package/src/plugins/themes/catppuccin-latte.ts +1 -1
  37. package/src/plugins/themes/catppuccin-mocha.ts +1 -1
  38. package/src/plugins/themes/claude-code.ts +1 -1
  39. package/src/plugins/themes/dracula.ts +1 -1
  40. package/src/plugins/themes/github-light.ts +1 -1
  41. package/src/plugins/themes/gruvbox-dark.ts +1 -1
  42. package/src/plugins/themes/gruvbox-light.ts +1 -1
  43. package/src/plugins/themes/kanagawa.ts +1 -1
  44. package/src/plugins/themes/nord.ts +1 -1
  45. package/src/plugins/themes/one-dark.ts +1 -1
  46. package/src/plugins/themes/opencode.ts +1 -1
  47. package/src/plugins/themes/rose-pine-dawn.ts +1 -1
  48. package/src/plugins/themes/rose-pine.ts +1 -1
  49. package/src/plugins/themes/solarized-light.ts +1 -1
  50. package/src/plugins/themes/tokyo-night.ts +1 -1
  51. package/src/plugins/update-checker.ts +2 -2
  52. package/src/pricing/estimator.test.ts +1 -1
  53. package/src/pricing/estimator.ts +1 -1
  54. package/src/pricing/fallback.ts +5 -1
  55. package/src/pricing/index.ts +1 -1
  56. package/src/pricing/models-dev.ts +1 -1
  57. package/src/storage/index.ts +6 -0
  58. package/src/storage/migrations/index.ts +17 -0
  59. package/src/storage/repos/pluginStorage.ts +35 -0
  60. package/src/tui/App.tsx +17 -9
  61. package/src/tui/components/HelpOverlay.tsx +1 -1
  62. package/src/tui/components/LimitGauge.tsx +12 -4
  63. package/src/tui/components/ProviderCard.tsx +39 -23
  64. package/src/tui/components/ProviderDetailPanel.tsx +1 -1
  65. package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
  66. package/src/tui/components/SessionDetailsDrawer.tsx +10 -0
  67. package/src/tui/components/SessionsTable.tsx +62 -37
  68. package/src/tui/components/SettingsModal.tsx +1 -1
  69. package/src/tui/components/Skeleton.tsx +9 -6
  70. package/src/tui/components/SortOverlay.tsx +167 -0
  71. package/src/tui/components/Spinner.tsx +16 -11
  72. package/src/tui/components/ThemePicker.tsx +1 -1
  73. package/src/tui/contexts/AgentSessionContext.tsx +70 -17
  74. package/src/tui/contexts/PluginContext.tsx +72 -14
  75. package/src/tui/contexts/RealTimeActivityContext.tsx +33 -20
  76. package/src/tui/contexts/ThemeContext.tsx +7 -4
  77. package/src/tui/createApp.tsx +2 -0
  78. package/src/tui/hooks/index.ts +1 -0
  79. package/src/tui/hooks/useAnimatedValue.ts +29 -26
  80. package/src/tui/hooks/useAnimationTick.ts +93 -0
  81. package/src/tui/hooks/useDashboardKeyboard.ts +11 -4
  82. package/src/tui/hooks/useEntranceAnimation.ts +16 -10
  83. package/src/tui/hooks/usePulse.ts +13 -7
  84. package/src/tui/hooks/useSessionEnrichedProviders.ts +1 -1
  85. package/src/tui/hooks/useValueFlash.ts +25 -33
  86. package/src/tui/index.tsx +4 -0
  87. package/src/tui/types/projectSort.ts +35 -0
  88. package/src/tui/types/sort.ts +61 -0
  89. package/src/tui/views/ProjectsView.tsx +104 -38
  90. package/src/tui/views/RealTimeDashboard.tsx +91 -8
  91. package/src/tui/views/SettingsView.tsx +1 -1
  92. package/src/utils/__tests__/error-category.test.ts +147 -0
  93. package/src/utils/currency.ts +4 -2
  94. package/src/utils/error-category.ts +91 -0
  95. package/src/version.ts +1 -1
  96. package/src/plugins/types/agent.ts +0 -108
  97. package/src/plugins/types/base.ts +0 -278
  98. package/src/plugins/types/index.ts +0 -19
  99. package/src/plugins/types/notification.ts +0 -51
  100. package/src/plugins/types/provider.ts +0 -237
  101. package/src/plugins/types/theme.ts +0 -78
  102. package/src/storage/database.ts +0 -623
@@ -2,7 +2,7 @@ import type {
2
2
  NotificationContext,
3
3
  NotificationEvent,
4
4
  NotificationPlugin,
5
- } from "../types/notification.ts";
5
+ } from "@tokentop/plugin-sdk";
6
6
 
7
7
  export const visualFlashPlugin: NotificationPlugin = {
8
8
  apiVersion: 2,
@@ -1,27 +1,17 @@
1
+ import type { PluginContext, PluginPermissions, PluginStorage } from "@tokentop/plugin-sdk";
2
+ import { isDatabaseInitialized } from "@/storage/db.ts";
1
3
  import {
2
- getDatabase,
3
4
  pluginStorageDelete,
4
5
  pluginStorageGet,
5
6
  pluginStorageHas,
6
7
  pluginStorageSet,
7
- } from "@/storage/database.ts";
8
+ } from "@/storage/repos/pluginStorage.ts";
8
9
  import { createAuthSources } from "./auth-sources.ts";
9
10
  import { createPluginLogger, createSandboxedHttpClient } from "./sandbox.ts";
10
11
  import { deepFreeze } from "./sandbox-guard.ts";
11
- import type { PluginPermissions } from "./types/base.ts";
12
- import type { PluginContext, PluginStorage } from "./types/provider.ts";
13
-
14
- let dbAvailable: boolean | null = null;
15
12
 
16
13
  function isDbAvailable(): boolean {
17
- if (dbAvailable !== null) return dbAvailable;
18
- try {
19
- getDatabase();
20
- dbAvailable = true;
21
- } catch {
22
- dbAvailable = false;
23
- }
24
- return dbAvailable;
14
+ return isDatabaseInitialized();
25
15
  }
26
16
 
27
17
  function createPluginStorage(pluginId: string): PluginStorage {
@@ -44,11 +34,6 @@ function createPluginStorage(pluginId: string): PluginStorage {
44
34
  },
45
35
  };
46
36
  }
47
-
48
- export function resetDbAvailableCache(): void {
49
- dbAvailable = null;
50
- }
51
-
52
37
  export function createPluginContext(
53
38
  pluginId: string,
54
39
  permissions: PluginPermissions,
@@ -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
+ });
@@ -1,4 +1,3 @@
1
- import { execFileSync } from "child_process";
2
1
  import type {
3
2
  CredentialResult,
4
3
  Credentials,
@@ -8,7 +7,8 @@ import type {
8
7
  ProviderFetchContext,
9
8
  ProviderPlugin,
10
9
  ProviderUsageData,
11
- } from "../types/provider.ts";
10
+ } from "@tokentop/plugin-sdk";
11
+ import { execFileSync } from "child_process";
12
12
 
13
13
  const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
14
14
 
@@ -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(),
@@ -9,7 +9,7 @@ import type {
9
9
  ProviderUsageData,
10
10
  RefreshedCredentials,
11
11
  UsageLimit,
12
- } from "../types/provider.ts";
12
+ } from "@tokentop/plugin-sdk";
13
13
 
14
14
  const ANTIGRAVITY_ENDPOINTS = [
15
15
  "https://daily-cloudcode-pa.sandbox.googleapis.com",
@@ -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(),