import { describe, expect, it } from "vitest";
import {
classifyFailoverReason,
classifyFailoverReasonFromHttpStatus,
extractObservedOverflowTokenCount,
isAuthErrorMessage,
isAuthPermanentErrorMessage,
isBillingErrorMessage,
isCloudCodeAssistFormatError,
isCloudflareOrHtmlErrorPage,
isCompactionFailureError,
isContextOverflowError,
isFailoverErrorMessage,
isImageDimensionErrorMessage,
isLikelyContextOverflowError,
isTimeoutErrorMessage,
isTransientHttpError,
parseImageDimensionError,
parseImageSizeError,
} from "./pi-embedded-helpers.js";
// OpenAI 429 example shape: https://help.openai.com/en/articles/5955604-how-can-i-solve-429-too-many-requests-errors
const OPENAI_RATE_LIMIT_MESSAGE =
"Rate limit reached for gpt-4.1-mini in organization org_test on requests per min. Limit: 3.000000 / min. Current: 3.000000 / min.";
// Gemini RESOURCE_EXHAUSTED troubleshooting example: https://ai.google.dev/gemini-api/docs/troubleshooting
const GEMINI_RESOURCE_EXHAUSTED_MESSAGE =
"RESOURCE_EXHAUSTED: Resource has been exhausted (e.g. check quota).";
// Anthropic overloaded_error example shape: https://docs.anthropic.com/en/api/errors
const ANTHROPIC_OVERLOADED_PAYLOAD =
'{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"},"request_id":"req_test"}';
// OpenRouter 402 billing example: https://openrouter.ai/docs/api-reference/errors
const OPENROUTER_CREDITS_MESSAGE = "Payment Required: insufficient credits";
// Issue-backed Anthropic/OpenAI-compatible insufficient_quota payload under HTTP 400:
// https://github.com/openclaw/openclaw/issues/23440
const INSUFFICIENT_QUOTA_PAYLOAD =
'{"type":"error","error":{"type":"insufficient_quota","message":"Your account has insufficient quota balance to run this request."}}'; // pragma: allowlist secret
// Together AI error code examples: https://docs.together.ai/docs/error-codes
const TOGETHER_PAYMENT_REQUIRED_MESSAGE =
"402 Payment Required: The account associated with this API key has reached its maximum allowed monthly spending limit.";
const TOGETHER_ENGINE_OVERLOADED_MESSAGE =
"503 Engine Overloaded: The server is experiencing a high volume of requests and is temporarily overloaded.";
// Groq error code examples: https://console.groq.com/docs/errors
const GROQ_TOO_MANY_REQUESTS_MESSAGE =
"429 Too Many Requests: Too many requests were sent in a given timeframe.";
const GROQ_SERVICE_UNAVAILABLE_MESSAGE =
"503 Service Unavailable: The server is temporarily unable to handle the request due to overloading or maintenance."; // pragma: allowlist secret
describe("isAuthPermanentErrorMessage", () => {
it("matches permanent auth failure patterns", () => {
const samples = [
"invalid_api_key",
"api key revoked",
"api key deactivated",
"key has been disabled",
"key has been revoked",
"account has been deactivated",
"could not authenticate api key",
"could not validate credentials",
"API_KEY_REVOKED",
"api_key_deleted",
];
for (const sample of samples) {
expect(isAuthPermanentErrorMessage(sample)).toBe(true);
}
});
it("does not match transient auth errors", () => {
const samples = [
"unauthorized",
"invalid token",
"authentication failed",
"forbidden",
"access denied",
"token has expired",
];
for (const sample of samples) {
expect(isAuthPermanentErrorMessage(sample)).toBe(false);
}
});
});
describe("isAuthErrorMessage", () => {
it("matches credential validation errors", () => {
const samples = [
'No credentials found for profile "anthropic:default".',
"No API key found for profile openai.",
];
for (const sample of samples) {
expect(isAuthErrorMessage(sample)).toBe(true);
}
});
it("matches OAuth refresh failures", () => {
const samples = [
"OAuth token refresh failed for anthropic: Failed to refresh OAuth token for anthropic. Please try again or re-authenticate.",
"Please re-authenticate to continue.",
];
for (const sample of samples) {
expect(isAuthErrorMessage(sample)).toBe(true);
}
});
});
describe("isBillingErrorMessage", () => {
it("matches credit / payment failures", () => {
const samples = [
"Your credit balance is too low to access the Anthropic API.",
"insufficient credits",
"Payment Required",
"HTTP 402 Payment Required",
"plans & billing",
// Venice returns "Insufficient USD or Diem balance" which has extra words
// between "insufficient" and "balance"
"Insufficient USD or Diem balance to complete request. Visit https://venice.ai/settings/api to add credits.",
];
for (const sample of samples) {
expect(isBillingErrorMessage(sample)).toBe(true);
}
});
it("does not false-positive on issue IDs or text containing 402", () => {
const falsePositives = [
"Fixed issue CHE-402 in the latest release",
"See ticket #402 for details",
"ISSUE-402 has been resolved",
"Room 402 is available",
"Error code 403 was returned, not 402-related",
"The building at 402 Main Street",
"processed 402 records",
"402 items found in the database",
"port 402 is open",
"Use a 402 stainless bolt",
"Book a 402 room",
"There is a 402 near me",
];
for (const sample of falsePositives) {
expect(isBillingErrorMessage(sample)).toBe(false);
}
});
it("does not false-positive on long assistant responses mentioning billing keywords", () => {
// Simulate a multi-paragraph assistant response that mentions billing terms
const longResponse =
"Sure! Here's how to set up billing for your SaaS application.\n\n" +
"## Payment Integration\n\n" +
"First, you'll need to configure your payment gateway. Most providers offer " +
"a dashboard where you can manage credits, view invoices, and upgrade your plan. " +
"The billing page typically shows your current balance and payment history.\n\n" +
"## Managing Credits\n\n" +
"Users can purchase credits through the billing portal. When their credit balance " +
"runs low, send them a notification to upgrade their plan or add more credits. " +
"You should also handle insufficient balance cases gracefully.\n\n" +
"## Subscription Plans\n\n" +
"Offer multiple plan tiers with different features. Allow users to upgrade or " +
"downgrade their plan at any time. Make sure the billing cycle is clear.\n\n" +
"Let me know if you need more details on any of these topics!";
expect(longResponse.length).toBeGreaterThan(512);
expect(isBillingErrorMessage(longResponse)).toBe(false);
});
it("does not false-positive on short non-billing text that mentions insufficient and balance", () => {
const sample = "The evidence is insufficient to reconcile the final balance after compaction.";
expect(isBillingErrorMessage(sample)).toBe(false);
expect(classifyFailoverReason(sample)).toBeNull();
});
it("still matches explicit 402 markers in long payloads", () => {
const longStructuredError =
'{"error":{"code":402,"message":"payment required","details":"' + "x".repeat(700) + '"}}';
expect(longStructuredError.length).toBeGreaterThan(512);
expect(isBillingErrorMessage(longStructuredError)).toBe(true);
});
it("does not match long numeric text that is not a billing error", () => {
const longNonError =
"Quarterly report summary: subsystem A returned 402 records after retry. " +
"This is an analytics count, not an HTTP/API billing failure. " +
"Notes: " +
"x".repeat(700);
expect(longNonError.length).toBeGreaterThan(512);
expect(isBillingErrorMessage(longNonError)).toBe(false);
});
it("still matches real HTTP 402 billing errors", () => {
const realErrors = [
"HTTP 402 Payment Required",
"status: 402",
"error code 402",
"http 402",
"status=402 payment required",
"got a 402 from the API",
"returned 402",
"received a 402 response",
'{"status":402,"type":"error"}',
'{"code":402,"message":"payment required"}',
'{"error":{"code":402,"message":"billing hard limit reached"}}',
];
for (const sample of realErrors) {
expect(isBillingErrorMessage(sample)).toBe(true);
}
});
});
describe("isCloudCodeAssistFormatError", () => {
it("matches format errors", () => {
const samples = [
"INVALID_REQUEST_ERROR: string should match pattern",
"messages.1.content.1.tool_use.id",
"tool_use.id should match pattern",
"invalid request format",
];
for (const sample of samples) {
expect(isCloudCodeAssistFormatError(sample)).toBe(true);
}
});
});
describe("isCloudflareOrHtmlErrorPage", () => {
it("detects Cloudflare 521 HTML pages", () => {
const htmlError = `521
Web server is down | example.com | Cloudflare
Web server is down
`;
expect(isCloudflareOrHtmlErrorPage(htmlError)).toBe(true);
});
it("detects generic 5xx HTML pages", () => {
const htmlError = `503 Service Unavailabledown`;
expect(isCloudflareOrHtmlErrorPage(htmlError)).toBe(true);
});
it("does not flag non-HTML status lines", () => {
expect(isCloudflareOrHtmlErrorPage("500 Internal Server Error")).toBe(false);
expect(isCloudflareOrHtmlErrorPage("429 Too Many Requests")).toBe(false);
});
it("does not flag quoted HTML without a closing html tag", () => {
const plainTextWithHtmlPrefix = "500 upstream responded with partial HTML text";
expect(isCloudflareOrHtmlErrorPage(plainTextWithHtmlPrefix)).toBe(false);
});
});
describe("isCompactionFailureError", () => {
it("matches compaction overflow failures", () => {
const samples = [
'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
"auto-compaction failed due to context overflow",
"Compaction failed: prompt is too long",
"Summarization failed: context window exceeded for this request",
];
for (const sample of samples) {
expect(isCompactionFailureError(sample)).toBe(true);
}
});
it("ignores non-compaction overflow errors", () => {
expect(isCompactionFailureError("Context overflow: prompt too large")).toBe(false);
expect(isCompactionFailureError("rate limit exceeded")).toBe(false);
});
});
describe("isContextOverflowError", () => {
it("matches known overflow hints", () => {
const samples = [
"request_too_large",
"Request exceeds the maximum size",
"context length exceeded",
"Maximum context length",
"prompt is too long: 208423 tokens > 200000 maximum",
"Context overflow: Summarization failed",
"413 Request Entity Too Large",
];
for (const sample of samples) {
expect(isContextOverflowError(sample)).toBe(true);
}
});
it("matches 'exceeds model context window' in various formats", () => {
const samples = [
// Anthropic returns this JSON payload when prompt exceeds model context window.
'{"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}',
"Request size exceeds model context window",
"request size exceeds model context window",
'400 {"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}',
"The request size exceeds model context window limit",
];
for (const sample of samples) {
expect(isContextOverflowError(sample)).toBe(true);
}
});
it("matches Kimi 'model token limit' context overflow errors", () => {
const samples = [
"Invalid request: Your request exceeded model token limit: 262144 (requested: 291351)",
"error, status code: 400, message: Invalid request: Your request exceeded model token limit: 262144 (requested: 291351)",
"Your request exceeded model token limit",
];
for (const sample of samples) {
expect(isContextOverflowError(sample)).toBe(true);
}
});
it("matches exceed/context/max_tokens overflow variants", () => {
const samples = [
"input length and max_tokens exceed context limit (i.e 156321 + 48384 > 200000)",
"This request exceeds the model's maximum context length",
"LLM request rejected: max_tokens would exceed context window",
"input length would exceed context budget for this model",
];
for (const sample of samples) {
expect(isContextOverflowError(sample)).toBe(true);
}
});
it("matches model_context_window_exceeded stop reason surfaced by pi-ai", () => {
// Anthropic API (and some OpenAI-compatible providers like ZhipuAI/GLM) return
// stop_reason: "model_context_window_exceeded" when the context window is hit.
// The pi-ai library surfaces this as "Unhandled stop reason: model_context_window_exceeded".
const samples = [
"Unhandled stop reason: model_context_window_exceeded",
"model_context_window_exceeded",
"context_window_exceeded",
"Unhandled stop reason: context_window_exceeded",
];
for (const sample of samples) {
expect(isContextOverflowError(sample)).toBe(true);
}
});
it("matches Chinese context overflow error messages from proxy providers", () => {
const samples = [
"上下文过长",
"错误:上下文过长,请减少输入",
"上下文超出限制",
"上下文长度超出模型最大限制",
"超出最大上下文长度",
"请压缩上下文后重试",
];
for (const sample of samples) {
expect(isContextOverflowError(sample)).toBe(true);
}
});
it("ignores normal conversation text mentioning context overflow", () => {
// These are legitimate conversation snippets, not error messages
expect(isContextOverflowError("Let's investigate the context overflow bug")).toBe(false);
expect(isContextOverflowError("The mystery context overflow errors are strange")).toBe(false);
expect(isContextOverflowError("We're debugging context overflow issues")).toBe(false);
expect(isContextOverflowError("Something is causing context overflow messages")).toBe(false);
});
it("excludes reasoning-required invalid-request errors", () => {
const samples = [
"400 Reasoning is mandatory for this endpoint and cannot be disabled.",
'{"type":"error","error":{"type":"invalid_request_error","message":"Reasoning is mandatory for this endpoint and cannot be disabled."}}',
"This model requires reasoning to be enabled",
];
for (const sample of samples) {
expect(isContextOverflowError(sample)).toBe(false);
}
});
});
describe("error classifiers", () => {
it("ignore unrelated errors", () => {
const checks: Array<{
matcher: (message: string) => boolean;
samples: string[];
}> = [
{
matcher: isAuthErrorMessage,
samples: ["rate limit exceeded", "billing issue detected"],
},
{
matcher: isBillingErrorMessage,
samples: ["rate limit exceeded", "invalid api key", "context length exceeded"],
},
{
matcher: isCloudCodeAssistFormatError,
samples: [
"rate limit exceeded",
'400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}',
],
},
{
matcher: isContextOverflowError,
samples: [
"rate limit exceeded",
"request size exceeds upload limit",
"model not found",
"authentication failed",
],
},
];
for (const check of checks) {
for (const sample of check.samples) {
expect(check.matcher(sample)).toBe(false);
}
}
});
});
describe("isLikelyContextOverflowError", () => {
it("matches context overflow hints", () => {
const samples = [
"Model context window is 128k tokens, you requested 256k tokens",
"Context window exceeded: requested 12000 tokens",
"Prompt too large for this model",
];
for (const sample of samples) {
expect(isLikelyContextOverflowError(sample)).toBe(true);
}
});
it("excludes context window too small errors", () => {
const samples = [
"Model context window too small (minimum is 128k tokens)",
"Context window too small: minimum is 1000 tokens",
];
for (const sample of samples) {
expect(isLikelyContextOverflowError(sample)).toBe(false);
}
});
it("excludes rate limit errors that match the broad hint regex", () => {
const samples = [
"request reached organization TPD rate limit, current: 1506556, limit: 1500000",
"rate limit exceeded",
"too many requests",
"429 Too Many Requests",
"exceeded your current quota",
"This request would exceed your account's rate limit",
"429 Too Many Requests: request exceeds rate limit",
"AWS Bedrock: Too many tokens per day. Please try again tomorrow.",
];
for (const sample of samples) {
expect(isLikelyContextOverflowError(sample)).toBe(false);
}
});
it("keeps too-many-tokens-per-request context overflow errors out of the rate-limit lane", () => {
const sample = "Context window exceeded: too many tokens per request.";
expect(isLikelyContextOverflowError(sample)).toBe(true);
expect(classifyFailoverReason(sample)).toBeNull();
});
it("excludes reasoning-required invalid-request errors", () => {
const samples = [
"400 Reasoning is mandatory for this endpoint and cannot be disabled.",
'{"type":"error","error":{"type":"invalid_request_error","message":"Reasoning is mandatory for this endpoint and cannot be disabled."}}',
"This endpoint requires reasoning",
];
for (const sample of samples) {
expect(isLikelyContextOverflowError(sample)).toBe(false);
}
});
it("excludes billing errors even when text matches context overflow patterns", () => {
const samples = [
"402 Payment Required: request token limit exceeded for this billing plan",
"insufficient credits: request size exceeds your current plan limits",
"Your credit balance is too low. Maximum request token limit exceeded.",
];
for (const sample of samples) {
expect(isBillingErrorMessage(sample)).toBe(true);
expect(isLikelyContextOverflowError(sample)).toBe(false);
}
});
});
describe("extractObservedOverflowTokenCount", () => {
it("extracts provider-reported prompt token counts", () => {
expect(
extractObservedOverflowTokenCount(
'400 {"type":"error","error":{"message":"prompt is too long: 277403 tokens > 200000 maximum"}}',
),
).toBe(277403);
expect(
extractObservedOverflowTokenCount("Context window exceeded: requested 12000 tokens"),
).toBe(12000);
expect(
extractObservedOverflowTokenCount(
"This model's maximum context length is 128000 tokens. However, your messages resulted in 145000 tokens.",
),
).toBe(145000);
});
it("returns undefined when overflow counts are not present", () => {
expect(extractObservedOverflowTokenCount("Prompt too large for this model")).toBeUndefined();
expect(extractObservedOverflowTokenCount("rate limit exceeded")).toBeUndefined();
});
});
describe("isTransientHttpError", () => {
it("returns true for retryable 5xx status codes", () => {
expect(isTransientHttpError("499 Client Closed Request")).toBe(true);
expect(isTransientHttpError("500 Internal Server Error")).toBe(true);
expect(isTransientHttpError("502 Bad Gateway")).toBe(true);
expect(isTransientHttpError("503 Service Unavailable")).toBe(true);
expect(isTransientHttpError("504 Gateway Timeout")).toBe(true);
expect(isTransientHttpError("521 ")).toBe(true);
expect(isTransientHttpError("529 Overloaded")).toBe(true);
});
it("returns false for non-retryable or non-http text", () => {
expect(isTransientHttpError("429 Too Many Requests")).toBe(false);
expect(isTransientHttpError("network timeout")).toBe(false);
});
});
describe("classifyFailoverReasonFromHttpStatus", () => {
it("treats HTTP 499 as transient for structured errors", () => {
expect(classifyFailoverReasonFromHttpStatus(499)).toBe("timeout");
expect(classifyFailoverReasonFromHttpStatus(499, "499 Client Closed Request")).toBe("timeout");
expect(
classifyFailoverReasonFromHttpStatus(
499,
'{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}',
),
).toBe("overloaded");
});
});
describe("isFailoverErrorMessage", () => {
it("matches auth/rate/billing/timeout", () => {
const samples = [
"invalid api key",
"429 rate limit exceeded",
"Your credit balance is too low",
"request timed out",
"Connection error.",
"invalid request format",
];
for (const sample of samples) {
expect(isFailoverErrorMessage(sample)).toBe(true);
}
});
it("matches abort stop-reason timeout variants", () => {
const samples = [
"Unhandled stop reason: abort",
"Unhandled stop reason: error",
"stop reason: abort",
"stop reason: error",
"reason: abort",
"reason: error",
];
for (const sample of samples) {
expect(isTimeoutErrorMessage(sample)).toBe(true);
expect(classifyFailoverReason(sample)).toBe("timeout");
expect(isFailoverErrorMessage(sample)).toBe(true);
}
});
it("matches Gemini MALFORMED_RESPONSE stop reason as timeout (#42149)", () => {
const samples = [
"Unhandled stop reason: MALFORMED_RESPONSE",
"Unhandled stop reason: malformed_response",
"stop reason: MALFORMED_RESPONSE",
];
for (const sample of samples) {
expect(isTimeoutErrorMessage(sample)).toBe(true);
expect(classifyFailoverReason(sample)).toBe("timeout");
expect(isFailoverErrorMessage(sample)).toBe(true);
}
});
it("matches network errno codes in serialized error messages", () => {
const samples = [
"Error: connect ETIMEDOUT 10.0.0.1:443",
"Error: connect ESOCKETTIMEDOUT 10.0.0.1:443",
"Error: connect EHOSTUNREACH 10.0.0.1:443",
"Error: connect ENETUNREACH 10.0.0.1:443",
"Error: write EPIPE",
"Error: read ENETRESET",
"Error: connect EHOSTDOWN 192.168.1.1:443",
];
for (const sample of samples) {
expect(isTimeoutErrorMessage(sample)).toBe(true);
expect(classifyFailoverReason(sample)).toBe("timeout");
expect(isFailoverErrorMessage(sample)).toBe(true);
}
});
it("does not classify MALFORMED_FUNCTION_CALL as timeout", () => {
const sample = "Unhandled stop reason: MALFORMED_FUNCTION_CALL";
expect(isTimeoutErrorMessage(sample)).toBe(false);
expect(classifyFailoverReason(sample)).toBe(null);
expect(isFailoverErrorMessage(sample)).toBe(false);
});
});
describe("parseImageSizeError", () => {
it("parses max MB values from error text", () => {
expect(parseImageSizeError("image exceeds 5 MB maximum")?.maxMb).toBe(5);
expect(parseImageSizeError("Image exceeds 5.5 MB limit")?.maxMb).toBe(5.5);
});
it("returns null for unrelated errors", () => {
expect(parseImageSizeError("context overflow")).toBeNull();
});
});
describe("image dimension errors", () => {
it("parses anthropic image dimension errors", () => {
const raw =
'400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}';
const parsed = parseImageDimensionError(raw);
expect(parsed).not.toBeNull();
expect(parsed?.maxDimensionPx).toBe(2000);
expect(parsed?.messageIndex).toBe(84);
expect(parsed?.contentIndex).toBe(1);
expect(isImageDimensionErrorMessage(raw)).toBe(true);
});
});
describe("classifyFailoverReasonFromHttpStatus – 402 temporary limits", () => {
it("reclassifies periodic usage limits as rate_limit", () => {
const samples = [
"Monthly spend limit reached.",
"Weekly usage limit exhausted.",
"Daily limit reached, resets tomorrow.",
];
for (const sample of samples) {
expect(classifyFailoverReasonFromHttpStatus(402, sample)).toBe("rate_limit");
}
});
it("reclassifies org/workspace spend limits as rate_limit", () => {
const samples = [
"Organization spending limit exceeded.",
"Workspace spend limit reached.",
"Organization limit exceeded for this billing period.",
];
for (const sample of samples) {
expect(classifyFailoverReasonFromHttpStatus(402, sample)).toBe("rate_limit");
}
});
it("keeps 402 as billing when explicit billing signals are present", () => {
expect(
classifyFailoverReasonFromHttpStatus(
402,
"Your credit balance is too low. Monthly limit exceeded.",
),
).toBe("billing");
expect(
classifyFailoverReasonFromHttpStatus(
402,
"Insufficient credits. Organization limit reached.",
),
).toBe("billing");
expect(
classifyFailoverReasonFromHttpStatus(
402,
"The account associated with this API key has reached its maximum allowed monthly spending limit.",
),
).toBe("billing");
});
it("keeps long 402 payloads with explicit billing text as billing", () => {
const longBillingPayload = `${"x".repeat(520)} insufficient credits. Monthly spend limit reached.`;
expect(classifyFailoverReasonFromHttpStatus(402, longBillingPayload)).toBe("billing");
});
it("keeps 402 as billing without message or with generic message", () => {
expect(classifyFailoverReasonFromHttpStatus(402, undefined)).toBe("billing");
expect(classifyFailoverReasonFromHttpStatus(402, "")).toBe("billing");
expect(classifyFailoverReasonFromHttpStatus(402, "Payment required")).toBe("billing");
});
it("matches raw 402 wrappers and status-split payloads for the same message", () => {
const transientMessage = "Monthly spend limit reached. Please visit your billing settings.";
expect(classifyFailoverReason(`402 Payment Required: ${transientMessage}`)).toBe("rate_limit");
expect(classifyFailoverReasonFromHttpStatus(402, transientMessage)).toBe("rate_limit");
const billingMessage =
"The account associated with this API key has reached its maximum allowed monthly spending limit.";
expect(classifyFailoverReason(`402 Payment Required: ${billingMessage}`)).toBe("billing");
expect(classifyFailoverReasonFromHttpStatus(402, billingMessage)).toBe("billing");
});
it("keeps explicit 402 rate-limit messages in the rate_limit lane", () => {
const transientMessage = "rate limit exceeded";
expect(classifyFailoverReason(`HTTP 402 Payment Required: ${transientMessage}`)).toBe(
"rate_limit",
);
expect(classifyFailoverReasonFromHttpStatus(402, transientMessage)).toBe("rate_limit");
});
it("keeps plan-upgrade 402 limit messages in billing", () => {
const billingMessage = "Your usage limit has been reached. Please upgrade your plan.";
expect(classifyFailoverReason(`HTTP 402 Payment Required: ${billingMessage}`)).toBe("billing");
expect(classifyFailoverReasonFromHttpStatus(402, billingMessage)).toBe("billing");
});
});
describe("classifyFailoverReason", () => {
it("classifies documented provider error messages", () => {
expect(classifyFailoverReason(OPENAI_RATE_LIMIT_MESSAGE)).toBe("rate_limit");
expect(classifyFailoverReason(GEMINI_RESOURCE_EXHAUSTED_MESSAGE)).toBe("rate_limit");
expect(classifyFailoverReason(ANTHROPIC_OVERLOADED_PAYLOAD)).toBe("overloaded");
expect(classifyFailoverReason(OPENROUTER_CREDITS_MESSAGE)).toBe("billing");
expect(classifyFailoverReason(TOGETHER_PAYMENT_REQUIRED_MESSAGE)).toBe("billing");
expect(classifyFailoverReason(TOGETHER_ENGINE_OVERLOADED_MESSAGE)).toBe("overloaded");
expect(classifyFailoverReason(GROQ_TOO_MANY_REQUESTS_MESSAGE)).toBe("rate_limit");
expect(classifyFailoverReason(GROQ_SERVICE_UNAVAILABLE_MESSAGE)).toBe("overloaded");
// Venice 402 billing error with extra words between "insufficient" and "balance"
expect(
classifyFailoverReason(
"Insufficient USD or Diem balance to complete request. Visit https://venice.ai/settings/api to add credits.",
),
).toBe("billing");
});
it("classifies internal and compatibility error messages", () => {
expect(classifyFailoverReason("invalid api key")).toBe("auth");
expect(classifyFailoverReason("no credentials found")).toBe("auth");
expect(classifyFailoverReason("no api key found")).toBe("auth");
expect(
classifyFailoverReason(
'No API key found for provider "openai". Auth store: /tmp/openclaw-agent-abc/auth-profiles.json (agentDir: /tmp/openclaw-agent-abc).',
),
).toBe("auth");
expect(classifyFailoverReason("You have insufficient permissions for this operation.")).toBe(
"auth",
);
expect(classifyFailoverReason("Missing scopes: model.request")).toBe("auth");
expect(
classifyFailoverReason("model_cooldown: All credentials for model gpt-5 are cooling down"),
).toBe("rate_limit");
expect(classifyFailoverReason("all credentials for model x are cooling down")).toBeNull();
expect(classifyFailoverReason("invalid request format")).toBe("format");
expect(classifyFailoverReason("credit balance too low")).toBe("billing");
// Billing with "limit exhausted" must stay billing, not rate_limit (avoids key-disable regression)
expect(
classifyFailoverReason("HTTP 402 payment required. Your limit exhausted for this plan."),
).toBe("billing");
expect(classifyFailoverReason("402 Payment Required: Weekly/Monthly Limit Exhausted")).toBe(
"billing",
);
// Poe returns 402 without "payment required"; must be recognized for fallback
expect(
classifyFailoverReason(
"402 You've used up your points! Visit https://poe.com/api/keys to get more.",
),
).toBe("billing");
expect(classifyFailoverReason(INSUFFICIENT_QUOTA_PAYLOAD)).toBe("billing");
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
expect(classifyFailoverReason("request ended without sending any chunks")).toBe("timeout");
expect(classifyFailoverReason("Connection error.")).toBe("timeout");
expect(classifyFailoverReason("fetch failed")).toBe("timeout");
expect(classifyFailoverReason("network error: ECONNREFUSED")).toBe("timeout");
expect(
classifyFailoverReason("dial tcp: lookup api.example.com: no such host (ENOTFOUND)"),
).toBe("timeout");
expect(classifyFailoverReason("temporary dns failure EAI_AGAIN")).toBe("timeout");
expect(
classifyFailoverReason(
"521 Web server is downCloudflare",
),
).toBe("timeout");
expect(classifyFailoverReason("string should match pattern")).toBe("format");
expect(classifyFailoverReason("bad request")).toBeNull();
expect(
classifyFailoverReason(
"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels",
),
).toBeNull();
expect(classifyFailoverReason("image exceeds 5 MB maximum")).toBeNull();
});
it("classifies OpenAI usage limit errors as rate_limit", () => {
expect(classifyFailoverReason("You have hit your ChatGPT usage limit (plus plan)")).toBe(
"rate_limit",
);
});
it("classifies AWS Bedrock too-many-tokens-per-day errors as rate_limit", () => {
expect(
classifyFailoverReason("AWS Bedrock: Too many tokens per day. Please try again tomorrow."),
).toBe("rate_limit");
});
it("classifies provider high-demand / service-unavailable messages as overloaded", () => {
expect(
classifyFailoverReason(
"This model is currently experiencing high demand. Please try again later.",
),
).toBe("overloaded");
// "service unavailable" combined with overload/capacity indicator → overloaded
// (exercises the new regex — none of the standalone patterns match here)
expect(classifyFailoverReason("service unavailable due to capacity limits")).toBe("overloaded");
expect(
classifyFailoverReason(
'{"error":{"code":503,"message":"The model is overloaded. Please try later","status":"UNAVAILABLE"}}',
),
).toBe("overloaded");
});
it("classifies bare 'service unavailable' as timeout instead of rate_limit (#32828)", () => {
// A generic "service unavailable" from a proxy/CDN should stay retryable,
// but it should not be treated as provider overload / rate limit.
expect(classifyFailoverReason("LLM error: service unavailable")).toBe("timeout");
expect(classifyFailoverReason("503 Internal Database Error")).toBe("timeout");
// Raw 529 text without explicit overload keywords still classifies as overloaded.
expect(classifyFailoverReason("529 API is busy")).toBe("overloaded");
expect(classifyFailoverReason("529 Please try again")).toBe("overloaded");
});
it("classifies zhipuai Weekly/Monthly Limit Exhausted as rate_limit (#33785)", () => {
expect(
classifyFailoverReason(
"LLM error 1310: Weekly/Monthly Limit Exhausted. Your limit will reset at 2026-03-06 22:19:54 (request_id: 20260303141547610b7f574d1b44cb)",
),
).toBe("rate_limit");
// Independent coverage for broader periodic limit patterns.
expect(classifyFailoverReason("LLM error: weekly/monthly limit reached")).toBe("rate_limit");
expect(classifyFailoverReason("LLM error: monthly limit reached")).toBe("rate_limit");
expect(classifyFailoverReason("LLM error: daily limit exceeded")).toBe("rate_limit");
});
it("classifies permanent auth errors as auth_permanent", () => {
expect(classifyFailoverReason("invalid_api_key")).toBe("auth_permanent");
expect(classifyFailoverReason("Your api key has been revoked")).toBe("auth_permanent");
expect(classifyFailoverReason("key has been disabled")).toBe("auth_permanent");
expect(classifyFailoverReason("account has been deactivated")).toBe("auth_permanent");
});
it("classifies JSON api_error internal server failures as timeout", () => {
expect(
classifyFailoverReason(
'{"type":"error","error":{"type":"api_error","message":"Internal server error"}}',
),
).toBe("timeout");
});
});