diff --git a/src/gateway/model-pricing-cache.test.ts b/src/gateway/model-pricing-cache.test.ts index d88857c6728..85e93fa7faf 100644 --- a/src/gateway/model-pricing-cache.test.ts +++ b/src/gateway/model-pricing-cache.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { modelKey } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; +import { resetLogger, setLoggerOverride } from "../logging/logger.js"; +import { loggingState } from "../logging/state.js"; import type { normalizeProviderModelIdWithPlugin } from "../plugins/provider-runtime.js"; import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; @@ -26,6 +28,8 @@ describe("model-pricing-cache", () => { afterEach(() => { __resetGatewayModelPricingCacheForTest(); + loggingState.rawConsole = null; + resetLogger(); }); it("collects configured model refs across defaults, aliases, overrides, and media tools", () => { @@ -515,6 +519,45 @@ describe("model-pricing-cache", () => { }); }); + it("logs configured timeout seconds when pricing fetches time out", async () => { + const warnings: string[] = []; + loggingState.rawConsole = { + log: vi.fn(), + info: vi.fn(), + warn: vi.fn((message: string) => warnings.push(message)), + error: vi.fn(), + }; + setLoggerOverride({ level: "silent", consoleLevel: "warn" }); + + const config = { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-6" }, + }, + }, + } as unknown as OpenClawConfig; + const timeoutError = new DOMException( + "The operation was aborted due to timeout", + "TimeoutError", + ); + const fetchImpl = withFetchPreconnect(async () => { + throw timeoutError; + }); + + await refreshGatewayModelPricingCache({ config, fetchImpl }); + + expect(warnings).toEqual( + expect.arrayContaining([ + expect.stringContaining( + "OpenRouter pricing fetch failed (timeout 15s): TimeoutError: The operation was aborted due to timeout", + ), + expect.stringContaining( + "LiteLLM pricing fetch failed (timeout 15s): TimeoutError: The operation was aborted due to timeout", + ), + ]), + ); + }); + it("treats oversized LiteLLM catalog responses as source failures", async () => { const config = { agents: { diff --git a/src/gateway/model-pricing-cache.ts b/src/gateway/model-pricing-cache.ts index 7f95a4dbbf8..661a7aac1d1 100644 --- a/src/gateway/model-pricing-cache.ts +++ b/src/gateway/model-pricing-cache.ts @@ -98,6 +98,31 @@ function parseNumberString(value: unknown): number | null { return Number.isFinite(parsed) ? parsed : null; } +function formatTimeoutSeconds(timeoutMs: number): string { + const seconds = timeoutMs / 1000; + return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s`; +} + +function readErrorName(error: unknown): string | undefined { + return error && typeof error === "object" && "name" in error + ? String((error as { name?: unknown }).name) + : undefined; +} + +function isTimeoutError(error: unknown): boolean { + if (readErrorName(error) === "TimeoutError") { + return true; + } + return /\bTimeoutError\b/u.test(String(error)); +} + +function formatPricingFetchFailure(source: "LiteLLM" | "OpenRouter", error: unknown): string { + if (isTimeoutError(error)) { + return `${source} pricing fetch failed (timeout ${formatTimeoutSeconds(FETCH_TIMEOUT_MS)}): ${String(error)}`; + } + return `${source} pricing fetch failed: ${String(error)}`; +} + function toPricePerMillion(value: number | null): number { if (value === null || value < 0 || !Number.isFinite(value)) { return 0; @@ -535,12 +560,12 @@ export async function refreshGatewayModelPricingCache(params: { let litellmFailed = false; const [catalogById, litellmCatalog] = await Promise.all([ fetchOpenRouterPricingCatalog(fetchImpl).catch((error: unknown) => { - log.warn(`OpenRouter pricing fetch failed: ${String(error)}`); + log.warn(formatPricingFetchFailure("OpenRouter", error)); openRouterFailed = true; return new Map(); }), fetchLiteLLMPricingCatalog(fetchImpl).catch((error: unknown) => { - log.warn(`LiteLLM pricing fetch failed: ${String(error)}`); + log.warn(formatPricingFetchFailure("LiteLLM", error)); litellmFailed = true; return new Map() as LiteLLMPricingCatalog; }),