import { describe, expect, it } from "vitest";
import {
  classifyProviderRuntimeFailureKind,
  classifyFailoverReason,
  classifyFailoverReasonFromHttpStatus,
  extractObservedOverflowTokenCount,
  isAuthErrorMessage,
  isAuthPermanentErrorMessage,
  isBillingErrorMessage,
  isCloudCodeAssistFormatError,
  isCloudflareOrHtmlErrorPage,
  isCompactionFailureError,
  isContextOverflowError,
  isFailoverErrorMessage,
  isImageDimensionErrorMessage,
  isLikelyContextOverflowError,
  isTimeoutErrorMessage,
  isTransientHttpError,
  parseImageDimensionError,
  parseImageSizeError,
} from "./embedded-agent-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
const PLAIN_INTERNAL_SERVER_ERROR_STATUS_SAMPLE = "Proxy notice: Status: Internal Server Error";
const MIXED_INTERNAL_SERVER_ERROR_STATUS_SAMPLE = `${PLAIN_INTERNAL_SERVER_ERROR_STATUS_SAMPLE}; upstream connect error`;
const INTERNAL_SERVER_ERROR_STATUS_WITH_500_SAMPLE = `${PLAIN_INTERNAL_SERVER_ERROR_STATUS_SAMPLE}; code:500`;
const OPENAI_SERVER_ERROR_PAYLOAD =
  'Codex error: {"type":"error","error":{"type":"server_error","code":"server_error","message":"An error occurred while processing your request."},"sequence_number":2}';

function expectMessageMatches(
  matcher: (message: string) => boolean,
  samples: readonly string[],
  expected: boolean,
) {
  for (const sample of samples) {
    expect(matcher(sample), sample).toBe(expected);
  }
}

function expectTimeoutFailoverSamples(samples: readonly string[]) {
  for (const sample of samples) {
    expect(isTimeoutErrorMessage(sample)).toBe(true);
    expect(classifyFailoverReason(sample)).toBe("timeout");
    expect(isFailoverErrorMessage(sample)).toBe(true);
  }
}

function expectNotFailoverSample(sample: string) {
  expect(isTimeoutErrorMessage(sample)).toBe(false);
  expect(classifyFailoverReason(sample)).toBeNull();
  expect(isFailoverErrorMessage(sample)).toBe(false);
}

describe("isAuthPermanentErrorMessage", () => {
  it.each([
    {
      name: "matches permanent auth failure patterns",
      samples: [
        "api key revoked",
        "api key deactivated",
        "key has been disabled",
        "key has been revoked",
        "account has been deactivated",
        "OAuth authentication is currently not allowed for this organization",
        "API_KEY_REVOKED",
        "api_key_deleted",
        "deactivated_workspace",
        "deactivated workspace",
      ],
      expected: true,
    },
    {
      name: "does not match transient auth errors",
      samples: [
        "invalid_api_key",
        "permission_error",
        "unauthorized",
        "invalid token",
        "authentication failed",
        "forbidden",
        "access denied",
        "token has expired",
      ],
      expected: false,
    },
  ])("$name", ({ samples, expected }) => {
    expectMessageMatches(isAuthPermanentErrorMessage, samples, expected);
  });
});

describe("isAuthErrorMessage", () => {
  it.each([
    'No credentials found for profile "anthropic:default".',
    "No API key found for profile openai.",
    "invalid_api_key",
    "permission_error",
    "OAuth token refresh failed for anthropic: Failed to refresh OAuth token for anthropic. Please try again or re-authenticate.",
    "Please re-authenticate to continue.",
    "could not authenticate api key",
    "could not validate credentials",
    "Failed to extract accountId from token",
  ])("matches auth errors for %j", (sample) => {
    expect(isAuthErrorMessage(sample)).toBe(true);
  });
});

