import type { AssistantMessage } from "@mariozechner/pi-ai"; import type { ClawdbotConfig } from "../../config/config.js"; import { formatSandboxToolPolicyBlockedMessage } from "../sandbox.js"; import type { FailoverReason } from "./types.js"; export function isContextOverflowError(errorMessage?: string): boolean { if (!errorMessage) return false; const lower = errorMessage.toLowerCase(); return ( lower.includes("request_too_large") || 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("context overflow") || (lower.includes("413") && lower.includes("too large")) ); } export function isCompactionFailureError(errorMessage?: string): boolean { if (!errorMessage) return false; if (!isContextOverflowError(errorMessage)) return false; const lower = errorMessage.toLowerCase(); return ( lower.includes("summarization failed") || lower.includes("auto-compaction") || lower.includes("compaction failed") || lower.includes("compaction") ); } const ERROR_PAYLOAD_PREFIX_RE = /^(?:error|api\s*error|apierror|openai\s*error|anthropic\s*error|gateway\s*error)[:\s-]+/i; const FINAL_TAG_RE = /<\s*\/?\s*final\s*>/gi; const ERROR_PREFIX_RE = /^(?:error|api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|request failed|failed|exception)[:\s-]+/i; const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i; 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", ]; function stripFinalTagsFromText(text: string): string { if (!text) return text; return text.replace(FINAL_TAG_RE, ""); } function isLikelyHttpErrorText(raw: string): boolean { const match = raw.match(HTTP_STATUS_PREFIX_RE); if (!match) return false; const code = Number(match[1]); if (!Number.isFinite(code) || code < 400) return false; const message = match[2].toLowerCase(); return HTTP_ERROR_HINTS.some((hint) => message.includes(hint)); } type ErrorPayload = Record; function isErrorPayloadObject(payload: unknown): payload is ErrorPayload { if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; const record = payload as ErrorPayload; if (record.type === "error") return true; if (typeof record.request_id === "string" || typeof record.requestId === "string") return true; if ("error" in record) { const err = record.error; if (err && typeof err === "object" && !Array.isArray(err)) { const errRecord = err as ErrorPayload; if ( typeof errRecord.message === "string" || typeof errRecord.type === "string" || typeof errRecord.code === "string" ) { return true; } } } return false; } function parseApiErrorPayload(raw: string): ErrorPayload | null { if (!raw) return null; const trimmed = raw.trim(); if (!trimmed) return null; const candidates = [trimmed]; if (ERROR_PAYLOAD_PREFIX_RE.test(trimmed)) { candidates.push(trimmed.replace(ERROR_PAYLOAD_PREFIX_RE, "").trim()); } for (const candidate of candidates) { if (!candidate.startsWith("{") || !candidate.endsWith("}")) continue; try { const parsed = JSON.parse(candidate) as unknown; if (isErrorPayloadObject(parsed)) return parsed; } catch { // ignore parse errors } } return null; } function stableStringify(value: unknown): string { if (!value || typeof value !== "object") { return JSON.stringify(value) ?? "null"; } if (Array.isArray(value)) { return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; } const record = value as Record; const keys = Object.keys(record).sort(); const entries = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`); return `{${entries.join(",")}}`; } 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; } export type ApiErrorInfo = { httpCode?: string; type?: string; message?: string; requestId?: string; }; export function parseApiErrorInfo(raw?: string): ApiErrorInfo | null { if (!raw) return null; const trimmed = raw.trim(); if (!trimmed) return null; let httpCode: string | undefined; let candidate = trimmed; const httpPrefixMatch = candidate.match(/^(\d{3})\s+(.+)$/s); if (httpPrefixMatch) { httpCode = httpPrefixMatch[1]; candidate = httpPrefixMatch[2].trim(); } const payload = parseApiErrorPayload(candidate); if (!payload) return null; const requestId = typeof payload.request_id === "string" ? payload.request_id : typeof payload.requestId === "string" ? payload.requestId : undefined; const topType = typeof payload.type === "string" ? payload.type : undefined; const topMessage = typeof payload.message === "string" ? payload.message : undefined; let errType: string | undefined; let errMessage: string | undefined; if (payload.error && typeof payload.error === "object" && !Array.isArray(payload.error)) { const err = payload.error as Record; if (typeof err.type === "string") errType = err.type; if (typeof err.code === "string" && !errType) errType = err.code; if (typeof err.message === "string") errMessage = err.message; } return { httpCode, type: errType ?? topType, message: errMessage ?? topMessage, requestId, }; } export function formatRawAssistantErrorForUi(raw?: string): string { const trimmed = (raw ?? "").trim(); if (!trimmed) return "LLM request failed with an unknown error."; const info = parseApiErrorInfo(trimmed); if (info?.message) { const prefix = info.httpCode ? `HTTP ${info.httpCode}` : "LLM error"; const type = info.type ? ` ${info.type}` : ""; const requestId = info.requestId ? ` (request_id: ${info.requestId})` : ""; return `${prefix}${type}: ${info.message}${requestId}`; } return trimmed.length > 600 ? `${trimmed.slice(0, 600)}…` : trimmed; } export function formatAssistantErrorText( msg: AssistantMessage, opts?: { cfg?: ClawdbotConfig; sessionKey?: 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; } if (isContextOverflowError(raw)) { return ( "Context overflow: prompt too large for the model. " + "Try again with less input or a larger-context model." ); } // 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." ); } const invalidRequest = raw.match(/"type":"invalid_request_error".*?"message":"([^"]+)"/); if (invalidRequest?.[1]) { return `LLM request rejected: ${invalidRequest[1]}`; } if (isOverloadedErrorMessage(raw)) { return "The AI service is temporarily overloaded. Please try again in a moment."; } if (isRawApiErrorPayload(raw)) { return "The AI service returned an error. Please try again."; } // Never return raw unhandled errors - log for debugging but return safe message if (raw.length > 600) { console.warn("[formatAssistantErrorText] Long error truncated:", raw.slice(0, 200)); } return raw.length > 600 ? `${raw.slice(0, 600)}…` : raw; } export function sanitizeUserFacingText(text: string): string { if (!text) return text; const stripped = stripFinalTagsFromText(text); const trimmed = stripped.trim(); if (!trimmed) return stripped; 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 (isContextOverflowError(trimmed)) { return ( "Context overflow: prompt too large for the model. " + "Try again with less input or a larger-context model." ); } if (isRawApiErrorPayload(trimmed) || isLikelyHttpErrorText(trimmed)) { return "The AI service returned an error. Please try again."; } if (ERROR_PREFIX_RE.test(trimmed)) { if (isOverloadedErrorMessage(trimmed) || isRateLimitErrorMessage(trimmed)) { return "The AI service is temporarily overloaded. Please try again in a moment."; } if (isTimeoutErrorMessage(trimmed)) { return "LLM request timed out."; } return "The AI service returned an error. Please try again."; } return stripped; } export function isRateLimitAssistantError(msg: AssistantMessage | undefined): boolean { if (!msg || msg.stopReason !== "error") return false; return isRateLimitErrorMessage(msg.errorMessage ?? ""); } type ErrorPattern = RegExp | string; const ERROR_PATTERNS = { rateLimit: [ /rate[_ ]limit|too many requests|429/, "exceeded your current quota", "resource has been exhausted", "quota exceeded", "resource_exhausted", "usage limit", ], overloaded: [/overloaded_error|"type"\s*:\s*"overloaded_error"/i, "overloaded"], timeout: ["timeout", "timed out", "deadline exceeded", "context deadline exceeded"], billing: [ /\b402\b/, "payment required", "insufficient credits", "credit balance", "plans & billing", ], auth: [ /invalid[_ ]?api[_ ]?key/, "incorrect api key", "invalid token", "authentication", "unauthorized", "forbidden", "access denied", "expired", "token has expired", /\b401\b/, /\b403\b/, "no credentials found", "no api key found", ], format: [ "invalid_request_error", "string should match pattern", "tool_use.id", "tool_use_id", "messages.1.content.1.tool_use.id", "invalid request format", ], } as const; function matchesErrorPatterns(raw: string, patterns: readonly ErrorPattern[]): boolean { if (!raw) return false; const value = raw.toLowerCase(); return patterns.some((pattern) => pattern instanceof RegExp ? pattern.test(value) : value.includes(pattern), ); } export function isRateLimitErrorMessage(raw: string): boolean { return matchesErrorPatterns(raw, ERROR_PATTERNS.rateLimit); } export function isTimeoutErrorMessage(raw: string): boolean { return matchesErrorPatterns(raw, ERROR_PATTERNS.timeout); } export function isBillingErrorMessage(raw: string): boolean { const value = raw.toLowerCase(); if (!value) return false; if (matchesErrorPatterns(value, ERROR_PATTERNS.billing)) return true; return ( value.includes("billing") && (value.includes("upgrade") || value.includes("credits") || value.includes("payment") || value.includes("plan")) ); } export function isBillingAssistantError(msg: AssistantMessage | undefined): boolean { if (!msg || msg.stopReason !== "error") return false; return isBillingErrorMessage(msg.errorMessage ?? ""); } export function isAuthErrorMessage(raw: string): boolean { return matchesErrorPatterns(raw, ERROR_PATTERNS.auth); } export function isOverloadedErrorMessage(raw: string): boolean { return matchesErrorPatterns(raw, ERROR_PATTERNS.overloaded); } export function isCloudCodeAssistFormatError(raw: string): boolean { return matchesErrorPatterns(raw, ERROR_PATTERNS.format); } export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean { if (!msg || msg.stopReason !== "error") return false; return isAuthErrorMessage(msg.errorMessage ?? ""); } export function classifyFailoverReason(raw: string): FailoverReason | null { if (isRateLimitErrorMessage(raw)) return "rate_limit"; if (isOverloadedErrorMessage(raw)) return "rate_limit"; if (isCloudCodeAssistFormatError(raw)) return "format"; if (isBillingErrorMessage(raw)) return "billing"; if (isTimeoutErrorMessage(raw)) return "timeout"; if (isAuthErrorMessage(raw)) return "auth"; return null; } export function isFailoverErrorMessage(raw: string): boolean { return classifyFailoverReason(raw) !== null; } export function isFailoverAssistantError(msg: AssistantMessage | undefined): boolean { if (!msg || msg.stopReason !== "error") return false; return isFailoverErrorMessage(msg.errorMessage ?? ""); }