@tokentop/ttop 0.5.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 +8 -6
- package/src/agents/aggregator.test.ts +306 -0
- package/src/agents/aggregator.ts +40 -5
- package/src/cli.ts +14 -0
- package/src/demo/simulator.ts +1 -1
- package/src/plugins/agents/index.ts +1 -0
- package/src/plugins/lifecycle.ts +1 -1
- 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 +19 -0
- package/src/plugins/providers/antigravity.ts +16 -0
- package/src/plugins/providers/chutes.ts +16 -0
- package/src/plugins/providers/codex.ts +16 -0
- package/src/plugins/providers/gemini.ts +16 -0
- package/src/plugins/providers/github-copilot.ts +16 -0
- package/src/plugins/providers/minimax.ts +16 -0
- package/src/plugins/providers/openai-api.ts +52 -0
- package/src/plugins/providers/perplexity.ts +10 -1
- package/src/plugins/providers/zai.ts +16 -0
- package/src/pricing/fallback.ts +4 -0
- package/src/tui/App.tsx +16 -8
- package/src/tui/components/HelpOverlay.tsx +1 -1
- package/src/tui/components/LimitGauge.tsx +12 -4
- package/src/tui/components/ProviderCard.tsx +38 -22
- package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
- package/src/tui/components/SessionsTable.tsx +54 -36
- 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/contexts/AgentSessionContext.tsx +69 -16
- package/src/tui/contexts/PluginContext.tsx +64 -10
- package/src/tui/contexts/ThemeContext.tsx +4 -1
- 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/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/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
|
@@ -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
|
+
});
|
|
@@ -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(),
|
|
@@ -109,6 +109,22 @@ export const chutesPlugin: ProviderPlugin = {
|
|
|
109
109
|
if (!response.ok) {
|
|
110
110
|
log.warn("Failed to fetch Chutes usage", { status: response.status });
|
|
111
111
|
|
|
112
|
+
if (response.status === 429) {
|
|
113
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
114
|
+
const parsedRetryAfter = retryAfterHeader
|
|
115
|
+
? Number.parseInt(retryAfterHeader, 10)
|
|
116
|
+
: Number.NaN;
|
|
117
|
+
const retrySec =
|
|
118
|
+
Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0 ? parsedRetryAfter : 300;
|
|
119
|
+
log.warn("Rate limited by Chutes API", { retryAfterHeader, retryAfterSec: retrySec });
|
|
120
|
+
return {
|
|
121
|
+
fetchedAt: Date.now(),
|
|
122
|
+
error: `Rate limited. Retry after ${retrySec}s.`,
|
|
123
|
+
rateLimited: true,
|
|
124
|
+
retryAfterMs: retrySec * 1000,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
112
128
|
if (response.status === 401 || response.status === 403) {
|
|
113
129
|
return {
|
|
114
130
|
fetchedAt: Date.now(),
|
|
@@ -178,6 +178,22 @@ export const codexPlugin: ProviderPlugin = {
|
|
|
178
178
|
const bodyText = await response.text().catch(() => "");
|
|
179
179
|
log.warn("Failed to fetch ChatGPT usage", { status: response.status, body: bodyText });
|
|
180
180
|
|
|
181
|
+
if (response.status === 429) {
|
|
182
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
183
|
+
const parsedRetryAfter = retryAfterHeader
|
|
184
|
+
? Number.parseInt(retryAfterHeader, 10)
|
|
185
|
+
: Number.NaN;
|
|
186
|
+
const retrySec =
|
|
187
|
+
Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0 ? parsedRetryAfter : 300;
|
|
188
|
+
log.warn("Rate limited by ChatGPT API", { retryAfterHeader, retryAfterSec: retrySec });
|
|
189
|
+
return {
|
|
190
|
+
fetchedAt: Date.now(),
|
|
191
|
+
error: `Rate limited. Retry after ${retrySec}s.`,
|
|
192
|
+
rateLimited: true,
|
|
193
|
+
retryAfterMs: retrySec * 1000,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
181
197
|
if (response.status === 401) {
|
|
182
198
|
return {
|
|
183
199
|
fetchedAt: Date.now(),
|
|
@@ -212,6 +212,22 @@ export const geminiPlugin: ProviderPlugin = {
|
|
|
212
212
|
});
|
|
213
213
|
|
|
214
214
|
if (!response.ok) {
|
|
215
|
+
if (response.status === 429) {
|
|
216
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
217
|
+
const parsedRetryAfter = retryAfterHeader
|
|
218
|
+
? Number.parseInt(retryAfterHeader, 10)
|
|
219
|
+
: Number.NaN;
|
|
220
|
+
const retrySec =
|
|
221
|
+
Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0 ? parsedRetryAfter : 300;
|
|
222
|
+
log.warn("Rate limited by Google API", { retryAfterHeader, retryAfterSec: retrySec });
|
|
223
|
+
return {
|
|
224
|
+
fetchedAt: Date.now(),
|
|
225
|
+
error: `Rate limited. Retry after ${retrySec}s.`,
|
|
226
|
+
rateLimited: true,
|
|
227
|
+
retryAfterMs: retrySec * 1000,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
215
231
|
const errorText = await response.text().catch(() => "");
|
|
216
232
|
if (response.status === 401) {
|
|
217
233
|
return {
|
|
@@ -134,6 +134,22 @@ export const githubCopilotPlugin: ProviderPlugin = {
|
|
|
134
134
|
});
|
|
135
135
|
|
|
136
136
|
if (!response.ok) {
|
|
137
|
+
if (response.status === 429) {
|
|
138
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
139
|
+
const parsedRetryAfter = retryAfterHeader
|
|
140
|
+
? Number.parseInt(retryAfterHeader, 10)
|
|
141
|
+
: Number.NaN;
|
|
142
|
+
const retrySec =
|
|
143
|
+
Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0 ? parsedRetryAfter : 300;
|
|
144
|
+
log.warn("Rate limited by GitHub API", { retryAfterHeader, retryAfterSec: retrySec });
|
|
145
|
+
return {
|
|
146
|
+
fetchedAt: Date.now(),
|
|
147
|
+
error: `Rate limited. Retry after ${retrySec}s.`,
|
|
148
|
+
rateLimited: true,
|
|
149
|
+
retryAfterMs: retrySec * 1000,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
137
153
|
if (response.status === 404) {
|
|
138
154
|
return {
|
|
139
155
|
planType: "None",
|
|
@@ -151,6 +151,22 @@ function createMinimaxPlugin(id: string, name: string): ProviderPlugin {
|
|
|
151
151
|
if (!response.ok) {
|
|
152
152
|
log.warn("Failed to fetch MiniMax usage", { status: response.status });
|
|
153
153
|
|
|
154
|
+
if (response.status === 429) {
|
|
155
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
156
|
+
const parsedRetryAfter = retryAfterHeader
|
|
157
|
+
? Number.parseInt(retryAfterHeader, 10)
|
|
158
|
+
: Number.NaN;
|
|
159
|
+
const retrySec =
|
|
160
|
+
Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0 ? parsedRetryAfter : 300;
|
|
161
|
+
log.warn("Rate limited by MiniMax API", { retryAfterHeader, retryAfterSec: retrySec });
|
|
162
|
+
return {
|
|
163
|
+
fetchedAt: Date.now(),
|
|
164
|
+
error: `Rate limited. Retry after ${retrySec}s.`,
|
|
165
|
+
rateLimited: true,
|
|
166
|
+
retryAfterMs: retrySec * 1000,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
154
170
|
if (response.status === 401 || response.status === 403) {
|
|
155
171
|
return {
|
|
156
172
|
fetchedAt: Date.now(),
|