describe("isBillingErrorMessage", () => {
  it.each([
    {
      name: "matches credit and payment failures",
      samples: [
        "Your credit balance is too low to access the Anthropic API.",
        "insufficient credits",
        "Payment Required",
        "HTTP 402 Payment Required",
        "plans & billing",
        "Insufficient USD or Diem balance to complete request. Visit https://venice.ai/settings/api to add credits.",
        "This model requires more credits to use",
        "This endpoint require more credits",
        "You're out of extra usage. Add more at claude.ai/settings/usage and keep going.",
        "Extra usage is required for long context requests.",
      ],
      expected: true,
    },
    {
      name: "does not false-positive on issue ids and numeric references",
      samples: [
        "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",
      ],
      expected: false,
    },
    {
      name: "still matches real HTTP 402 billing errors",
      samples: [
        "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"}}',
      ],
      expected: true,
    },
  ])("$name", ({ samples, expected }) => {
    expectMessageMatches(isBillingErrorMessage, samples, expected);
  });

  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("matches insufficient_balance snake_case error codes (#74079)", () => {
    expect(isBillingErrorMessage("insufficient_balance")).toBe(true);
    expect(classifyFailoverReason("insufficient_balance")).toBe("billing");
  });
  it("matches 'Insufficient MBT balance' with intervening words (#74079)", () => {
    const msg = "Insufficient MBT balance. Top up or upgrade your subscription to continue.";
    expect(isBillingErrorMessage(msg)).toBe(true);
    expect(classifyFailoverReason(msg)).toBe("billing");
  });
  it("matches provider spending-limit exhaustion messages", () => {
    const msg =
      "Your team has either used all available credits or reached its monthly spending limit.";
    expect(isBillingErrorMessage(msg)).toBe(true);
    expect(classifyFailoverReason(msg)).toBe("billing");
  });
  it("classifies flat JSON billing payloads with string error code (#74079)", () => {
    const raw =
      '{"error":"insufficient_balance","message":"Insufficient MBT balance. Top up or upgrade your subscription to continue.","upgradeUrl":"/settings/billing"}';
    expect(isBillingErrorMessage(raw)).toBe(true);
    expect(classifyFailoverReason(raw)).toBe("billing");
  });
  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("prefers billing when API-key and 402 hints both appear", () => {
    const sample =
      "402 Payment Required: The account associated with this API key has reached its maximum allowed monthly spending limit.";
    expect(isBillingErrorMessage(sample)).toBe(true);
    expect(classifyFailoverReason(sample)).toBe("billing");
  });

  it("classifies Anthropic extra-usage exhaustion variants as billing", () => {
    const samples = [
      "You're out of extra usage. Add more at claude.ai/settings/usage and keep going.",
      "Extra usage is required for long context requests.",
      "Third-party apps now draw from your extra usage, not your plan limits. We've added a $200 credit to get you started. Claim it at claude.ai/settings/usage and keep going.",
      '{"type":"error","error":{"type":"invalid_request_error","message":"You\'re out of extra usage. Add more at claude.ai/settings/usage and keep going."}}',
      '{"type":"error","error":{"type":"invalid_request_error","message":"Extra usage is required for long context requests."}}',
    ];

    for (const sample of samples) {
      expect(isBillingErrorMessage(sample)).toBe(true);
      expect(classifyFailoverReason(sample, { provider: "anthropic" })).toBe("billing");
    }
  });
});

describe("isCloudCodeAssistFormatError", () => {
  it("matches format errors", () => {
    expectMessageMatches(
      isCloudCodeAssistFormatError,
      [
        "INVALID_REQUEST_ERROR: string should match pattern",
        "messages.1.content.1.tool_use.id",
        "tool_use.id should match pattern",
        "invalid request format",
      ],
      true,
    );
  });
});

describe("isCloudflareOrHtmlErrorPage", () => {
  it("detects Cloudflare 521 HTML pages", () => {
    const htmlError = `521 <!DOCTYPE html>
<html lang="en-US">
  <head><title>Web server is down | example.com | Cloudflare</title></head>
  <body><h1>Web server is down</h1></body>
</html>`;

    expect(isCloudflareOrHtmlErrorPage(htmlError)).toBe(true);
  });

  it("detects generic 5xx HTML pages", () => {
    const htmlError = `503 <html><head><title>Service Unavailable</title></head><body>down</body></html>`;
    expect(isCloudflareOrHtmlErrorPage(htmlError)).toBe(true);
  });

  it("detects standalone Cloudflare challenge HTML pages", () => {
    const htmlError = `<!DOCTYPE html>
<html lang="en-US">
  <head><title>Just a moment...</title></head>
  <body>
    <span id="challenge-error-text">Enable JavaScript and cookies to continue</span>
    <script src="/cdn-cgi/challenge-platform/h/g/orchestrate/chl_page/v1"></script>
  </body>
</html>`;
    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 <!DOCTYPE html> upstream responded with partial HTML text";
    expect(isCloudflareOrHtmlErrorPage(plainTextWithHtmlPrefix)).toBe(false);
  });
});

