Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/lib/providers/anthropic/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@ import {
ProviderError,
RateLimitError,
} from "../../types/index.js";
import { classifyProviderError } from "../../utils/errorClassifier.js";
import {
classifyProviderError,
messageNamesStatus,
} from "../../utils/errorClassifier.js";
import { logger } from "../../utils/logger.js";
import { drainDetachedPump } from "../../utils/drainDetachedPump.js";
import {
Expand Down Expand Up @@ -1834,7 +1837,8 @@ export class AnthropicProvider extends BaseProvider {
{
match: (ctx) =>
ctx.statusCode === 429 ||
/rate limit|too_many_requests|429/i.test(ctx.message),
/rate limit|too_many_requests/i.test(ctx.message) ||
messageNamesStatus(ctx.message, 429),
errorClass: RateLimitError,
message: "Anthropic rate limit exceeded. Please try again later.",
},
Expand Down
20 changes: 13 additions & 7 deletions src/lib/providers/azureOpenai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { logger } from "../utils/logger.js";
import {
classifyProviderError,
DEFAULT_ERROR_RULES,
messageNamesStatus,
} from "../utils/errorClassifier.js";
import {
createAzureAPIKeyConfig,
Expand Down Expand Up @@ -205,15 +206,20 @@ export class AzureOpenAIProvider extends OpenAIChatCompletionsProvider {
}

protected formatProviderError(error: unknown): Error {
// RULING 1 fix: previously only a raw "401" substring was special-cased —
// everything else (429, 404, network errors, 5xx) fell through to one
// generic ProviderError. The 401 branch below is preserved byte-for-byte
// (message text + the raw-substring match style); DEFAULT_ERROR_RULES now
// gives 429/404/network/5xx their correct classifications instead of the
// old undifferentiated catch-all.
// RULING 1 fix: previously only a "401" in the message text was
// special-cased — everything else (429, 404, network errors, 5xx) fell
// through to one generic ProviderError. The 401 branch below keeps its
// message text byte-for-byte; DEFAULT_ERROR_RULES now gives
// 429/404/network/5xx their correct classifications instead of the old
// undifferentiated catch-all. The branch still reads the text, not the
// structured status, so a 401 whose message names no status still falls
// through to DEFAULT_ERROR_RULES as before. What changed is the match: a
// status-shaped "401" ("status code 401", a leading "401 ...") rather than
// any three digits, so a request id or token count no longer reads as an
// auth failure.
const rules: ProviderErrorRule[] = [
{
match: (ctx) => /401/.test(ctx.message),
match: (ctx) => messageNamesStatus(ctx.message, 401),
errorClass: AuthenticationError,
message: "Invalid Azure OpenAI API key or endpoint.",
},
Expand Down
15 changes: 10 additions & 5 deletions src/lib/providers/googleVertex/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ import {
ProviderError,
RateLimitError,
} from "../../types/index.js";
import { classifyProviderError } from "../../utils/errorClassifier.js";
import {
classifyProviderError,
messageNamesStatus,
} from "../../utils/errorClassifier.js";
import { ERROR_CODES, NeuroLinkError } from "../../utils/errorHandling.js";
import { applyVertexAnthropicCacheBreakpoints } from "../../utils/anthropicCacheBreakpoints.js";
import { FileDetector } from "../../utils/fileDetector.js";
Expand Down Expand Up @@ -8429,9 +8432,8 @@ export class GoogleVertexProvider extends BaseProvider {
// operational meaning as a 429, so classify it here instead of the
// generic 5xx branch below.
match: (ctx) =>
/QUOTA_EXCEEDED|RATE_LIMIT_EXCEEDED|rate limit|429/i.test(
ctx.message,
) ||
/QUOTA_EXCEEDED|RATE_LIMIT_EXCEEDED|rate limit/i.test(ctx.message) ||
messageNamesStatus(ctx.message, 429) ||
statusCode === 429 ||
statusCode === 529 ||
/overloaded/i.test(ctx.message),
Expand Down Expand Up @@ -8483,9 +8485,12 @@ export class GoogleVertexProvider extends BaseProvider {
},
{
match: (ctx) =>
/500|502|503|504|server error|Internal Server Error|INTERNAL|UNAVAILABLE/i.test(
/server error|Internal Server Error|INTERNAL|UNAVAILABLE/i.test(
ctx.message,
) ||
[500, 502, 503, 504].some((code) =>
messageNamesStatus(ctx.message, code),
) ||
(statusCode !== undefined && statusCode >= 500 && statusCode < 600),
errorClass: ProviderError,
message: (ctx) =>
Expand Down
16 changes: 12 additions & 4 deletions src/lib/providers/nvidiaNim/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import {
classifyProviderError,
DEFAULT_ERROR_RULES,
messageNamesStatus,
} from "../../utils/errorClassifier.js";
import { logger } from "../../utils/logger.js";
import { redactUrlCredentials } from "../../utils/logSanitize.js";
Expand Down Expand Up @@ -337,27 +338,34 @@ export class NvidiaNimProvider extends OpenAIChatCompletionsProvider {
// parameter retry (K5) need the original "Bad Request" message to
// surface.
{
match: (ctx) => /Invalid API key|401|Unauthorized/.test(ctx.message),
match: (ctx) =>
/Invalid API key|Unauthorized/.test(ctx.message) ||
messageNamesStatus(ctx.message, 401),
errorClass: AuthenticationError,
message:
"Invalid NVIDIA NIM API key. Get one at https://build.nvidia.com/settings/api-keys",
},
{
match: (ctx) => /rate limit|429/.test(ctx.message),
match: (ctx) =>
/rate limit/.test(ctx.message) ||
messageNamesStatus(ctx.message, 429),
errorClass: RateLimitError,
message: "NVIDIA NIM rate limit exceeded",
},
{
// NIM answers most of its roster with a 404 whose text ("Function …
// not found for account …") names no model, so the status decides.
match: (ctx) =>
ctx.statusCode === 404 || /404|model_not_found/.test(ctx.message),
ctx.statusCode === 404 ||
/model_not_found/.test(ctx.message) ||
messageNamesStatus(ctx.message, 404),
errorClass: InvalidModelError,
message: () =>
`NVIDIA NIM model '${this.modelName}' not available. Browse the catalog at https://build.nvidia.com/models`,
},
{
match: (ctx) => /quota|403/.test(ctx.message),
match: (ctx) =>
/quota/.test(ctx.message) || messageNamesStatus(ctx.message, 403),
errorClass: ProviderError,
message: "NVIDIA NIM quota exceeded for your account",
},
Expand Down
31 changes: 31 additions & 0 deletions src/lib/utils/errorClassifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,37 @@ const MODEL_404_TEXT = new RegExp(
"i",
);

/**
* True when `status` is written in `message` as an HTTP status, as opposed to
* an unrelated number that happens to have the same digits ("max_tokens (429)
* exceeds the model limit", a request id, a token count). Provider rules that
* once tested the bare digits (`/429/`) classified such a 400 as a rate limit.
*
* Status-shaped means one of:
* - the text starts with it, the way some SDKs format an API error
* ("429 {...}", "401 Unauthorized");
* - it follows "status", "status code" or "HTTP" ("Request failed with status
* code 429", "HTTP/1.1 429");
* - it sits next to the word "error" in either order ("error 429", "429 error",
* and the nested JSON body `{"error":{"code":429`).
*
* The gap between the word and the number is bounded and holds no digit, so
* unrelated digits further along cannot bridge the match. This is the shape the
* shared 5xx rule in `DEFAULT_ERROR_RULES` already uses, so the two stay in
* step. It is a heuristic over text: a rule that can read the structured
* status should still check `ctx.statusCode` first.
*/
export function messageNamesStatus(message: string, status: number): boolean {
if (!Number.isInteger(status) || status < 100 || status > 599) {
return false;
}
const code = String(status);
return new RegExp(
`^\\s*${code}\\b|\\b(?:error|status(?:\\s*code)?)\\b\\D{0,12}\\b${code}\\b|\\bhttp(?:/\\d(?:\\.\\d)?)?[\\s:-]{0,3}${code}\\b|\\b${code}\\b\\D{0,12}\\berror\\b`,
"i",
).test(message);
}

/**
* Generic fallback rule table covering the five categories every
* OpenAI-compatible provider already hand-rolled near-identically:
Expand Down
115 changes: 115 additions & 0 deletions test/continuous-test-suite-error-classification-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1404,6 +1404,121 @@ async function main(): Promise<void> {
messageIncludes: ["Server error:"],
});

// =========================================================================
// SECTION: a status number in the message text (NIM, Anthropic, Azure)
// -------------------------------------------------------------------------
// These providers' own rules once tested the bare digits ("429", "401",
// "404", "403") against the message, so an unrelated number in a vendor
// text ("(429)" in a validation message, an id, a count) classified a 400
// as a rate limit, an auth failure, a missing model or a quota stop. The
// rules now accept a number only in a status-shaped position, so:
// - every "unrelated number" case below answers a 400 and must fall
// through to the generic error, not to the provider's own override;
// - every "named status" case answers a 400 whose TEXT names the
// status (a relay wrapping an upstream reply: "HTTP 429", "status code
// 401", a leading "403 ..."), which the override still has to catch.
// The structured status is 400 there, so only the text rule can
// produce the override message these cases look for.
// Every case here answers 400, which is not retried, so each is one
// request and the classified message is the thrown one. Google Vertex has
// the same rule change but no case here: it cannot be driven through a
// local server (see its section below).
// =========================================================================
{
setEnv("NVIDIA_NIM_API_KEY", "test-fake-nvidia-nim-credential");
setEnv("NVIDIA_NIM_BASE_URL", mockOrigin);
setEnv("ANTHROPIC_API_KEY", "test-fake-anthropic-credential");
setEnv("ANTHROPIC_BASE_URL", mockOrigin);
setEnv("AZURE_OPENAI_API_KEY", "test-fake-azure-credential");
setEnv("AZURE_OPENAI_ENDPOINT", mockOrigin);
setEnv("AZURE_API_VERSION", undefined);
setEnv("AZURE_OPENAI_MODEL", undefined);
setEnv("AZURE_OPENAI_DEPLOYMENT", undefined);
setEnv("AZURE_OPENAI_DEPLOYMENT_ID", undefined);

const nimModel = "meta/llama-3.1-8b-instruct";
const nimCases = [
{
code: 429,
label: "rate limit",
override: "NVIDIA NIM rate limit exceeded",
named: "relay failed with status code 429",
},
{
code: 401,
label: "invalid key",
override: "build.nvidia.com/settings/api-keys",
named: "401 key rejected by the model host",
},
{
code: 404,
label: "model not available",
override: "build.nvidia.com/models",
named: "upstream returned error 404",
},
{
code: 403,
label: "quota",
override: "NVIDIA NIM quota exceeded",
named: "upstream HTTP 403 relayed",
},
];
for (const c of nimCases) {
setHandler(
jsonError(400, `the offset value (${c.code}) is not accepted here`),
);
await expectGenerateError({
name: `nvidia-nim: an unrelated ${c.code} in a 400's text is not read as ${c.label}`,
run: () => gen({ provider: "nvidia-nim", model: nimModel }),
messageIncludes: ["nvidia-nim error:"],
messageExcludes: [c.override],
});
setHandler(jsonError(400, c.named));
await expectGenerateError({
name: `nvidia-nim: a 400 whose text names status ${c.code} is still read as ${c.label}`,
run: () => gen({ provider: "nvidia-nim", model: nimModel }),
messageIncludes: [c.override],
});
}

const anthropicModel = "claude-3-5-sonnet-20241022";
setHandler(jsonError(400, "the offset value (429) is not accepted here"));
await expectGenerateError({
name: "anthropic: an unrelated 429 in a 400's text is not read as a rate limit",
run: () => gen({ provider: "anthropic", model: anthropicModel }),
messageIncludes: ["Anthropic error:"],
messageExcludes: ["Anthropic rate limit exceeded"],
});
setHandler(jsonError(400, "upstream HTTP 429 relayed by the gateway"));
await expectGenerateError({
name: "anthropic: a 400 whose text names HTTP 429 is still read as a rate limit",
run: () => gen({ provider: "anthropic", model: anthropicModel }),
messageIncludes: ["Anthropic rate limit exceeded"],
});

setHandler(jsonError(400, "the offset value (401) is not accepted here"));
await expectGenerateError({
name: "azure: an unrelated 401 in a 400's text is not read as an auth failure",
run: () => gen({ provider: "azure", model: "gpt-4o" }),
messageIncludes: ["azure error:"],
messageExcludes: ["Invalid Azure OpenAI API key or endpoint."],
});
setHandler(jsonError(400, "401 key rejected by the gateway"));
await expectGenerateError({
name: "azure: a 400 whose text starts with 401 is still read as an auth failure",
run: () => gen({ provider: "azure", model: "gpt-4o" }),
expectClass: AuthenticationError,
messageIncludes: ["Invalid Azure OpenAI API key or endpoint."],
});
setHandler(jsonError(400, "gateway answered HTTP 401 for this call"));
await expectGenerateError({
name: "azure: a 400 whose text names HTTP 401 is still read as an auth failure",
run: () => gen({ provider: "azure", model: "gpt-4o" }),
expectClass: AuthenticationError,
messageIncludes: ["Invalid Azure OpenAI API key or endpoint."],
});
}

// =========================================================================
// SECTION: Google Vertex (old File3 #8-11) — VERTEX EXCEPTION
// -------------------------------------------------------------------------
Expand Down
Loading