File size: 16,523 Bytes
e249c6d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 | import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { isTransientNetworkError } from "../../infra/retryable-network-errors.js";
import {
extractLeadingHttpStatus,
parseApiErrorInfo,
} from "../../shared/assistant-error-format.js";
import {
isAuthPermanentErrorMessage,
isBillingErrorMessage,
isRateLimitErrorMessage,
} from "./message-patterns.js";
import type { FailoverClassification, FailoverReason, FailoverSignal } from "./signal.js";
const FAILOVER_TIMEOUT_ERROR_CODES = new Set([
"EHOSTDOWN",
"ENETRESET",
"ERR_STREAM_PREMATURE_CLOSE",
]);
const NO_BODY_HTTP_WRAPPER_RE =
/^(?:no body(?: response)?|no response body|status code \(no body\))$/i;
function stripErrorPrefix(raw: string): string {
return raw.replace(/^error:\s*/i, "").trim();
}
export function inferSignalStatus(signal: FailoverSignal): number | undefined {
if (typeof signal.status === "number" && Number.isFinite(signal.status)) {
return signal.status;
}
return extractLeadingHttpStatus(stripErrorPrefix(signal.message?.trim() ?? ""))?.code;
}
function isExplicitNoBodyHttpMessage(raw: string | undefined, status?: number): boolean {
const trimmed = raw?.trim();
if (!trimmed) {
return false;
}
const candidate = extractLeadingHttpStatus(trimmed) ? trimmed : stripErrorPrefix(trimmed);
const leadingStatus = extractLeadingHttpStatus(candidate);
if (leadingStatus) {
if (typeof status === "number" && leadingStatus.code !== status) {
return false;
}
return NO_BODY_HTTP_WRAPPER_RE.test(leadingStatus.rest);
}
return NO_BODY_HTTP_WRAPPER_RE.test(candidate);
}
export function isUnclassifiedNoBodyHttpSignal(signal: FailoverSignal): boolean {
const status = inferSignalStatus(signal);
if (status !== 400 && status !== 422) {
return false;
}
const message = signal.message?.trim();
return !message || isExplicitNoBodyHttpMessage(message, status);
}
type PaymentRequiredFailoverReason = Extract<FailoverReason, "billing" | "rate_limit">;
// Provider SDKs often keep semantic error fields outside Error.message.
// These bounded candidates feed classification only; user-facing copy still
// comes from the normal sanitized formatter path.
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|.*used up your points\b|no available asset for api access\b)/i;
const BARE_LEADING_402_RE = /^\s*402\b/i;
const LEADING_402_WRAPPER_RE =
/^(?:error[:\s-]+)?(?:(?:http\s*)?402(?:\s+payment required)?|payment required)(?:[:\s-]+|$)/i;
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 hasKnownBareLeading402Signal(text: string): boolean {
return (
hasQuotaRefreshWindowSignal(text) ||
hasExplicit402BillingSignal(text) ||
isRateLimitErrorMessage(text) ||
hasRetryable402TransientSignal(text)
);
}
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";
}
export function classifyFailoverReasonFrom402Text(
raw: string,
): PaymentRequiredFailoverReason | null {
if (RAW_402_MARKER_RE.test(raw)) {
return classify402Message(raw);
}
if (!BARE_LEADING_402_RE.test(raw)) {
return null;
}
const normalized = normalize402Message(raw);
if (!normalized || !hasKnownBareLeading402Signal(normalized)) {
return null;
}
return classify402Message(raw);
}
export function toReasonClassification(reason: FailoverReason): FailoverClassification {
return { kind: "reason", reason };
}
export function toPluginClassification(reason: FailoverReason): FailoverClassification {
return reason === "context_overflow"
? { kind: "context_overflow" }
: toReasonClassification(reason);
}
export function failoverReasonFromClassification(
classification: FailoverClassification | null,
): FailoverReason | null {
if (!classification) {
return null;
}
return classification.kind === "reason" ? classification.reason : "context_overflow";
}
export function classifyFailoverClassificationFromHttpStatus(
status: number | undefined,
message: string | undefined,
messageClassification: FailoverClassification | null,
explicitStatus: number | undefined,
provider?: string,
opts?: { preserveProviderSignalClassification?: boolean },
): FailoverClassification | null {
const messageReason = failoverReasonFromClassification(messageClassification);
if (typeof status !== "number" || !Number.isFinite(status)) {
return null;
}
if (status === 402) {
if (!message) {
return toReasonClassification("billing");
}
const leadingStatus = extractLeadingHttpStatus(message.trim());
if (leadingStatus?.code === 402) {
const reasonFrom402Text = classifyFailoverReasonFrom402Text(message);
if (reasonFrom402Text) {
return toReasonClassification(reasonFrom402Text);
}
return typeof explicitStatus === "number"
? toReasonClassification(classify402Message(message))
: messageClassification;
}
return toReasonClassification(classify402Message(message));
}
if (status === 429) {
if (messageReason === "billing" && !isAmbiguousGeneric429BalanceMessage(message ?? "")) {
return toReasonClassification("billing");
}
if (message && isBilling429MessageForProvider(message, provider)) {
return toReasonClassification("billing");
}
return toReasonClassification("rate_limit");
}
if (status === 401 || status === 403) {
if (opts?.preserveProviderSignalClassification && messageClassification) {
return messageClassification;
}
if (message && isAuthPermanentErrorMessage(message)) {
return toReasonClassification("auth_permanent");
}
// Provider-owned billing classifications on ambiguous 401/403 responses
// take precedence over generic 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");
}
// Context payloads can use 5xx; preserve the compaction decision before generic status mapping.
if (messageClassification?.kind === "context_overflow") {
return messageClassification;
}
if (status === 404) {
if (
messageReason === "session_expired" ||
messageReason === "billing" ||
messageReason === "auth_permanent" ||
messageReason === "auth" ||
messageReason === "format"
) {
return messageClassification;
}
return toReasonClassification("model_not_found");
}
if (status === 529) {
return toReasonClassification("overloaded");
}
if (status === 499 || (status >= 500 && status < 600)) {
// Gateways can wrap a deterministic request rejection in a 5xx response.
return messageReason === "overloaded" ||
messageReason === "server_error" ||
(status >= 500 && messageReason === "format")
? messageClassification
: toReasonClassification("timeout");
}
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 && messageReason !== "server_error") {
return messageClassification;
}
// When the response has no body at all, or only surfaces as an HTTP wrapper
// like "400 status code (no body)", return null instead of defaulting to
// "format". These shapes are likely transient proxy issues — classifying
// them as "format" triggers a compaction loop that cannot recover.
if (isUnclassifiedNoBodyHttpSignal({ status, message })) {
return null;
}
// Body exists but couldn't be classified — still treat as format error
// since the provider rejected the request schema.
return toReasonClassification("format");
}
return null;
}
// Only cross-provider structured codes classify in core; provider-native
// mappings belong to provider hooks.
export function classifyFailoverReasonFromCode(raw: string | undefined): FailoverReason | null {
const normalized = raw?.trim().toUpperCase();
if (!normalized) {
return null;
}
switch (normalized) {
case "UNKNOWN_PARAMETER":
return "format";
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 "DEACTIVATED_WORKSPACE":
return "auth_permanent";
case "OVERLOADED":
case "OVERLOADED_ERROR":
return "overloaded";
default:
return FAILOVER_TIMEOUT_ERROR_CODES.has(normalized) ||
isTransientNetworkError({ code: normalized })
? "timeout"
: null;
}
}
export function classifyCoreFailoverReasonFromErrorType(
raw: string | undefined,
): FailoverReason | null {
const normalized = normalizeOptionalLowercaseString(raw);
switch (normalized) {
case "invalid_request_error":
return "format";
case "server_error":
case "upstream_error":
return "server_error";
case "overloaded_error":
return "overloaded";
default:
return null;
}
}
export function classifyFailoverClassificationFromErrorType(
raw: string | undefined,
): FailoverClassification | null {
const reason = classifyCoreFailoverReasonFromErrorType(raw);
return reason ? toReasonClassification(reason) : null;
}
function isProvider(provider: string | undefined, match: string): boolean {
const normalized = normalizeOptionalLowercaseString(provider);
return Boolean(normalized && normalized.includes(match));
}
function hasProviderBilling429Override(provider: string | undefined): boolean {
return (
isProvider(provider, "xai") || isProvider(provider, "moonshot") || isProvider(provider, "kimi")
);
}
function hasStructuredBilling429Signal(raw: string): boolean {
if (hasBillingApiErrorType(raw)) {
return true;
}
const leadingStatus = extractLeadingHttpStatus(raw.trim());
return Boolean(leadingStatus?.rest && hasBillingApiErrorType(leadingStatus.rest));
}
function hasBillingApiErrorType(raw: string): boolean {
const type = normalizeOptionalLowercaseString(parseApiErrorInfo(raw)?.type);
if (!type) {
return false;
}
return isBillingErrorMessage(type) || isBillingErrorMessage(type.replaceAll("_", " "));
}
function isAmbiguousGeneric429BalanceMessage(raw: string): boolean {
return /\binsufficient\s+account\s+balance\b/i.test(raw) && !hasStructuredBilling429Signal(raw);
}
function isBilling429MessageForProvider(raw: string, provider: string | undefined): boolean {
if (!isBillingErrorMessage(raw)) {
return false;
}
return hasProviderBilling429Override(provider) || !isAmbiguousGeneric429BalanceMessage(raw);
}
const REPLAY_INVALID_RE =
/\bprevious_response_id\b.*\b(?:invalid|unknown|not found|does not exist|expired|mismatch)\b|\btool_(?:use|call)\.(?:input|arguments)\b.*\b(?:missing|required)\b|\bincorrect role information\b|\broles must alternate\b|\binput item id does not belong to this connection\b/i;
const THINKING_SIGNATURE_ERROR_RE =
/\b(?:invalid|expired)\b.*\bsignature\b|\bsignature\b.*\b(?:invalid|expired)\b/i;
function isThinkingSignatureReplayInvalidErrorMessage(raw: string): boolean {
return /\bthinking\b/i.test(raw) && THINKING_SIGNATURE_ERROR_RE.test(raw);
}
export function isReplayInvalidErrorMessage(raw: string): boolean {
return REPLAY_INVALID_RE.test(raw) || isThinkingSignatureReplayInvalidErrorMessage(raw);
}
// shared model runtime providers throw `Error("An unknown error occurred")` provider-agnostically
// (anthropic, google, vertex, openai-completions, mistral, bedrock, etc.) when a
// stream ends with stopReason === "aborted" | "error" without specific info. Treat
// it as a transient transport failure so the configured fallback chain rotates
// instead of returning the bare string to the user (#71620).
export function isGenericUnknownStreamErrorMessage(raw: string): boolean {
return /^\s*an unknown error occurred\.?\s*$/i.test(raw);
}
export function isExactUnknownNoDetailsError(raw: string): boolean {
return (
normalizeOptionalLowercaseString(raw)?.trim() === "unknown error (no error details in response)"
);
}
export function isClaudeCliAuthError(raw: string, provider?: string): boolean {
// These upstream phrases overlap generic session/auth wording. Provider identity
// must come from runner metadata so other CLIs cannot inherit Claude policy.
if (normalizeOptionalLowercaseString(provider)?.trim() !== "claude-cli") {
return false;
}
return /\bnot logged in\b\s*·\s*please run \/login\b|\bfailed to authenticate:\s*oauth session expired and could not be refreshed\b/i.test(
raw,
);
}
export function isUnsupportedImageInputErrorMessage(raw: string | undefined): boolean {
const normalized = normalizeOptionalLowercaseString(raw);
if (!normalized) {
return false;
}
return (
/\bdoes not support image inputs?\b/.test(normalized) ||
/\bunsupported image input\b/.test(normalized) ||
(/\bno endpoints found\b/.test(normalized) && /\bsupport image input\b/.test(normalized))
);
}
|