describe("isCompactionFailureError", () => {
  it.each([
    {
      name: "matches compaction overflow failures",
      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",
      ],
      expected: true,
    },
    {
      name: "ignores non-compaction overflow errors",
      samples: ["Context overflow: prompt too large", "rate limit exceeded"],
      expected: false,
    },
  ])("$name", ({ samples, expected }) => {
    expectMessageMatches(isCompactionFailureError, samples, expected);
  });
});

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 shared model runtime", () => {
    // Anthropic API (and some OpenAI-compatible providers like ZhipuAI/GLM) return
    // stop_reason: "model_context_window_exceeded" when the context window is hit.
    // The shared model runtime 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);
  });
});

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 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("reasoning-required invalid-request errors", () => {
  it.each([
    {
      name: "strict context overflow classifier",
      classifier: isContextOverflowError,
      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",
      ],
    },
    {
      name: "likely context overflow classifier",
      classifier: isLikelyContextOverflowError,
      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",
      ],
    },
  ])("excludes reasoning-required invalid-request errors from $name", ({ classifier, samples }) => {
    for (const sample of samples) {
      expect(classifier(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);
    expect(
      extractObservedOverflowTokenCount(
        "400 The prompt is too long: 203557, model maximum context length: 196607",
      ),
    ).toBe(203557);
    expect(
      extractObservedOverflowTokenCount(
        "Invalid request: Your request exceeded model token limit: 262144 (requested: 291351)",
      ),
    ).toBe(291351);
    expect(
      extractObservedOverflowTokenCount(
        "input length and max_tokens exceed context limit (i.e 156321 + 48384 > 200000)",
      ),
    ).toBe(204705);
  });

  it("returns undefined when overflow counts are not present", () => {
    expect(extractObservedOverflowTokenCount("Prompt too large for this model")).toBeUndefined();
    expect(
      extractObservedOverflowTokenCount(
        "The prompt is too long: 203557 characters, model maximum context length: 196607",
      ),
    ).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 <!DOCTYPE html><html></html>")).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 401 invalid_api_key as ambiguous auth", () => {
    expect(classifyFailoverReasonFromHttpStatus(401, "invalid_api_key")).toBe("auth");
  });

  it("treats body-less HTTP 422 as unknown instead of format", () => {
    expect(classifyFailoverReasonFromHttpStatus(422)).toBeNull();
  });

  it("treats no-body HTTP 400/422 wrappers as unknown instead of format", () => {
    expect(classifyFailoverReasonFromHttpStatus(400, "No body response")).toBeNull();
    expect(classifyFailoverReasonFromHttpStatus(400, "400 status code (no body)")).toBeNull();
    expect(classifyFailoverReasonFromHttpStatus(422, "HTTP 422: No body")).toBeNull();
    expect(classifyFailoverReasonFromHttpStatus(422, "HTTP 422: No response body")).toBeNull();
    expect(
      classifyFailoverReasonFromHttpStatus(422, "Error: HTTP 422: No response body"),
    ).toBeNull();
  });

  it("treats HTTP 422 with an unclassifiable body as format error", () => {
    expect(classifyFailoverReasonFromHttpStatus(422, "check open ai req parameter error")).toBe(
      "format",
    );
    expect(classifyFailoverReasonFromHttpStatus(422, "Unprocessable Entity")).toBe("format");
  });

  it("treats 422 with billing message as billing instead of format", () => {
    expect(classifyFailoverReasonFromHttpStatus(422, "insufficient credits")).toBe("billing");
  });

  it("treats HTTP 400 insufficient-quota payloads as billing instead of format", () => {
    expect(classifyFailoverReasonFromHttpStatus(400, INSUFFICIENT_QUOTA_PAYLOAD)).toBe("billing");
  });

  it("keeps HTTP 400 provider-specific rate limits out of the generic format bucket", () => {
    expect(
      classifyFailoverReasonFromHttpStatus(
        400,
        "ThrottlingException: Too many concurrent requests",
      ),
    ).toBe("rate_limit");
  });

  it("does not force HTTP 400 context-overflow payloads into format", () => {
    expect(
      classifyFailoverReasonFromHttpStatus(
        400,
        "INVALID_ARGUMENT: input exceeds the maximum number of tokens",
      ),
    ).toBeNull();
  });

  it("lets OpenRouter billing-classified HTTP 401 responses bypass generic auth", () => {
    expect(
      classifyFailoverReasonFromHttpStatus(401, "401 Key limit exceeded (monthly limit)", {
        provider: "openrouter",
      }),
    ).toBe("billing");
  });

  it("lets OpenRouter API-key budget limit 403 responses bypass generic auth", () => {
    expect(
      classifyFailoverReasonFromHttpStatus(
        403,
        "403 API key budget limit exceeded (monthly limit). Contact your org admin.",
        { provider: "openrouter" },
      ),
    ).toBe("billing");
  });

  it("keeps generic HTTP 401 key-limit text on the auth path without provider context", () => {
    expect(
      classifyFailoverReasonFromHttpStatus(401, "401 Key limit exceeded (monthly limit)"),
    ).toBe("auth");
  });

  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");
  });

  it("does not let structured server_error markers override 4xx status handling", () => {
    const payload = '{"type":"error","error":{"type":"server_error","code":"server_error"}}';

    expect(classifyFailoverReasonFromHttpStatus(401, payload)).toBe("auth");
    expect(classifyFailoverReasonFromHttpStatus(402, payload)).toBe("billing");
    expect(classifyFailoverReasonFromHttpStatus(422, payload)).toBe("format");
  });

  it("preserves structured server_error markers on explicit HTTP 5xx statuses", () => {
    expect(classifyFailoverReasonFromHttpStatus(500, OPENAI_SERVER_ERROR_PAYLOAD)).toBe(
      "server_error",
    );
    expect(classifyFailoverReasonFromHttpStatus(502, OPENAI_SERVER_ERROR_PAYLOAD)).toBe(
      "server_error",
    );
    expect(classifyFailoverReasonFromHttpStatus(504, OPENAI_SERVER_ERROR_PAYLOAD)).toBe(
      "server_error",
    );
    expect(classifyFailoverReasonFromHttpStatus(500)).toBe("timeout");
  });

  it("treats generic HTTP 410 responses as retryable timeouts", () => {
    expect(classifyFailoverReasonFromHttpStatus(410)).toBe("timeout");
    expect(classifyFailoverReasonFromHttpStatus(410, "")).toBe("timeout");
    expect(classifyFailoverReasonFromHttpStatus(410, "No body response")).toBe("timeout");
  });

  it("treats session-specific HTTP 410 responses as session_expired", () => {
    expect(classifyFailoverReasonFromHttpStatus(410, "session not found")).toBe("session_expired");
    expect(classifyFailoverReasonFromHttpStatus(410, "conversation expired")).toBe(
      "session_expired",
    );
  });

  it("preserves explicit billing and auth signals on HTTP 410", () => {
    expect(classifyFailoverReasonFromHttpStatus(410, "invalid_api_key")).toBe("auth");
    expect(classifyFailoverReasonFromHttpStatus(410, "authentication failed")).toBe("auth");
    expect(classifyFailoverReasonFromHttpStatus(410, "insufficient credits")).toBe("billing");
  });
});

