@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.
- package/README.md +7 -6
- package/package.json +12 -5
- package/src/agents/aggregator.test.ts +306 -0
- package/src/agents/aggregator.ts +44 -2
- package/src/agents/types.ts +3 -0
- package/src/cli.ts +16 -2
- package/src/demo/simulator.ts +2 -2
- package/src/plugins/agents/index.ts +3 -0
- package/src/plugins/auth-sources.ts +2 -3
- package/src/plugins/lifecycle.ts +2 -2
- package/src/plugins/loader.test.ts +1 -1
- package/src/plugins/loader.ts +15 -7
- package/src/plugins/notification-bus.ts +5 -2
- package/src/plugins/notifications/terminal-bell.ts +1 -1
- package/src/plugins/notifications/visual-flash.ts +1 -1
- package/src/plugins/plugin-context-factory.ts +4 -19
- package/src/plugins/plugin-host.test.ts +197 -0
- package/src/plugins/plugin-host.ts +45 -1
- package/src/plugins/providers/anthropic.test.ts +202 -0
- package/src/plugins/providers/anthropic.ts +21 -2
- package/src/plugins/providers/antigravity.ts +17 -1
- package/src/plugins/providers/chutes.ts +205 -0
- package/src/plugins/providers/codex.ts +17 -1
- package/src/plugins/providers/gemini.ts +17 -1
- package/src/plugins/providers/github-copilot.ts +17 -1
- package/src/plugins/providers/index.ts +1 -0
- package/src/plugins/providers/minimax.ts +17 -1
- package/src/plugins/providers/openai-api.ts +53 -1
- package/src/plugins/providers/opencode-zen.ts +1 -1
- package/src/plugins/providers/perplexity.ts +11 -2
- package/src/plugins/providers/zai.ts +17 -1
- package/src/plugins/registry.ts +9 -9
- package/src/plugins/sandbox-guard.test.ts +1 -1
- package/src/plugins/sandbox-guard.ts +1 -1
- package/src/plugins/sandbox.ts +1 -1
- package/src/plugins/themes/catppuccin-latte.ts +1 -1
- package/src/plugins/themes/catppuccin-mocha.ts +1 -1
- package/src/plugins/themes/claude-code.ts +1 -1
- package/src/plugins/themes/dracula.ts +1 -1
- package/src/plugins/themes/github-light.ts +1 -1
- package/src/plugins/themes/gruvbox-dark.ts +1 -1
- package/src/plugins/themes/gruvbox-light.ts +1 -1
- package/src/plugins/themes/kanagawa.ts +1 -1
- package/src/plugins/themes/nord.ts +1 -1
- package/src/plugins/themes/one-dark.ts +1 -1
- package/src/plugins/themes/opencode.ts +1 -1
- package/src/plugins/themes/rose-pine-dawn.ts +1 -1
- package/src/plugins/themes/rose-pine.ts +1 -1
- package/src/plugins/themes/solarized-light.ts +1 -1
- package/src/plugins/themes/tokyo-night.ts +1 -1
- package/src/plugins/update-checker.ts +2 -2
- package/src/pricing/estimator.test.ts +1 -1
- package/src/pricing/estimator.ts +1 -1
- package/src/pricing/fallback.ts +5 -1
- package/src/pricing/index.ts +1 -1
- package/src/pricing/models-dev.ts +1 -1
- package/src/storage/index.ts +6 -0
- package/src/storage/migrations/index.ts +17 -0
- package/src/storage/repos/pluginStorage.ts +35 -0
- package/src/tui/App.tsx +17 -9
- package/src/tui/components/HelpOverlay.tsx +1 -1
- package/src/tui/components/LimitGauge.tsx +12 -4
- package/src/tui/components/ProviderCard.tsx +39 -23
- package/src/tui/components/ProviderDetailPanel.tsx +1 -1
- package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
- package/src/tui/components/SessionDetailsDrawer.tsx +10 -0
- package/src/tui/components/SessionsTable.tsx +62 -37
- package/src/tui/components/SettingsModal.tsx +1 -1
- package/src/tui/components/Skeleton.tsx +9 -6
- package/src/tui/components/SortOverlay.tsx +167 -0
- package/src/tui/components/Spinner.tsx +16 -11
- package/src/tui/components/ThemePicker.tsx +1 -1
- package/src/tui/contexts/AgentSessionContext.tsx +70 -17
- package/src/tui/contexts/PluginContext.tsx +72 -14
- package/src/tui/contexts/RealTimeActivityContext.tsx +33 -20
- package/src/tui/contexts/ThemeContext.tsx +7 -4
- package/src/tui/createApp.tsx +2 -0
- package/src/tui/hooks/index.ts +1 -0
- package/src/tui/hooks/useAnimatedValue.ts +29 -26
- package/src/tui/hooks/useAnimationTick.ts +93 -0
- package/src/tui/hooks/useDashboardKeyboard.ts +11 -4
- package/src/tui/hooks/useEntranceAnimation.ts +16 -10
- package/src/tui/hooks/usePulse.ts +13 -7
- package/src/tui/hooks/useSessionEnrichedProviders.ts +1 -1
- package/src/tui/hooks/useValueFlash.ts +25 -33
- package/src/tui/index.tsx +4 -0
- package/src/tui/types/projectSort.ts +35 -0
- package/src/tui/types/sort.ts +61 -0
- package/src/tui/views/ProjectsView.tsx +104 -38
- package/src/tui/views/RealTimeDashboard.tsx +91 -8
- package/src/tui/views/SettingsView.tsx +1 -1
- package/src/utils/__tests__/error-category.test.ts +147 -0
- package/src/utils/currency.ts +4 -2
- package/src/utils/error-category.ts +91 -0
- package/src/version.ts +1 -1
- package/src/plugins/types/agent.ts +0 -108
- package/src/plugins/types/base.ts +0 -278
- package/src/plugins/types/index.ts +0 -19
- package/src/plugins/types/notification.ts +0 -51
- package/src/plugins/types/provider.ts +0 -237
- package/src/plugins/types/theme.ts +0 -78
- package/src/storage/database.ts +0 -623
|
@@ -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/
|
|
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
|
-
|
|
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
|
-
//
|
|
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 "
|
|
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 "
|
|
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(),
|