-
Notifications
You must be signed in to change notification settings - Fork 309
Expand file tree
/
Copy pathshared.test.ts
More file actions
326 lines (287 loc) · 13.8 KB
/
Copy pathshared.test.ts
File metadata and controls
326 lines (287 loc) · 13.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
classifyQuotaError,
isTransientError,
isUsingAiGateway,
parseInvestigateResults,
parseRefusalReport,
parseRevalidateVerdicts,
QuotaExhaustedError,
} from "../agents/shared.js";
describe("isTransientError", () => {
it("flags 5xx, 429, eager_input_streaming, ECONNRESET", () => {
expect(isTransientError("HTTP 503 Service Unavailable")).toBe(true);
expect(isTransientError("HTTP 429 too many requests")).toBe(true);
expect(isTransientError("Extra inputs are not permitted: eager_input_streaming")).toBe(true);
expect(isTransientError("ECONNRESET fetch failed")).toBe(true);
expect(isTransientError("rate-limit hit")).toBe(true);
expect(isTransientError("overloaded")).toBe(true);
});
it("doesn't flag obvious permanent errors", () => {
expect(isTransientError("ENOENT no such file")).toBe(false);
expect(isTransientError("invalid api key")).toBe(false);
});
it("does NOT classify quota errors as transient (defense-in-depth)", () => {
// A 429 carrying an `insufficient_quota` body would otherwise loop in
// the retry budget; classifyQuotaError takes precedence so we don't
// burn attempts on a permanently-empty wallet.
expect(isTransientError("HTTP 429 insufficient_quota: You exceeded your current quota")).toBe(
false,
);
expect(isTransientError("Claude AI usage limit reached for the week")).toBe(false);
});
});
describe("classifyQuotaError", () => {
// Strings in this block were extracted from the actual platform binaries
// shipped via @anthropic-ai/claude-agent-sdk-darwin-arm64 (claude) and
// @openai/codex/vendor/.../codex/codex (codex Rust binary), via
// `strings | grep`. Treat these tests as ground truth — if a binary
// upgrade breaks them, the regexes need updating.
it("Claude binary: 'Credit balance is too low' (with 'is')", () => {
expect(classifyQuotaError("Credit balance is too low", "claude")).toBe("anthropic-credits");
expect(
classifyQuotaError("Your credit balance is too low to access the Claude API", "claude"),
).toBe("anthropic-credits");
});
it("Claude binary: 'Credit balance too low' (without 'is') + funds URL", () => {
expect(
classifyQuotaError(
"Credit balance too low · Add funds: https://platform.claude.com/settings/billing",
"claude",
),
).toBe("anthropic-credits");
});
it("Anthropic structured tags: billing_error / out_of_credits / credit_balance_low", () => {
expect(classifyQuotaError('{"type":"billing_error","message":"…"}', "claude")).toBe(
"anthropic-credits",
);
expect(classifyQuotaError("event=out_of_credits", "claude")).toBe("anthropic-credits");
expect(classifyQuotaError("error_code: credit_balance_low", "claude")).toBe(
"anthropic-credits",
);
});
it("Claude subscription prose ('usage limit', 'weekly limit') — needs hint to disambiguate", () => {
// Same prose appears in both binaries; the agent hint resolves source.
expect(classifyQuotaError("usage limit reached", "claude")).toBe("claude-subscription");
expect(classifyQuotaError("Hit the weekly limit", "claude")).toBe("claude-subscription");
expect(classifyQuotaError("/upgrade to increase your usage limit.", "claude")).toBe(
"claude-subscription",
);
// Without a hint, it's still classified — falls back to 'unknown' so
// the run still bails (the alternative is to silently retry, which
// wastes the budget).
expect(classifyQuotaError("usage limit reached")).toBe("unknown");
});
it("Codex binary: canonical 'You've hit your usage limit' phrasing", () => {
expect(classifyQuotaError("You've hit your usage limit.", "codex")).toBe("openai-subscription");
expect(
classifyQuotaError(
"You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus)",
"codex",
),
).toBe("openai-subscription");
expect(
classifyQuotaError(
"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits",
"codex",
),
).toBe("openai-subscription");
expect(
classifyQuotaError(
"You've hit your usage limit. To get more access now, send a request to your admin",
"codex",
),
).toBe("openai-subscription");
});
it("Codex internal tags: workspace_*_credits_depleted / *_usage_limit_reached / usageLimitExceeded", () => {
expect(classifyQuotaError("workspace_owner_credits_depleted", "codex")).toBe(
"openai-subscription",
);
expect(classifyQuotaError("workspace_member_credits_depleted", "codex")).toBe(
"openai-subscription",
);
expect(classifyQuotaError("workspace_owner_usage_limit_reached", "codex")).toBe(
"openai-subscription",
);
expect(classifyQuotaError("usage_limit_exceeded", "codex")).toBe("openai-subscription");
expect(classifyQuotaError("usageLimitExceeded", "codex")).toBe("openai-subscription");
});
it("Direct OpenAI API: insufficient_quota / 'You exceeded your current quota'", () => {
expect(classifyQuotaError("HTTP 429 insufficient_quota", "codex")).toBe("openai-quota");
expect(
classifyQuotaError(
"You exceeded your current quota, please check your plan and billing",
"codex",
),
).toBe("openai-quota");
});
it("Vercel AI Gateway: literal strings extracted from gateway source code", () => {
// Strings extracted from vercel/ai-gateway. These are the exact
// bodies the gateway emits to clients (see check-billing.ts:33-47,
// check-quota-entity.ts:77-85, check-video-eligibility.ts:151-308).
// The gateway WRAPS upstream provider errors rather than passing them
// through, so client-facing bodies always carry these canonical types.
// HTTP 402 — insufficient_funds (the headline scenario).
expect(
classifyQuotaError(
'{"error":{"message":"Insufficient funds. Please add credits to your account to continue using AI services. Visit https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dtop-up to top up your credits.","type":"insufficient_funds"}}',
),
).toBe("gateway-credits");
expect(classifyQuotaError("error.type=insufficient_funds")).toBe("gateway-credits");
// HTTP 403 — customer_verification_required (no card on file).
expect(
classifyQuotaError("AI Gateway requires a valid credit card on file to service requests."),
).toBe("gateway-credits");
expect(classifyQuotaError("type: customer_verification_required")).toBe("gateway-credits");
// HTTP 429 — admin-set quota for an entity.
expect(
classifyQuotaError(
'Quota limit exceeded for "team-x". Current spend: $50.00, limit: $50.00. Please contact your administrator to increase the quota.',
),
).toBe("gateway-credits");
expect(classifyQuotaError("type: quota_for_entity_exceeded")).toBe("gateway-credits");
});
it("Vercel AI Gateway prose wins over provider attribution in the same body", () => {
// The gateway WRAPS provider errors, but if any historical/loose
// pattern leaks through with provider names alongside gateway
// attribution, we want gateway-credits to win.
expect(classifyQuotaError("AI Gateway: insufficient credits")).toBe("gateway-credits");
expect(classifyQuotaError("ai_gateway: payment required")).toBe("gateway-credits");
expect(classifyQuotaError("insufficient credits")).toBe("gateway-credits");
});
it("returns undefined for non-quota errors so retry/transient logic still applies", () => {
expect(classifyQuotaError("HTTP 503 Service Unavailable")).toBeUndefined();
expect(classifyQuotaError("ECONNRESET")).toBeUndefined();
expect(classifyQuotaError("ENOENT")).toBeUndefined();
expect(classifyQuotaError("")).toBeUndefined();
});
it("falls back to 'unknown' for bare HTTP 402 with payment-required", () => {
expect(classifyQuotaError("HTTP 402 Payment Required (insufficient)")).toBe("unknown");
});
});
describe("QuotaExhaustedError", () => {
it("carries source + raw, and the message includes both", () => {
const e = new QuotaExhaustedError("anthropic-credits", "Your credit balance is too low");
expect(e.source).toBe("anthropic-credits");
expect(e.rawMessage).toContain("credit balance");
expect(e.message).toContain("anthropic-credits");
expect(e.name).toBe("QuotaExhaustedError");
expect(e instanceof Error).toBe(true);
});
});
describe("isUsingAiGateway", () => {
// We mutate process.env directly — capture and restore so tests don't
// bleed environment into each other.
const KEYS = ["AI_GATEWAY_API_KEY", "ANTHROPIC_BASE_URL", "OPENAI_BASE_URL"] as const;
const saved: Record<string, string | undefined> = {};
beforeEach(() => {
for (const k of KEYS) saved[k] = process.env[k];
for (const k of KEYS) delete process.env[k];
});
afterEach(() => {
for (const k of KEYS) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
});
it("returns true when AI_GATEWAY_API_KEY is set", () => {
process.env.AI_GATEWAY_API_KEY = "vck_x";
expect(isUsingAiGateway()).toBe(true);
});
it("returns true when ANTHROPIC_BASE_URL points at the gateway", () => {
process.env.ANTHROPIC_BASE_URL = "https://ai-gateway.vercel.sh";
expect(isUsingAiGateway()).toBe(true);
});
it("returns true when OPENAI_BASE_URL points at the gateway (with trailing slash)", () => {
process.env.OPENAI_BASE_URL = "https://ai-gateway.vercel.sh/v1/";
expect(isUsingAiGateway()).toBe(true);
});
it("returns false when neither base URL points at the gateway", () => {
process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com";
process.env.OPENAI_BASE_URL = "https://api.openai.com/v1";
expect(isUsingAiGateway()).toBe(false);
});
it("returns false when no relevant env vars are set", () => {
expect(isUsingAiGateway()).toBe(false);
});
});
describe("parseRefusalReport", () => {
it("parses fenced JSON with refused: true", () => {
const raw =
'```json\n{"refused": true, "reason": "policy", "skipped": [{"filePath":"a.ts","reason":"x"}]}\n```';
const r = parseRefusalReport(raw);
expect(r?.refused).toBe(true);
expect(r?.reason).toBe("policy");
expect(r?.skipped).toEqual([{ filePath: "a.ts", reason: "x" }]);
});
it("parses bare JSON with refused: false", () => {
const r = parseRefusalReport('{"refused": false, "skipped": []}');
expect(r?.refused).toBe(false);
expect(r?.skipped).toEqual([]);
});
it("falls back to heuristic on non-JSON refusal text", () => {
const r = parseRefusalReport("I can't analyze this content.");
expect(r?.refused).toBe(true);
expect(r?.reason).toContain("heuristic");
});
it("returns undefined on empty input", () => {
expect(parseRefusalReport("")).toBeUndefined();
});
});
describe("parseInvestigateResults", () => {
const batch = [{ filePath: "a.ts" } as any, { filePath: "b.ts" } as any];
it("matches results to batch files; fills missing with empty findings", () => {
const text =
'```json\n[{"filePath":"a.ts","findings":[{"severity":"HIGH","vulnSlug":"x","title":"t","description":"d","lineNumbers":[1],"recommendation":"r","confidence":"high"}]}]\n```';
const out = parseInvestigateResults(text, batch);
expect(out.find((r) => r.filePath === "a.ts")?.findings.length).toBe(1);
expect(out.find((r) => r.filePath === "b.ts")?.findings).toEqual([]);
});
it("throws on parse failure (fail-loud, never silently empty)", () => {
// Silently returning empty findings on malformed JSON would mask
// model truncation, prompt-injection-driven non-JSON output, and
// gateway splices — all of which are indistinguishable from a
// legitimate clean result. The processor's batch-level catch
// converts this throw into batchesFailed++ + status=error.
expect(() => parseInvestigateResults("not JSON at all", batch)).toThrow(
/wasn't a parseable JSON findings array/,
);
});
it("throws when the JSON parses but isn't an array", () => {
expect(() => parseInvestigateResults('```json\n{"oops":"object"}\n```', batch)).toThrow(
/not an array/,
);
});
it("throws when a finding has invalid severity", () => {
const text =
'```json\n[{"filePath":"a.ts","findings":[{"severity":"INVALID","vulnSlug":"x","title":"t","description":"d","lineNumbers":[1],"recommendation":"r","confidence":"high"}]}]\n```';
expect(() => parseInvestigateResults(text, batch)).toThrow(/schema validation/);
});
it("throws when a finding has invalid confidence", () => {
const text =
'```json\n[{"filePath":"a.ts","findings":[{"severity":"HIGH","vulnSlug":"x","title":"t","description":"d","lineNumbers":[1],"recommendation":"r","confidence":"INVALID"}]}]\n```';
expect(() => parseInvestigateResults(text, batch)).toThrow(/schema validation/);
});
it("throws when a finding is missing a required field", () => {
const text =
'```json\n[{"filePath":"a.ts","findings":[{"severity":"HIGH","vulnSlug":"x"}]}]\n```';
expect(() => parseInvestigateResults(text, batch)).toThrow(/schema validation/);
});
});
describe("parseRevalidateVerdicts", () => {
it("parses verdicts from fenced JSON", () => {
const text =
'```json\n[{"filePath":"a.ts","title":"x","verdict":"true-positive","reasoning":"r"}]\n```';
const v = parseRevalidateVerdicts(text);
expect(v).toHaveLength(1);
expect(v[0].verdict).toBe("true-positive");
});
it("throws on parse failure", () => {
expect(() => parseRevalidateVerdicts("garbage")).toThrow(/wasn't parseable JSON/);
});
it("throws when verdict is invalid", () => {
const text =
'```json\n[{"filePath":"a.ts","title":"x","verdict":"INVALID","reasoning":"r"}]\n```';
expect(() => parseRevalidateVerdicts(text)).toThrow(/schema validation/);
});
});