diff --git a/src/lib/providers/anthropic/client.ts b/src/lib/providers/anthropic/client.ts index a4a3248470..9f3b87b91b 100644 --- a/src/lib/providers/anthropic/client.ts +++ b/src/lib/providers/anthropic/client.ts @@ -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 { @@ -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.", }, diff --git a/src/lib/providers/azureOpenai.ts b/src/lib/providers/azureOpenai.ts index 30b4295580..954b40c0a9 100644 --- a/src/lib/providers/azureOpenai.ts +++ b/src/lib/providers/azureOpenai.ts @@ -9,6 +9,7 @@ import { logger } from "../utils/logger.js"; import { classifyProviderError, DEFAULT_ERROR_RULES, + messageNamesStatus, } from "../utils/errorClassifier.js"; import { createAzureAPIKeyConfig, @@ -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.", }, diff --git a/src/lib/providers/googleVertex/client.ts b/src/lib/providers/googleVertex/client.ts index 07d5e785e4..f1e96995fa 100644 --- a/src/lib/providers/googleVertex/client.ts +++ b/src/lib/providers/googleVertex/client.ts @@ -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"; @@ -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), @@ -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) => diff --git a/src/lib/providers/nvidiaNim/client.ts b/src/lib/providers/nvidiaNim/client.ts index 0bd436b64b..079d59f045 100644 --- a/src/lib/providers/nvidiaNim/client.ts +++ b/src/lib/providers/nvidiaNim/client.ts @@ -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"; @@ -337,13 +338,17 @@ 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", }, @@ -351,13 +356,16 @@ export class NvidiaNimProvider extends OpenAIChatCompletionsProvider { // 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", }, diff --git a/src/lib/utils/errorClassifier.ts b/src/lib/utils/errorClassifier.ts index b2e13907a9..874b39b4c9 100644 --- a/src/lib/utils/errorClassifier.ts +++ b/src/lib/utils/errorClassifier.ts @@ -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: diff --git a/test/continuous-test-suite-error-classification-e2e.ts b/test/continuous-test-suite-error-classification-e2e.ts index 7d5ad43a58..c5452478f0 100644 --- a/test/continuous-test-suite-error-classification-e2e.ts +++ b/test/continuous-test-suite-error-classification-e2e.ts @@ -1404,6 +1404,121 @@ async function main(): Promise { 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 // -------------------------------------------------------------------------