Files
openclaw/src/agents/pi-embedded-helpers/errors.ts
2026-04-07 13:01:23 +01:00

1315 lines
41 KiB
TypeScript

import type { AssistantMessage } from "@mariozechner/pi-ai";
import type { OpenClawConfig } from "../../config/config.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import {
extractLeadingHttpStatus,
formatRawAssistantErrorForUi,
isCloudflareOrHtmlErrorPage,
parseApiErrorInfo,
parseApiErrorPayload,
} from "../../shared/assistant-error-format.js";
import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js";
export {
extractLeadingHttpStatus,
formatRawAssistantErrorForUi,
isCloudflareOrHtmlErrorPage,
parseApiErrorInfo,
} from "../../shared/assistant-error-format.js";
import { formatExecDeniedUserMessage } from "../exec-approval-result.js";
import { stripInternalRuntimeContext } from "../internal-runtime-context.js";
import { formatSandboxToolPolicyBlockedMessage } from "../sandbox/runtime-status.js";
import { stableStringify } from "../stable-stringify.js";
import {
isAuthErrorMessage,
isAuthPermanentErrorMessage,
isBillingErrorMessage,
isOverloadedErrorMessage,
isPeriodicUsageLimitErrorMessage,
isRateLimitErrorMessage,
isServerErrorMessage,
isTimeoutErrorMessage,
matchesFormatErrorPattern,
} from "./failover-matches.js";
import {
classifyProviderSpecificError,
matchesProviderContextOverflow,
} from "./provider-error-patterns.js";
import type { FailoverReason } from "./types.js";
export {
isAuthErrorMessage,
isAuthPermanentErrorMessage,
isBillingErrorMessage,
isOverloadedErrorMessage,
isRateLimitErrorMessage,
isServerErrorMessage,
isTimeoutErrorMessage,
} from "./failover-matches.js";
const log = createSubsystemLogger("errors");
export function formatBillingErrorMessage(provider?: string, model?: string): string {
const providerName = provider?.trim();
const modelName = model?.trim();
const providerLabel =
providerName && modelName ? `${providerName} (${modelName})` : providerName || undefined;
if (providerLabel) {
return `⚠️ ${providerLabel} returned a billing error — your API key has run out of credits or has an insufficient balance. Check your ${providerName} billing dashboard and top up or switch to a different API key.`;
}
return "⚠️ API provider returned a billing error — your API key has run out of credits or has an insufficient balance. Check your provider's billing dashboard and top up or switch to a different API key.";
}
export const BILLING_ERROR_USER_MESSAGE = formatBillingErrorMessage();
const RATE_LIMIT_ERROR_USER_MESSAGE = "⚠️ API rate limit reached. Please try again later.";
const OVERLOADED_ERROR_USER_MESSAGE =
"The AI service is temporarily overloaded. Please try again in a moment.";
/**
* Check whether the raw rate-limit error contains provider-specific details
* worth surfacing (e.g. reset times, plan names, quota info). Bare status
* codes like "429" or generic phrases like "rate limit exceeded" are not
* considered specific enough.
*/
const RATE_LIMIT_SPECIFIC_HINT_RE =
/\bmin(ute)?s?\b|\bhours?\b|\bseconds?\b|\btry again in\b|\breset\b|\bplan\b|\bquota\b/i;
function extractProviderRateLimitMessage(raw: string): string | undefined {
const withoutPrefix = raw.replace(ERROR_PREFIX_RE, "").trim();
// Try to pull a human-readable message out of a JSON error payload first.
const info = parseApiErrorInfo(raw) ?? parseApiErrorInfo(withoutPrefix);
// When the raw string is not a JSON payload, strip any leading HTTP status
// code (e.g. "429 ") so the surfaced message stays clean.
const candidate =
info?.message ?? (extractLeadingHttpStatus(withoutPrefix)?.rest || withoutPrefix);
if (!candidate || !RATE_LIMIT_SPECIFIC_HINT_RE.test(candidate)) {
return undefined;
}
// Skip HTML/Cloudflare error pages even if the body mentions quota/plan text.
if (isCloudflareOrHtmlErrorPage(withoutPrefix)) {
return undefined;
}
// Avoid surfacing very long or clearly non-human-readable blobs.
const trimmed = candidate.trim();
if (
trimmed.length > 300 ||
trimmed.startsWith("{") ||
/^(?:<!doctype\s+html\b|<html\b)/i.test(trimmed)
) {
return undefined;
}
return `⚠️ ${trimmed}`;
}
function formatRateLimitOrOverloadedErrorCopy(raw: string): string | undefined {
if (isRateLimitErrorMessage(raw)) {
// Surface the provider's specific message when it contains actionable
// details (reset time, plan name, quota info) instead of the generic copy.
return extractProviderRateLimitMessage(raw) ?? RATE_LIMIT_ERROR_USER_MESSAGE;
}
if (isOverloadedErrorMessage(raw)) {
return OVERLOADED_ERROR_USER_MESSAGE;
}
return undefined;
}
function formatTransportErrorCopy(raw: string): string | undefined {
if (!raw) {
return undefined;
}
const lower = raw.toLowerCase();
if (
/\beconnrefused\b/i.test(raw) ||
lower.includes("connection refused") ||
lower.includes("actively refused")
) {
return "LLM request failed: connection refused by the provider endpoint.";
}
if (
/\beconnreset\b|\beconnaborted\b|\benetreset\b|\bepipe\b/i.test(raw) ||
lower.includes("socket hang up") ||
lower.includes("connection reset") ||
lower.includes("connection aborted")
) {
return "LLM request failed: network connection was interrupted.";
}
if (
/\benotfound\b|\beai_again\b/i.test(raw) ||
lower.includes("getaddrinfo") ||
lower.includes("no such host") ||
lower.includes("dns")
) {
return "LLM request failed: DNS lookup for the provider endpoint failed.";
}
if (
/\benetunreach\b|\behostunreach\b|\behostdown\b/i.test(raw) ||
lower.includes("network is unreachable") ||
lower.includes("host is unreachable")
) {
return "LLM request failed: the provider endpoint is unreachable from this host.";
}
if (
lower.includes("fetch failed") ||
lower.includes("connection error") ||
lower.includes("network request failed")
) {
return "LLM request failed: network connection error.";
}
return undefined;
}
function formatDiskSpaceErrorCopy(raw: string): string | undefined {
if (!raw) {
return undefined;
}
const lower = raw.toLowerCase();
if (
/\benospc\b/i.test(raw) ||
lower.includes("no space left on device") ||
lower.includes("disk full")
) {
return (
"OpenClaw could not write local session data because the disk is full. " +
"Free some disk space and try again."
);
}
return undefined;
}
export function isReasoningConstraintErrorMessage(raw: string): boolean {
if (!raw) {
return false;
}
const lower = raw.toLowerCase();
return (
lower.includes("reasoning is mandatory") ||
lower.includes("reasoning is required") ||
lower.includes("requires reasoning") ||
(lower.includes("reasoning") && lower.includes("cannot be disabled"))
);
}
function isInvalidStreamingEventOrderError(raw: string): boolean {
if (!raw) {
return false;
}
const lower = raw.toLowerCase();
return (
lower.includes("unexpected event order") &&
lower.includes("message_start") &&
lower.includes("message_stop")
);
}
function hasRateLimitTpmHint(raw: string): boolean {
const lower = raw.toLowerCase();
return /\btpm\b/i.test(lower) || lower.includes("tokens per minute");
}
export function isContextOverflowError(errorMessage?: string): boolean {
if (!errorMessage) {
return false;
}
const lower = errorMessage.toLowerCase();
// Groq uses 413 for TPM (tokens per minute) limits, which is a rate limit, not context overflow.
if (hasRateLimitTpmHint(errorMessage)) {
return false;
}
if (isReasoningConstraintErrorMessage(errorMessage)) {
return false;
}
const hasRequestSizeExceeds = lower.includes("request size exceeds");
const hasContextWindow =
lower.includes("context window") ||
lower.includes("context length") ||
lower.includes("maximum context length");
return (
lower.includes("request_too_large") ||
(lower.includes("invalid_argument") && lower.includes("maximum number of tokens")) ||
lower.includes("request exceeds the maximum size") ||
lower.includes("context length exceeded") ||
lower.includes("maximum context length") ||
lower.includes("prompt is too long") ||
lower.includes("prompt too long") ||
lower.includes("exceeds model context window") ||
lower.includes("model token limit") ||
(lower.includes("input exceeds") && lower.includes("maximum number of tokens")) ||
(hasRequestSizeExceeds && hasContextWindow) ||
lower.includes("context overflow:") ||
lower.includes("exceed context limit") ||
lower.includes("exceeds the model's maximum context") ||
(lower.includes("max_tokens") && lower.includes("exceed") && lower.includes("context")) ||
(lower.includes("input length") && lower.includes("exceed") && lower.includes("context")) ||
(lower.includes("413") && lower.includes("too large")) ||
// Anthropic API and OpenAI-compatible providers (e.g. ZhipuAI/GLM) return this stop reason
// when the context window is exceeded. pi-ai surfaces it as "Unhandled stop reason: model_context_window_exceeded".
lower.includes("context_window_exceeded") ||
// Chinese proxy error messages for context overflow
errorMessage.includes("上下文过长") ||
errorMessage.includes("上下文超出") ||
errorMessage.includes("上下文长度超") ||
errorMessage.includes("超出最大上下文") ||
errorMessage.includes("请压缩上下文") ||
// Provider-specific patterns (Bedrock, Azure, Ollama, Mistral, Cohere, etc.)
matchesProviderContextOverflow(errorMessage)
);
}
const CONTEXT_WINDOW_TOO_SMALL_RE = /context window.*(too small|minimum is)/i;
const CONTEXT_OVERFLOW_HINT_RE =
/context.*overflow|context window.*(too (?:large|long)|exceed|over|limit|max(?:imum)?|requested|sent|tokens)|prompt.*(too (?:large|long)|exceed|over|limit|max(?:imum)?)|(?:request|input).*(?:context|window|length|token).*(too (?:large|long)|exceed|over|limit|max(?:imum)?)/i;
const RATE_LIMIT_HINT_RE =
/rate limit|too many requests|requests per (?:minute|hour|day)|quota|throttl|429\b|tokens per day/i;
export function isLikelyContextOverflowError(errorMessage?: string): boolean {
if (!errorMessage) {
return false;
}
// Groq uses 413 for TPM (tokens per minute) limits, which is a rate limit, not context overflow.
if (hasRateLimitTpmHint(errorMessage)) {
return false;
}
if (isReasoningConstraintErrorMessage(errorMessage)) {
return false;
}
// Billing/quota errors can contain patterns like "request size exceeds" or
// "maximum token limit exceeded" that match the context overflow heuristic.
// Billing is a more specific error class — exclude it early.
if (isBillingErrorMessage(errorMessage)) {
return false;
}
if (CONTEXT_WINDOW_TOO_SMALL_RE.test(errorMessage)) {
return false;
}
// Rate limit errors can match the broad CONTEXT_OVERFLOW_HINT_RE pattern
// (e.g., "request reached organization TPD rate limit" matches request.*limit).
// Exclude them before checking context overflow heuristics.
if (isRateLimitErrorMessage(errorMessage)) {
return false;
}
if (isContextOverflowError(errorMessage)) {
return true;
}
if (RATE_LIMIT_HINT_RE.test(errorMessage)) {
return false;
}
return CONTEXT_OVERFLOW_HINT_RE.test(errorMessage);
}
export function isCompactionFailureError(errorMessage?: string): boolean {
if (!errorMessage) {
return false;
}
const lower = errorMessage.toLowerCase();
const hasCompactionTerm =
lower.includes("summarization failed") ||
lower.includes("auto-compaction") ||
lower.includes("compaction failed") ||
lower.includes("compaction");
if (!hasCompactionTerm) {
return false;
}
// Treat any likely overflow shape as a compaction failure when compaction terms are present.
// Providers often vary wording (e.g. "context window exceeded") across APIs.
if (isLikelyContextOverflowError(errorMessage)) {
return true;
}
// Keep explicit fallback for bare "context overflow" strings.
return lower.includes("context overflow");
}
const OBSERVED_OVERFLOW_TOKEN_PATTERNS = [
/prompt is too long:\s*([\d,]+)\s+tokens\s*>\s*[\d,]+\s+maximum/i,
/requested\s+([\d,]+)\s+tokens/i,
/resulted in\s+([\d,]+)\s+tokens/i,
];
export function extractObservedOverflowTokenCount(errorMessage?: string): number | undefined {
if (!errorMessage) {
return undefined;
}
for (const pattern of OBSERVED_OVERFLOW_TOKEN_PATTERNS) {
const match = errorMessage.match(pattern);
const rawCount = match?.[1]?.replaceAll(",", "");
if (!rawCount) {
continue;
}
const parsed = Number(rawCount);
if (Number.isFinite(parsed) && parsed > 0) {
return Math.floor(parsed);
}
}
return undefined;
}
const FINAL_TAG_RE = /<\s*\/?\s*final\s*>/gi;
const ERROR_PREFIX_RE =
/^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|codex\s*error|request failed|failed|exception)(?:\s+\d{3})?[:\s-]+/i;
const CONTEXT_OVERFLOW_ERROR_HEAD_RE =
/^(?:context overflow:|request_too_large\b|request size exceeds\b|request exceeds the maximum size\b|context length exceeded\b|maximum context length\b|prompt is too long\b|exceeds model context window\b)/i;
const TRANSIENT_HTTP_ERROR_CODES = new Set([499, 500, 502, 503, 504, 521, 522, 523, 524, 529]);
const HTTP_ERROR_HINTS = [
"error",
"bad request",
"not found",
"unauthorized",
"forbidden",
"internal server",
"service unavailable",
"gateway",
"rate limit",
"overloaded",
"timeout",
"timed out",
"invalid",
"too many requests",
"permission",
];
type PaymentRequiredFailoverReason = Extract<FailoverReason, "billing" | "rate_limit">;
export type FailoverSignal = {
status?: number;
code?: string;
message?: string;
provider?: string;
};
export type FailoverClassification =
| {
kind: "reason";
reason: FailoverReason;
}
| {
kind: "context_overflow";
};
const BILLING_402_HINTS = [
"insufficient credits",
"insufficient quota",
"credit balance",
"insufficient balance",
"plans & billing",
"add more credits",
"top up",
] as const;
const BILLING_402_PLAN_HINTS = [
"upgrade your plan",
"upgrade plan",
"current plan",
"subscription",
] as const;
const PERIODIC_402_HINTS = ["daily", "weekly", "monthly"] as const;
const RETRYABLE_402_RETRY_HINTS = ["try again", "retry", "temporary", "cooldown"] as const;
const RETRYABLE_402_LIMIT_HINTS = ["usage limit", "rate limit", "organization usage"] as const;
const RETRYABLE_402_SCOPED_HINTS = ["organization", "workspace"] as const;
const RETRYABLE_402_SCOPED_RESULT_HINTS = [
"billing period",
"exceeded",
"reached",
"exhausted",
] as const;
const RAW_402_MARKER_RE =
/["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\s+payment required\b|^\s*402\s+.*used up your points\b/i;
const LEADING_402_WRAPPER_RE =
/^(?:error[:\s-]+)?(?:(?:http\s*)?402(?:\s+payment required)?|payment required)(?:[:\s-]+|$)/i;
const TIMEOUT_ERROR_CODES = new Set([
"ETIMEDOUT",
"ESOCKETTIMEDOUT",
"ECONNRESET",
"ECONNABORTED",
"ECONNREFUSED",
"ENETUNREACH",
"EHOSTUNREACH",
"EHOSTDOWN",
"ENETRESET",
"EPIPE",
"EAI_AGAIN",
]);
function includesAnyHint(text: string, hints: readonly string[]): boolean {
return hints.some((hint) => text.includes(hint));
}
function hasExplicit402BillingSignal(text: string): boolean {
return (
includesAnyHint(text, BILLING_402_HINTS) ||
(includesAnyHint(text, BILLING_402_PLAN_HINTS) && text.includes("limit")) ||
text.includes("billing hard limit") ||
text.includes("hard limit reached") ||
(text.includes("maximum allowed") && text.includes("limit"))
);
}
function hasQuotaRefreshWindowSignal(text: string): boolean {
return (
text.includes("subscription quota limit") &&
(text.includes("automatic quota refresh") || text.includes("rolling time window"))
);
}
function hasRetryable402TransientSignal(text: string): boolean {
const hasPeriodicHint = includesAnyHint(text, PERIODIC_402_HINTS);
const hasSpendLimit = text.includes("spend limit") || text.includes("spending limit");
const hasScopedHint = includesAnyHint(text, RETRYABLE_402_SCOPED_HINTS);
return (
(includesAnyHint(text, RETRYABLE_402_RETRY_HINTS) &&
includesAnyHint(text, RETRYABLE_402_LIMIT_HINTS)) ||
(hasPeriodicHint && (text.includes("usage limit") || hasSpendLimit)) ||
(hasPeriodicHint && text.includes("limit") && text.includes("reset")) ||
(hasScopedHint &&
text.includes("limit") &&
(hasSpendLimit || includesAnyHint(text, RETRYABLE_402_SCOPED_RESULT_HINTS)))
);
}
function normalize402Message(raw: string): string {
return normalizeOptionalLowercaseString(raw)?.replace(LEADING_402_WRAPPER_RE, "").trim() ?? "";
}
function classify402Message(message: string): PaymentRequiredFailoverReason {
const normalized = normalize402Message(message);
if (!normalized) {
return "billing";
}
if (hasQuotaRefreshWindowSignal(normalized)) {
return "rate_limit";
}
if (hasExplicit402BillingSignal(normalized)) {
return "billing";
}
if (isRateLimitErrorMessage(normalized)) {
return "rate_limit";
}
if (hasRetryable402TransientSignal(normalized)) {
return "rate_limit";
}
return "billing";
}
function classifyFailoverReasonFrom402Text(raw: string): PaymentRequiredFailoverReason | null {
if (!RAW_402_MARKER_RE.test(raw)) {
return null;
}
return classify402Message(raw);
}
function toReasonClassification(reason: FailoverReason): FailoverClassification {
return { kind: "reason", reason };
}
function failoverReasonFromClassification(
classification: FailoverClassification | null,
): FailoverReason | null {
return classification?.kind === "reason" ? classification.reason : null;
}
export function isTransientHttpError(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
const status = extractLeadingHttpStatus(trimmed);
if (!status) {
return false;
}
return TRANSIENT_HTTP_ERROR_CODES.has(status.code);
}
export function classifyFailoverReasonFromHttpStatus(
status: number | undefined,
message?: string,
opts?: { provider?: string },
): FailoverReason | null {
const messageClassification = message
? classifyFailoverClassificationFromMessage(message, opts?.provider)
: null;
return failoverReasonFromClassification(
classifyFailoverClassificationFromHttpStatus(status, message, messageClassification),
);
}
function classifyFailoverClassificationFromHttpStatus(
status: number | undefined,
message: string | undefined,
messageClassification: FailoverClassification | null,
): FailoverClassification | null {
const messageReason = failoverReasonFromClassification(messageClassification);
if (typeof status !== "number" || !Number.isFinite(status)) {
return null;
}
if (status === 402) {
return toReasonClassification(message ? classify402Message(message) : "billing");
}
if (status === 429) {
return toReasonClassification("rate_limit");
}
if (status === 401 || status === 403) {
if (message && isAuthPermanentErrorMessage(message)) {
return toReasonClassification("auth_permanent");
}
// billing message on 401/403 takes precedence over generic auth (e.g. OpenRouter
// "Key limit exceeded" 401/403 should trigger model fallback, not auth)
if (messageReason === "billing") {
return toReasonClassification("billing");
}
return toReasonClassification("auth");
}
if (status === 408) {
return toReasonClassification("timeout");
}
if (status === 410) {
// Generic 410/no-body responses behave like transport failures, not session expiry.
if (
messageReason === "session_expired" ||
messageReason === "billing" ||
messageReason === "auth_permanent" ||
messageReason === "auth"
) {
return messageClassification;
}
return toReasonClassification("timeout");
}
if (status === 404) {
if (messageClassification?.kind === "context_overflow") {
return messageClassification;
}
if (
messageReason === "session_expired" ||
messageReason === "billing" ||
messageReason === "auth_permanent" ||
messageReason === "auth"
) {
return messageClassification;
}
return toReasonClassification("model_not_found");
}
if (status === 503) {
if (messageReason === "overloaded") {
return messageClassification;
}
return toReasonClassification("timeout");
}
if (status === 499) {
if (messageReason === "overloaded") {
return messageClassification;
}
return toReasonClassification("timeout");
}
if (status === 500 || status === 502 || status === 504) {
return toReasonClassification("timeout");
}
if (status === 529) {
return toReasonClassification("overloaded");
}
if (status === 400 || status === 422) {
// 400/422 are ambiguous: inspect the payload first so provider-specific
// rate limits, auth failures, model-not-found errors, and billing signals
// are not collapsed into generic "format" failures.
if (messageClassification) {
return messageClassification;
}
return toReasonClassification("format");
}
return null;
}
function classifyFailoverReasonFromCode(raw: string | undefined): FailoverReason | null {
const normalized = raw?.trim().toUpperCase();
if (!normalized) {
return null;
}
switch (normalized) {
case "RESOURCE_EXHAUSTED":
case "RATE_LIMIT":
case "RATE_LIMITED":
case "RATE_LIMIT_EXCEEDED":
case "TOO_MANY_REQUESTS":
case "THROTTLED":
case "THROTTLING":
case "THROTTLINGEXCEPTION":
case "THROTTLING_EXCEPTION":
return "rate_limit";
case "OVERLOADED":
case "OVERLOADED_ERROR":
return "overloaded";
default:
return TIMEOUT_ERROR_CODES.has(normalized) ? "timeout" : null;
}
}
function isProvider(provider: string | undefined, match: string): boolean {
const normalized = normalizeOptionalLowercaseString(provider);
return Boolean(normalized && normalized.includes(match));
}
function isAnthropicGenericUnknownError(raw: string, provider?: string): boolean {
return (
isProvider(provider, "anthropic") && raw.toLowerCase().includes("an unknown error occurred")
);
}
function isOpenRouterProviderReturnedError(raw: string, provider?: string): boolean {
return (
isProvider(provider, "openrouter") && raw.toLowerCase().includes("provider returned error")
);
}
function isOpenRouterKeyLimitExceededError(raw: string, provider?: string): boolean {
return (
isProvider(provider, "openrouter") && /\bkey\s+limit\s*(?:exceeded|reached|hit)\b/i.test(raw)
);
}
function classifyFailoverClassificationFromMessage(
raw: string,
provider?: string,
): FailoverClassification | null {
if (isImageDimensionErrorMessage(raw)) {
return null;
}
if (isImageSizeError(raw)) {
return null;
}
if (isCliSessionExpiredErrorMessage(raw)) {
return toReasonClassification("session_expired");
}
if (isModelNotFoundErrorMessage(raw)) {
return toReasonClassification("model_not_found");
}
if (isContextOverflowError(raw)) {
return { kind: "context_overflow" };
}
const reasonFrom402Text = classifyFailoverReasonFrom402Text(raw);
if (reasonFrom402Text) {
return toReasonClassification(reasonFrom402Text);
}
if (isOpenRouterKeyLimitExceededError(raw, provider)) {
return toReasonClassification("billing");
}
if (isPeriodicUsageLimitErrorMessage(raw)) {
return toReasonClassification(isBillingErrorMessage(raw) ? "billing" : "rate_limit");
}
if (isRateLimitErrorMessage(raw)) {
return toReasonClassification("rate_limit");
}
if (isOverloadedErrorMessage(raw)) {
return toReasonClassification("overloaded");
}
if (isTransientHttpError(raw)) {
const status = extractLeadingHttpStatus(raw.trim());
if (status?.code === 529) {
return toReasonClassification("overloaded");
}
return toReasonClassification("timeout");
}
// Billing and auth classifiers run before the broad isJsonApiInternalServerError
// check so that provider errors like {"type":"api_error","message":"insufficient
// balance"} are correctly classified as "billing"/"auth" rather than "timeout".
if (isBillingErrorMessage(raw)) {
return toReasonClassification("billing");
}
if (isAuthPermanentErrorMessage(raw)) {
return toReasonClassification("auth_permanent");
}
if (isAuthErrorMessage(raw)) {
return toReasonClassification("auth");
}
if (isAnthropicGenericUnknownError(raw, provider)) {
return toReasonClassification("timeout");
}
if (isOpenRouterProviderReturnedError(raw, provider)) {
return toReasonClassification("timeout");
}
if (isServerErrorMessage(raw)) {
return toReasonClassification("timeout");
}
if (isJsonApiInternalServerError(raw)) {
return toReasonClassification("timeout");
}
if (isCloudCodeAssistFormatError(raw)) {
return toReasonClassification("format");
}
if (isTimeoutErrorMessage(raw)) {
return toReasonClassification("timeout");
}
// Provider-specific patterns as a final catch (Bedrock, Groq, Together AI, etc.)
const providerSpecific = classifyProviderSpecificError(raw);
if (providerSpecific) {
return toReasonClassification(providerSpecific);
}
return null;
}
export function classifyFailoverSignal(signal: FailoverSignal): FailoverClassification | null {
const inferredStatus =
typeof signal.status === "number" && Number.isFinite(signal.status)
? signal.status
: extractLeadingHttpStatus(signal.message?.trim() ?? "")?.code;
const messageClassification = signal.message
? classifyFailoverClassificationFromMessage(signal.message, signal.provider)
: null;
const statusClassification = classifyFailoverClassificationFromHttpStatus(
inferredStatus,
signal.message,
messageClassification,
);
if (statusClassification) {
return statusClassification;
}
const codeReason = classifyFailoverReasonFromCode(signal.code);
if (codeReason) {
return toReasonClassification(codeReason);
}
return messageClassification;
}
function coerceText(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (value == null) {
return "";
}
if (
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint" ||
typeof value === "symbol"
) {
return String(value);
}
if (typeof value === "object") {
try {
return JSON.stringify(value) ?? "";
} catch {
return "";
}
}
return "";
}
function stripFinalTagsFromText(text: unknown): string {
const normalized = coerceText(text);
if (!normalized) {
return normalized;
}
return normalized.replace(FINAL_TAG_RE, "");
}
function collapseConsecutiveDuplicateBlocks(text: string): string {
const trimmed = text.trim();
if (!trimmed) {
return text;
}
const blocks = trimmed.split(/\n{2,}/);
if (blocks.length < 2) {
return text;
}
const normalizeBlock = (value: string) => value.trim().replace(/\s+/g, " ");
const result: string[] = [];
let lastNormalized: string | null = null;
for (const block of blocks) {
const normalized = normalizeBlock(block);
if (lastNormalized && normalized === lastNormalized) {
continue;
}
result.push(block.trim());
lastNormalized = normalized;
}
if (result.length === blocks.length) {
return text;
}
return result.join("\n\n");
}
function isLikelyHttpErrorText(raw: string): boolean {
if (isCloudflareOrHtmlErrorPage(raw)) {
return true;
}
const status = extractLeadingHttpStatus(raw);
if (!status) {
return false;
}
if (status.code < 400) {
return false;
}
const message = status.rest.toLowerCase();
return HTTP_ERROR_HINTS.some((hint) => message.includes(hint));
}
function shouldRewriteContextOverflowText(raw: string): boolean {
if (!isContextOverflowError(raw)) {
return false;
}
return (
isRawApiErrorPayload(raw) ||
isLikelyHttpErrorText(raw) ||
ERROR_PREFIX_RE.test(raw) ||
CONTEXT_OVERFLOW_ERROR_HEAD_RE.test(raw)
);
}
export function getApiErrorPayloadFingerprint(raw?: string): string | null {
if (!raw) {
return null;
}
const payload = parseApiErrorPayload(raw);
if (!payload) {
return null;
}
return stableStringify(payload);
}
export function isRawApiErrorPayload(raw?: string): boolean {
return getApiErrorPayloadFingerprint(raw) !== null;
}
function isLikelyProviderErrorType(type?: string): boolean {
const normalized = normalizeOptionalLowercaseString(type);
if (!normalized) {
return false;
}
return normalized.endsWith("_error");
}
const NON_ERROR_PROVIDER_PAYLOAD_MAX_LENGTH = 16_384;
const NON_ERROR_PROVIDER_PAYLOAD_PREFIX_RE = /^codex\s*error(?:\s+\d{3})?[:\s-]+/i;
function shouldRewriteRawPayloadWithoutErrorContext(raw: string): boolean {
if (raw.length > NON_ERROR_PROVIDER_PAYLOAD_MAX_LENGTH) {
return false;
}
if (!NON_ERROR_PROVIDER_PAYLOAD_PREFIX_RE.test(raw)) {
return false;
}
const info = parseApiErrorInfo(raw);
if (!info) {
return false;
}
if (isLikelyProviderErrorType(info.type)) {
return true;
}
if (info.httpCode) {
const parsedCode = Number(info.httpCode);
if (Number.isFinite(parsedCode) && parsedCode >= 400) {
return true;
}
}
return false;
}
export function formatAssistantErrorText(
msg: AssistantMessage,
opts?: { cfg?: OpenClawConfig; sessionKey?: string; provider?: string; model?: string },
): string | undefined {
// Also format errors if errorMessage is present, even if stopReason isn't "error"
const raw = (msg.errorMessage ?? "").trim();
if (msg.stopReason !== "error" && !raw) {
return undefined;
}
if (!raw) {
return "LLM request failed with an unknown error.";
}
const unknownTool =
raw.match(/unknown tool[:\s]+["']?([a-z0-9_-]+)["']?/i) ??
raw.match(/tool\s+["']?([a-z0-9_-]+)["']?\s+(?:not found|is not available)/i);
if (unknownTool?.[1]) {
const rewritten = formatSandboxToolPolicyBlockedMessage({
cfg: opts?.cfg,
sessionKey: opts?.sessionKey,
toolName: unknownTool[1],
});
if (rewritten) {
return rewritten;
}
}
const diskSpaceCopy = formatDiskSpaceErrorCopy(raw);
if (diskSpaceCopy) {
return diskSpaceCopy;
}
if (isContextOverflowError(raw)) {
return (
"Context overflow: prompt too large for the model. " +
"Try /reset (or /new) to start a fresh session, or use a larger-context model."
);
}
if (isReasoningConstraintErrorMessage(raw)) {
return (
"Reasoning is required for this model endpoint. " +
"Use /think minimal (or any non-off level) and try again."
);
}
if (isInvalidStreamingEventOrderError(raw)) {
return "LLM request failed: provider returned an invalid streaming response. Please try again.";
}
// Catch role ordering errors - including JSON-wrapped and "400" prefix variants
if (
/incorrect role information|roles must alternate|400.*role|"message".*role.*information/i.test(
raw,
)
) {
return (
"Message ordering conflict - please try again. " +
"If this persists, use /new to start a fresh session."
);
}
if (isMissingToolCallInputError(raw)) {
return (
"Session history looks corrupted (tool call input missing). " +
"Use /new to start a fresh session. " +
"If this keeps happening, reset the session or delete the corrupted session transcript."
);
}
const invalidRequest = raw.match(/"type":"invalid_request_error".*?"message":"([^"]+)"/);
if (invalidRequest?.[1]) {
return `LLM request rejected: ${invalidRequest[1]}`;
}
const transientCopy = formatRateLimitOrOverloadedErrorCopy(raw);
if (transientCopy) {
return transientCopy;
}
const transportCopy = formatTransportErrorCopy(raw);
if (transportCopy) {
return transportCopy;
}
if (isTimeoutErrorMessage(raw)) {
return "LLM request timed out.";
}
if (isBillingErrorMessage(raw)) {
return formatBillingErrorMessage(opts?.provider, opts?.model ?? msg.model);
}
if (isLikelyHttpErrorText(raw) || isRawApiErrorPayload(raw)) {
return formatRawAssistantErrorForUi(raw);
}
// Never return raw unhandled errors - log for debugging but return safe message
if (raw.length > 600) {
log.warn(`Long error truncated: ${raw.slice(0, 200)}`);
}
return raw.length > 600 ? `${raw.slice(0, 600)}` : raw;
}
export function sanitizeUserFacingText(text: unknown, opts?: { errorContext?: boolean }): string {
const raw = coerceText(text);
if (!raw) {
return raw;
}
const errorContext = opts?.errorContext ?? false;
const stripped = stripInternalRuntimeContext(stripFinalTagsFromText(raw));
const trimmed = stripped.trim();
if (!trimmed) {
return "";
}
// Provider error payloads should not leak directly into user-visible text even
// when a stream chunk was not explicitly flagged as an error.
if (!errorContext && shouldRewriteRawPayloadWithoutErrorContext(trimmed)) {
return formatRawAssistantErrorForUi(trimmed);
}
// Only apply error-pattern rewrites when the caller knows this text is an error payload.
// Otherwise we risk swallowing legitimate assistant text that merely *mentions* these errors.
if (errorContext) {
const execDeniedMessage = formatExecDeniedUserMessage(trimmed);
if (execDeniedMessage) {
return execDeniedMessage;
}
const diskSpaceCopy = formatDiskSpaceErrorCopy(trimmed);
if (diskSpaceCopy) {
return diskSpaceCopy;
}
if (/incorrect role information|roles must alternate/i.test(trimmed)) {
return (
"Message ordering conflict - please try again. " +
"If this persists, use /new to start a fresh session."
);
}
if (shouldRewriteContextOverflowText(trimmed)) {
return (
"Context overflow: prompt too large for the model. " +
"Try /reset (or /new) to start a fresh session, or use a larger-context model."
);
}
if (isBillingErrorMessage(trimmed)) {
return BILLING_ERROR_USER_MESSAGE;
}
if (isInvalidStreamingEventOrderError(trimmed)) {
return "LLM request failed: provider returned an invalid streaming response. Please try again.";
}
if (isRawApiErrorPayload(trimmed) || isLikelyHttpErrorText(trimmed)) {
return formatRawAssistantErrorForUi(trimmed);
}
if (ERROR_PREFIX_RE.test(trimmed)) {
const prefixedCopy = formatRateLimitOrOverloadedErrorCopy(trimmed);
if (prefixedCopy) {
return prefixedCopy;
}
const transportCopy = formatTransportErrorCopy(trimmed);
if (transportCopy) {
return transportCopy;
}
if (isTimeoutErrorMessage(trimmed)) {
return "LLM request timed out.";
}
return formatRawAssistantErrorForUi(trimmed);
}
}
// Strip leading blank lines (including whitespace-only lines) without clobbering indentation on
// the first content line (e.g. markdown/code blocks).
const withoutLeadingEmptyLines = stripped.replace(/^(?:[ \t]*\r?\n)+/, "");
return collapseConsecutiveDuplicateBlocks(withoutLeadingEmptyLines);
}
export function isRateLimitAssistantError(msg: AssistantMessage | undefined): boolean {
if (!msg || msg.stopReason !== "error") {
return false;
}
return isRateLimitErrorMessage(msg.errorMessage ?? "");
}
const TOOL_CALL_INPUT_MISSING_RE =
/tool_(?:use|call)\.(?:input|arguments).*?(?:field required|required)/i;
const TOOL_CALL_INPUT_PATH_RE =
/messages\.\d+\.content\.\d+\.tool_(?:use|call)\.(?:input|arguments)/i;
const IMAGE_DIMENSION_ERROR_RE =
/image dimensions exceed max allowed size for many-image requests:\s*(\d+)\s*pixels/i;
const IMAGE_DIMENSION_PATH_RE = /messages\.(\d+)\.content\.(\d+)\.image/i;
const IMAGE_SIZE_ERROR_RE = /image exceeds\s*(\d+(?:\.\d+)?)\s*mb/i;
export function isMissingToolCallInputError(raw: string): boolean {
if (!raw) {
return false;
}
return TOOL_CALL_INPUT_MISSING_RE.test(raw) || TOOL_CALL_INPUT_PATH_RE.test(raw);
}
export function isBillingAssistantError(msg: AssistantMessage | undefined): boolean {
if (!msg || msg.stopReason !== "error") {
return false;
}
return isBillingErrorMessage(msg.errorMessage ?? "");
}
// Transient signal patterns for api_error payloads. Only treat an api_error as
// retryable when the message text itself indicates a transient server issue.
// Non-transient api_error payloads (context overflow, validation/schema errors)
// must NOT be classified as timeout.
const API_ERROR_TRANSIENT_SIGNALS_RE =
/internal server error|overload|temporarily unavailable|service unavailable|unknown error|server error|bad gateway|gateway timeout|upstream error|backend error|try again later|temporarily.+unable|unexpected error/i;
function isJsonApiInternalServerError(raw: string): boolean {
if (!raw) {
return false;
}
const value = raw.toLowerCase();
// Providers wrap transient 5xx errors in JSON payloads like:
// {"type":"error","error":{"type":"api_error","message":"Internal server error"}}
// Non-standard providers (e.g. MiniMax) may use different message text:
// {"type":"api_error","message":"unknown error, 520 (1000)"}
if (!value.includes('"type":"api_error"')) {
return false;
}
// Billing and auth errors can also carry "type":"api_error". Exclude them so
// the more specific classifiers further down the chain handle them correctly.
if (isBillingErrorMessage(raw) || isAuthErrorMessage(raw) || isAuthPermanentErrorMessage(raw)) {
return false;
}
// Only match when the message contains a transient signal. api_error payloads
// with non-transient messages (e.g. context overflow, schema validation) should
// fall through to more specific classifiers or remain unclassified.
return API_ERROR_TRANSIENT_SIGNALS_RE.test(raw);
}
export function parseImageDimensionError(raw: string): {
maxDimensionPx?: number;
messageIndex?: number;
contentIndex?: number;
raw: string;
} | null {
if (!raw) {
return null;
}
const lower = raw.toLowerCase();
if (!lower.includes("image dimensions exceed max allowed size")) {
return null;
}
const limitMatch = raw.match(IMAGE_DIMENSION_ERROR_RE);
const pathMatch = raw.match(IMAGE_DIMENSION_PATH_RE);
return {
maxDimensionPx: limitMatch?.[1] ? Number.parseInt(limitMatch[1], 10) : undefined,
messageIndex: pathMatch?.[1] ? Number.parseInt(pathMatch[1], 10) : undefined,
contentIndex: pathMatch?.[2] ? Number.parseInt(pathMatch[2], 10) : undefined,
raw,
};
}
export function isImageDimensionErrorMessage(raw: string): boolean {
return Boolean(parseImageDimensionError(raw));
}
export function parseImageSizeError(raw: string): {
maxMb?: number;
raw: string;
} | null {
if (!raw) {
return null;
}
const lower = raw.toLowerCase();
if (!lower.includes("image exceeds") || !lower.includes("mb")) {
return null;
}
const match = raw.match(IMAGE_SIZE_ERROR_RE);
return {
maxMb: match?.[1] ? Number.parseFloat(match[1]) : undefined,
raw,
};
}
export function isImageSizeError(errorMessage?: string): boolean {
if (!errorMessage) {
return false;
}
return Boolean(parseImageSizeError(errorMessage));
}
export function isCloudCodeAssistFormatError(raw: string): boolean {
return !isImageDimensionErrorMessage(raw) && matchesFormatErrorPattern(raw);
}
export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean {
if (!msg || msg.stopReason !== "error") {
return false;
}
return isAuthErrorMessage(msg.errorMessage ?? "");
}
export function isModelNotFoundErrorMessage(raw: string): boolean {
if (!raw) {
return false;
}
const lower = raw.toLowerCase();
// Direct pattern matches from OpenClaw internals and common providers.
if (
lower.includes("unknown model") ||
lower.includes("model not found") ||
lower.includes("model_not_found") ||
lower.includes("not_found_error") ||
(lower.includes("does not exist") && lower.includes("model")) ||
(lower.includes("invalid model") && !lower.includes("invalid model reference"))
) {
return true;
}
// Google Gemini: "models/X is not found for api version"
if (/models\/[^\s]+ is not found/i.test(raw)) {
return true;
}
// JSON error payloads: {"status": "NOT_FOUND"} or {"code": 404} combined with not-found text.
if (/\b404\b/.test(raw) && /not[-_ ]?found/i.test(raw)) {
return true;
}
return false;
}
function isCliSessionExpiredErrorMessage(raw: string): boolean {
if (!raw) {
return false;
}
const lower = raw.toLowerCase();
return (
lower.includes("session not found") ||
lower.includes("session does not exist") ||
lower.includes("session expired") ||
lower.includes("session invalid") ||
lower.includes("conversation not found") ||
lower.includes("conversation does not exist") ||
lower.includes("conversation expired") ||
lower.includes("conversation invalid") ||
lower.includes("no such session") ||
lower.includes("invalid session") ||
lower.includes("session id not found") ||
lower.includes("conversation id not found")
);
}
export function classifyFailoverReason(
raw: string,
opts?: { provider?: string },
): FailoverReason | null {
const trimmed = raw.trim();
const leadingStatus = extractLeadingHttpStatus(trimmed);
return failoverReasonFromClassification(
classifyFailoverSignal({
status: leadingStatus?.code,
message: raw,
provider: opts?.provider,
}),
);
}
export function isFailoverErrorMessage(raw: string, opts?: { provider?: string }): boolean {
return classifyFailoverReason(raw, opts) !== null;
}
export function isFailoverAssistantError(msg: AssistantMessage | undefined): boolean {
if (!msg || msg.stopReason !== "error") {
return false;
}
return isFailoverErrorMessage(msg.errorMessage ?? "", { provider: msg.provider });
}