describe("classifyFailoverReason HTTP 410 handling", () => {
  it("treats generic 410 text as retryable timeout", () => {
    expect(classifyFailoverReason("410")).toBe("timeout");
    expect(classifyFailoverReason("HTTP 410")).toBe("timeout");
    expect(classifyFailoverReason("410 Gone")).toBe("timeout");
    expect(classifyFailoverReason("410: No body")).toBe("timeout");
    expect(classifyFailoverReason("HTTP 410: No body")).toBe("timeout");
    expect(classifyFailoverReason("HTTP 410 Gone")).toBe("timeout");
  });

  it("keeps session-specific 410 text mapped to session_expired", () => {
    expect(classifyFailoverReason("HTTP 410: session not found")).toBe("session_expired");
    expect(classifyFailoverReason("410 conversation expired")).toBe("session_expired");
  });

  it("classifies 'No conversation found' from Claude CLI as session_expired", () => {
    expect(classifyFailoverReason("No conversation found with session ID: abc123")).toBe(
      "session_expired",
    );
  });

  it("keeps explicit billing and auth signals on 410 text", () => {
    expect(classifyFailoverReason("HTTP 410: invalid_api_key")).toBe("auth");
    expect(classifyFailoverReason("HTTP 410: authentication failed")).toBe("auth");
    expect(classifyFailoverReason("HTTP 410: insufficient credits")).toBe("billing");
  });

  it("classifies HTTP 404 assistant errors as model_not_found so model fallback can continue", () => {
    expect(classifyFailoverReason("404 status code (no body)")).toBe("model_not_found");
    expect(classifyFailoverReason("HTTP 404: No body")).toBe("model_not_found");
  });

  it("keeps HTTP 400/422 no-body wrappers out of the format bucket", () => {
    expect(classifyFailoverReason("400 status code (no body)")).toBeNull();
    expect(classifyFailoverReason("HTTP 400: No body")).toBeNull();
    expect(classifyFailoverReason("422 status code (no body)")).toBeNull();
    expect(classifyFailoverReason("HTTP 422: No body")).toBeNull();
    expect(classifyFailoverReason("HTTP 422: No response body")).toBeNull();
    expect(classifyFailoverReason("Error: HTTP 422: No response body")).toBeNull();
  });

  it("preserves session and auth billing signals on HTTP 404 text", () => {
    expect(classifyFailoverReason("HTTP 404: session not found")).toBe("session_expired");
    expect(classifyFailoverReason("HTTP 404: invalid_api_key")).toBe("auth");
    expect(classifyFailoverReason("HTTP 404: insufficient credits")).toBe("billing");
  });

  it("does not map HTTP 404 plus context-overflow text to model_not_found", () => {
    expect(
      classifyFailoverReason(
        "HTTP 404: INVALID_ARGUMENT: input exceeds the maximum number of tokens",
      ),
    ).toBeNull();
  });

  it("keeps raw HTTP 400 wrappers aligned with structured provider classification", () => {
    expect(
      classifyFailoverReason("HTTP 400: ThrottlingException: Too many concurrent requests"),
    ).toBe("rate_limit");
    expect(
      classifyFailoverReason(
        "HTTP 400: INVALID_ARGUMENT: input exceeds the maximum number of tokens",
      ),
    ).toBeNull();
  });

  it("classifies OpenAI Responses unknown-no-details message distinctly", () => {
    const message = "Unknown error (no error details in response)";
    expect(classifyFailoverReason(message)).toBe("no_error_details");
    expect(isFailoverErrorMessage(message)).toBe(true);
  });

  it("classifies bare shared model runtime stream wrapper as timeout regardless of provider (#71620)", () => {
    // shared model runtime providers throw `Error("An unknown error occurred")` provider-agnostically
    // when streams end with stopReason "aborted" | "error" with no specific info.
    for (const sample of [
      "An unknown error occurred",
      "an unknown error occurred",
      "AN UNKNOWN ERROR OCCURRED",
      "An unknown error occurred.",
      "  An unknown error occurred  ",
    ]) {
      expect(classifyFailoverReason(sample)).toBe("timeout");
      expect(isFailoverErrorMessage(sample)).toBe(true);
    }
    expect(classifyFailoverReason("An unknown error occurred", { provider: "anthropic" })).toBe(
      "timeout",
    );
    expect(classifyFailoverReason("An unknown error occurred", { provider: "google" })).toBe(
      "timeout",
    );
    expect(classifyFailoverReason("An unknown error occurred", { provider: "openrouter" })).toBe(
      "timeout",
    );
  });

  it("does not match wrapped or unrelated unknown-error phrases as bare wrapper", () => {
    // Wrapped messages must not slip into failover-as-timeout via the bare match.
    expect(classifyFailoverReason("LLM request failed with an unknown error.")).toBeNull();
    expect(
      classifyFailoverReason("user reported that an unknown error occurred during sync"),
    ).toBeNull();
  });

  it("classifies openrouter-scoped upstream messages", () => {
    expect(classifyFailoverReason("Provider returned error", { provider: "openrouter" })).toBe(
      "timeout",
    );
    expect(classifyFailoverReason("Key limit exceeded", { provider: "openrouter" })).toBe(
      "billing",
    );
  });

  it("does not classify openrouter-scoped upstream messages without provider context", () => {
    expect(classifyFailoverReason("Provider returned error")).toBeNull();
    expect(classifyFailoverReason("Provider returned error", { provider: "anthropic" })).toBeNull();
    expect(classifyFailoverReason("Key limit exceeded")).toBeNull();
  });
});

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", () => {
    expectTimeoutFailoverSamples([
      "Unhandled stop reason: abort",
      "Unhandled stop reason: error",
      "stop reason: abort",
      "stop reason: error",
      "reason: abort",
      "reason: error",
    ]);
  });

  it("matches AbortError / stream-abort messages as timeout (#58315)", () => {
    expectTimeoutFailoverSamples([
      "The operation was aborted",
      "This operation was aborted",
      "the operation was aborted",
      "stream closed",
      "stream was closed",
      "stream aborted",
      "stream was aborted",
    ]);
  });

  it("matches Gemini MALFORMED_RESPONSE stop reason as timeout (#42149)", () => {
    expectTimeoutFailoverSamples([
      "Unhandled stop reason: MALFORMED_RESPONSE",
      "Unhandled stop reason: malformed_response",
      "stop reason: MALFORMED_RESPONSE",
    ]);
  });

  it("matches network errno codes in serialized error messages", () => {
    expectTimeoutFailoverSamples([
      "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",
    ]);
  });

  it("matches z.ai network_error stop reason as timeout", () => {
    expectTimeoutFailoverSamples([
      "Unhandled stop reason: network_error",
      "stop reason: network_error",
      "reason: network_error",
    ]);
  });

  it("matches Provider finish_reason: network_error as timeout (#61281)", () => {
    expectTimeoutFailoverSamples([
      "Provider finish_reason: network_error",
      "Provider finish_reason: abort",
      "Provider finish_reason: malformed_response",
    ]);
  });

  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);
  });

  it("matches google INTERNAL status errors as timeout", () => {
    const sample =
      "provider=google model=gemini-3.1-flash-lite-preview got status: INTERNAL upstream failure code:500";
    expect(isTimeoutErrorMessage(sample)).toBe(true);
    expect(classifyFailoverReason(sample)).toBe("timeout");
    expect(isFailoverErrorMessage(sample)).toBe(true);
  });

  it("does not treat plain status text with internal-server-error wording as timeout", () => {
    expectNotFailoverSample(PLAIN_INTERNAL_SERVER_ERROR_STATUS_SAMPLE);
  });

  it("keeps mixed upstream server errors retryable when they also mention status prose", () => {
    expect(isTimeoutErrorMessage(MIXED_INTERNAL_SERVER_ERROR_STATUS_SAMPLE)).toBe(false);
    expect(classifyFailoverReason(MIXED_INTERNAL_SERVER_ERROR_STATUS_SAMPLE)).toBe("timeout");
    expect(isFailoverErrorMessage(MIXED_INTERNAL_SERVER_ERROR_STATUS_SAMPLE)).toBe(true);
  });

  it("keeps status prose retryable when it is explicitly paired with code 500", () => {
    expect(isTimeoutErrorMessage(INTERNAL_SERVER_ERROR_STATUS_WITH_500_SAMPLE)).toBe(false);
    expect(classifyFailoverReason(INTERNAL_SERVER_ERROR_STATUS_WITH_500_SAMPLE)).toBe("timeout");
    expect(isFailoverErrorMessage(INTERNAL_SERVER_ERROR_STATUS_WITH_500_SAMPLE)).toBe(true);
  });

  it("matches bare undici transport failures as timeout (#69368)", () => {
    expectTimeoutFailoverSamples([
      "terminated",
      "Terminated",
      "  terminated  ",
      "stream_read_error",
      "  stream_read_error  ",
      "UND_ERR_SOCKET",
      "Error: UND_ERR_SOCKET other side closed",
      "UND_ERR_CONNECT_TIMEOUT",
      "UND_ERR_HEADERS_TIMEOUT",
      "UND_ERR_BODY_TIMEOUT",
      "UND_ERR_ABORTED",
      "UND_ERR_REQ_CONTENT_LENGTH_MISMATCH",
    ]);
  });

  it("matches shared model runtime openai-codex bare transport failures as timeout (#69368)", () => {
    expectTimeoutFailoverSamples([
      "Request failed",
      "request failed",
      "  Request failed  ",
      "Request failed after repeated internal retries.",
    ]);
  });

  it("does not classify unrelated 'terminated' prose as timeout", () => {
    expectNotFailoverSample("The user terminated the session manually.");
  });
});

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).toEqual({
      maxDimensionPx: 2000,
      messageIndex: 84,
      contentIndex: 1,
      raw,
    });
    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");
    expect(classifyFailoverReasonFromHttpStatus(402, "402 custom proxy billing failure")).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(classifyFailoverReasonFromHttpStatus(402, `402: ${transientMessage}`)).toBe(
      "rate_limit",
    );
    expect(classifyFailoverReason(`HTTP 402 Payment Required: ${transientMessage}`)).toBe(
      "rate_limit",
    );
    expect(classifyFailoverReasonFromHttpStatus(402, transientMessage)).toBe("rate_limit");
  });

  it("classifies bare leading 402 quota-refresh payloads as rate_limit", () => {
    const zenMuxMessage =
      "402 You have reached your subscription quota limit. Please wait for automatic quota refresh in the rolling time window, upgrade to a higher plan, or use a Pay-As-You-Go API Key for unlimited access.";
    expect(classifyFailoverReason(zenMuxMessage)).toBe("rate_limit");
  });

  it("does not classify numeric references that merely start with 402", () => {
    expect(classifyFailoverReason("402 items found in the database")).toBeNull();
    expect(classifyFailoverReason("402 records processed")).toBeNull();
  });

  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 provider messages", () => {
  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");
    // OpenRouter "requires more credits" billing text
    expect(classifyFailoverReason("This model requires more credits to use")).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");
    // Third-party proxy 402 with non-standard wording (#45774)
    expect(
      classifyFailoverReason(
        "402 No available asset for API access, please purchase a subscription",
      ),
    ).toBe("billing");
    expect(classifyFailoverReason("402 items found in the database")).toBeNull();
    expect(classifyFailoverReason("402 room is available")).toBeNull();
    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 <!DOCTYPE html><html><head><title>Web server is down</title></head><body>Cloudflare</body></html>",
      ),
    ).toBe("timeout");
    expect(classifyFailoverReason(OPENAI_SERVER_ERROR_PAYLOAD)).toBe("server_error");
    expect(
      classifyFailoverReason(
        "An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID synthetic-provider-request-001 in your message.",
      ),
    ).toBe("timeout");
    expect(classifyFailoverReason(`402 Payment Required ${OPENAI_SERVER_ERROR_PAYLOAD}`)).toBe(
      "billing",
    );
    expect(classifyFailoverReason("string should match pattern")).toBe("format");
    expect(
      classifyFailoverReason(
        "This model does not support assistant message prefill. The conversation must end with a user message.",
      ),
    ).toBe("format");
    expect(
      classifyFailoverReason("LLM request rejected: does not support assistant message prefill"),
    ).toBe("format");
    expect(classifyFailoverReason("conversation must end with a user message")).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("keeps only high-confidence auth failures in auth_permanent", () => {
    expect(classifyFailoverReason("invalid_api_key")).toBe("auth");
    expect(classifyFailoverReason("permission_error")).toBe("auth");
    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");
    expect(
      classifyFailoverReason("OAuth authentication is currently not allowed for this organization"),
    ).toBe("auth_permanent");
  });
  it("classifies JSON api_error with transient signal as timeout", () => {
    expect(
      classifyFailoverReason(
        '{"type":"error","error":{"type":"api_error","message":"Internal server error"}}',
      ),
    ).toBe("timeout");
    // MiniMax non-standard message
    expect(
      classifyFailoverReason('{"type":"api_error","message":"unknown error, 520 (1000)"}'),
    ).toBe("timeout");
    // Overloaded variant
    expect(
      classifyFailoverReason(
        '{"type":"error","error":{"type":"api_error","message":"Service temporarily unavailable"}}',
      ),
    ).toBe("timeout");
    // Anthropic "unexpected error" variant (#57010)
    expect(
      classifyFailoverReason(
        '{"type":"error","error":{"type":"api_error","message":"An unexpected error occurred while processing the response"}}',
      ),
    ).toBe("timeout");
  });
  it("does not classify non-transient api_error payloads as timeout", () => {
    // Context overflow - not transient
    expect(
      classifyFailoverReason(
        '{"type":"error","error":{"type":"api_error","message":"Request size exceeds model context window"}}',
      ),
    ).not.toBe("timeout");
    // Schema/validation error - not transient
    expect(
      classifyFailoverReason(
        '{"type":"error","error":{"type":"api_error","message":"messages.1.content.1.tool_use.id should match pattern"}}',
      ),
    ).not.toBe("timeout");
    // Generic unknown api_error without transient wording - should not be retried
    expect(
      classifyFailoverReason(
        '{"type":"error","error":{"type":"api_error","message":"invalid input format"}}',
      ),
    ).not.toBe("timeout");
  });
  it("does not shadow billing errors that carry api_error type", () => {
    // A provider may wrap a billing error in a JSON payload with "type":"api_error".
    // The billing classifier must win over the broad api_error transient match.
    expect(
      classifyFailoverReason(
        '{"type":"error","error":{"type":"api_error","message":"insufficient credits"}}',
      ),
    ).toBe("billing");
    expect(
      classifyFailoverReason(
        '{"type":"error","error":{"type":"api_error","message":"Payment required"}}',
      ),
    ).toBe("billing");
  });
  it("does not shadow auth errors that carry api_error type", () => {
    expect(
      classifyFailoverReason(
        '{"type":"error","error":{"type":"api_error","message":"invalid api key"}}',
      ),
    ).toBe("auth");
    expect(
      classifyFailoverReason(
        '{"type":"error","error":{"type":"api_error","message":"unauthorized"}}',
      ),
    ).toBe("auth");
    expect(
      classifyFailoverReason(
        '{"type":"error","error":{"type":"api_error","message":"permission_error"}}',
      ),
    ).toBe("auth");
    expect(
      classifyFailoverReason(
        '{"type":"error","error":{"type":"api_error","message":"permission_error: OAuth authentication is currently not allowed for this organization"}}',
      ),
    ).toBe("auth_permanent");
  });

  it("classifies Chinese provider error messages correctly", () => {
    // ZhipuAI/GLM error code 1234: "网络错误" (network error) — real production error
    // from https://github.com/openclaw/openclaw/issues/56242
    expect(
      classifyFailoverReason(
        "LLM error 1234: 网络错误，错误id：202603281427587491f4467f1c4712，请联系客服。 (request_id: 202603281427587491f4467f1c4712)",
      ),
    ).toBe("timeout");
    expect(
      classifyFailoverReason(
        '{"error":{"code":"1234","message":"网络错误，错误id：abc123，请联系客服。"},"request_id":"abc123"}',
      ),
    ).toBe("timeout");

    // Network/connection errors
    expect(classifyFailoverReason("网络异常，请稍后重试")).toBe("timeout");
    expect(classifyFailoverReason("连接超时")).toBe("timeout");
    expect(classifyFailoverReason("请求超时，请重试")).toBe("timeout");
    expect(classifyFailoverReason("服务暂时不可用")).toBe("timeout");
    expect(classifyFailoverReason("连接错误")).toBe("timeout");
    expect(classifyFailoverReason("服务繁忙，请稍后再试")).toBe("timeout");

    // Server errors
    expect(classifyFailoverReason("内部错误")).toBe("timeout");
    expect(classifyFailoverReason("服务器错误")).toBe("timeout");
    expect(classifyFailoverReason("服务器内部错误")).toBe("timeout");
    expect(classifyFailoverReason("系统错误，请稍后重试")).toBe("timeout");
    expect(classifyFailoverReason("系统繁忙")).toBe("timeout");
    expect(classifyFailoverReason("系统异常")).toBe("timeout");

    // Rate limit errors
    expect(classifyFailoverReason("请求过于频繁，请稍后重试")).toBe("rate_limit");
    expect(classifyFailoverReason("调用频率超限")).toBe("rate_limit");
    expect(classifyFailoverReason("频率限制")).toBe("rate_limit");
    expect(classifyFailoverReason("配额不足")).toBe("rate_limit");
    expect(classifyFailoverReason("配额已用尽")).toBe("rate_limit");
    expect(classifyFailoverReason("额度不足，请充值")).toBe("rate_limit");
    expect(classifyFailoverReason("额度已用尽")).toBe("rate_limit");

    // Billing errors
    expect(classifyFailoverReason("余额不足，请充值")).toBe("billing");
    expect(classifyFailoverReason("账户余额不足")).toBe("billing");
    expect(classifyFailoverReason("账户已欠费")).toBe("billing");

    // Auth errors
    expect(classifyFailoverReason("无权访问该模型")).toBe("auth");
    expect(classifyFailoverReason("403 您无权访问glm-5.1。")).toBe("auth");
    expect(classifyFailoverReason("当前ak因违规请求被禁止访问该模型")).toBe("auth");
    expect(classifyFailoverReason('{"success":false,"code":"CE-011"}')).toBe("auth");
    expect(classifyFailoverReason("认证失败")).toBe("auth");
    expect(classifyFailoverReason("鉴权失败，请检查API Key")).toBe("auth");
    expect(classifyFailoverReason("密钥无效")).toBe("auth");

    // Overloaded errors
    expect(classifyFailoverReason("服务过载，请稍后重试")).toBe("overloaded");
    expect(classifyFailoverReason("当前负载过高")).toBe("overloaded");
  });
});

describe("classifyProviderRuntimeFailureKind", () => {
  it("classifies missing scope failures", () => {
    expect(
      classifyProviderRuntimeFailureKind({
        provider: "openai-codex",
        message:
          '401 {"type":"error","error":{"type":"permission_error","message":"Missing scopes: api.responses.write"}}',
      }),
    ).toBe("auth_scope");
  });

  it("classifies raw missing scope payloads without an HTTP prefix", () => {
    expect(
      classifyProviderRuntimeFailureKind({
        provider: "openai-codex",
        message:
          '{"type":"error","error":{"type":"permission_error","message":"Missing scopes: api.responses.write"},"code":401}',
      }),
    ).toBe("auth_scope");
  });

  it("does not classify non-Codex permission errors as missing scope failures", () => {
    expect(
      classifyProviderRuntimeFailureKind({
        provider: "openai",
        message:
          '401 {"type":"error","error":{"type":"permission_error","message":"Missing scopes: api.responses.write"}}',
      }),
    ).not.toBe("auth_scope");
  });

  it("does not treat generic Codex permission failures as missing scope failures", () => {
    expect(
      classifyProviderRuntimeFailureKind({
        provider: "openai-codex",
        message:
          '403 {"type":"error","error":{"type":"permission_error","message":"Insufficient permissions for this organization"}}',
      }),
    ).not.toBe("auth_scope");
  });

  it("classifies OAuth refresh failures", () => {
    const refreshFailures = [
      "OAuth token refresh failed for openai-codex: invalid_grant. Please try again or re-authenticate.",
      "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.",
      "Your authentication session could not be refreshed automatically. Please log out and sign in again.",
    ];
    for (const message of refreshFailures) {
      expect(classifyProviderRuntimeFailureKind(message)).toBe("auth_refresh");
      expect(classifyFailoverReason(message, { provider: "openai-codex" })).toBe("auth_permanent");
    }
  });

  it("does not make uncertain OAuth refresh wrappers terminal", () => {
    const message =
      "OAuth token refresh failed for openai-codex: file lock timeout for /tmp/agent/auth-profiles.json. Please try again or re-authenticate.";
    expect(classifyProviderRuntimeFailureKind(message)).toBe("auth_refresh");
    expect(classifyFailoverReason(message, { provider: "openai-codex" })).toBe("auth");
  });

  it("keeps Codex entitlement and usage-limit payloads out of terminal auth", () => {
    const entitlementMessages = [
      "You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus), try again after 11:34 AM.",
      "You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits, try again later.",
      '429 {"type":"error","error":{"type":"rate_limit_error","message":"You\\u0027ve hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus), try again after 11:34 AM."}}',
    ];
    for (const message of entitlementMessages) {
      expect(classifyProviderRuntimeFailureKind(message)).not.toBe("auth_refresh");
      expect(classifyFailoverReason(message, { provider: "openai-codex" })).toBe("rate_limit");
    }
  });

  it("classifies OAuth refresh timeouts and lock contention distinctly", () => {
    expect(
      classifyProviderRuntimeFailureKind(
        'OAuth refresh call "refreshProviderOAuthCredentialWithPlugin(openai-codex)" exceeded hard timeout (120000ms)',
      ),
    ).toBe("refresh_timeout");
    expect(
      classifyProviderRuntimeFailureKind("file lock timeout for /tmp/openclaw-oauth-refresh.lock"),
    ).toBe("refresh_contention");
    expect(
      classifyProviderRuntimeFailureKind({
        code: "refresh_contention",
        message:
          "OAuth token refresh failed for openai-codex: OAuth refresh failed (refresh_contention): another process is already refreshing openai-codex for openai-codex:default. Please wait for the in-flight refresh to finish and retry.",
      }),
    ).toBe("refresh_contention");
    expect(
      classifyProviderRuntimeFailureKind(
        "OAuth token refresh failed for openai-codex: file lock timeout for /tmp/agent/auth-profiles.json. Please try again or re-authenticate.",
      ),
    ).toBe("auth_refresh");
  });

  it("classifies wrapped OpenAI Codex callback validation failures distinctly", () => {
    expect(
      classifyProviderRuntimeFailureKind(
        "OpenAI Codex OAuth failed (callback_validation_failed): State mismatch",
      ),
    ).toBe("callback_validation");
  });

  it("classifies HTML 403 auth failures", () => {
    expect(
      classifyProviderRuntimeFailureKind(
        "403 <!DOCTYPE html><html><body>Access denied</body></html>",
      ),
    ).toBe("auth_html");
  });

  it("classifies HTML 401 auth failures", () => {
    expect(
      classifyProviderRuntimeFailureKind(
        "401 <!DOCTYPE html><html><body>Unauthorized</body></html>",
      ),
    ).toBe("auth_html");
  });

  it("classifies proxy, dns, timeout, schema, sandbox, and replay failures", () => {
    expect(classifyProviderRuntimeFailureKind("407 Proxy Authentication Required")).toBe("proxy");
    expect(
      classifyProviderRuntimeFailureKind("dial tcp: lookup api.example.com: no such host"),
    ).toBe("dns");
    expect(classifyProviderRuntimeFailureKind("socket hang up")).toBe("timeout");
    expect(
      classifyProviderRuntimeFailureKind("INVALID_REQUEST_ERROR: string should match pattern"),
    ).toBe("schema");
    expect(classifyProviderRuntimeFailureKind("exec denied (allowlist-miss):")).toBe(
      "sandbox_blocked",
    );
    expect(classifyProviderRuntimeFailureKind("tool_use.input: Field required")).toBe(
      "replay_invalid",
    );
    expect(
      classifyProviderRuntimeFailureKind("401 input item ID does not belong to this connection"),
    ).toBe("replay_invalid");
  });

  it("splits ambiguous provider runtime failures instead of collapsing to unknown", () => {
    expect(classifyProviderRuntimeFailureKind({})).toBe("empty_response");
    expect(classifyProviderRuntimeFailureKind("Unknown error (no error details in response)")).toBe(
      "no_error_details",
    );
    expect(classifyProviderRuntimeFailureKind("provider sent a strange opaque failure")).toBe(
      "unclassified",
    );
  });

  it("does not classify generic config errors that mention proxy settings as proxy failures", () => {
    expect(
      classifyProviderRuntimeFailureKind(
        'Model-provider request.proxy/request.tls is not yet supported for api "ollama"',
      ),
    ).not.toBe("proxy");
  });

  it("classifies google-style INTERNAL status payloads as timeout", () => {
    expect(
      classifyFailoverReason(
        'ERROR provider=google model=gemini-3.1-flash-lite-preview: got status: INTERNAL, details: {"code":500,"status":"INTERNAL"}',
      ),
    ).toBe("timeout");
    expect(
      classifyFailoverReason(
        'got status: INTERNAL. {"error":{"code":500,"message":"Internal error encountered.","status":"INTERNAL"}}',
      ),
    ).toBe("timeout");
  });

  it("does not classify google-style INTERNAL payloads without a 500 code as timeout", () => {
    const sample =
      'got status: INTERNAL. {"error":{"code":400,"message":"Request malformed","status":"INTERNAL"}}';
    expect(isTimeoutErrorMessage(sample)).toBe(false);
    expect(classifyFailoverReason(sample)).toBeNull();
    expect(isFailoverErrorMessage(sample)).toBe(false);
  });

  it("does not classify plain status text with internal server error wording as timeout", () => {
    expectNotFailoverSample(PLAIN_INTERNAL_SERVER_ERROR_STATUS_SAMPLE);
  });

  it("classifies internal server error status prose with code 500 as timeout", () => {
    expect(classifyFailoverReason(INTERNAL_SERVER_ERROR_STATUS_WITH_500_SAMPLE)).toBe("timeout");
  });
});
