diff --git a/src/gateway/model-pricing-cache-state.ts b/src/gateway/model-pricing-cache-state.ts index 8a884e8ea6f..771838a72b5 100644 --- a/src/gateway/model-pricing-cache-state.ts +++ b/src/gateway/model-pricing-cache-state.ts @@ -20,7 +20,7 @@ export type CachedModelPricing = { tieredPricing?: CachedPricingTier[]; }; -export type GatewayModelPricingHealthSource = "openrouter" | "litellm" | "bootstrap"; +export type GatewayModelPricingHealthSource = "openrouter" | "litellm" | "bootstrap" | "refresh"; export type GatewayModelPricingHealth = { state: "ok" | "degraded" | "disabled"; diff --git a/src/gateway/model-pricing-cache.test.ts b/src/gateway/model-pricing-cache.test.ts index a9836631dd7..9b196920110 100644 --- a/src/gateway/model-pricing-cache.test.ts +++ b/src/gateway/model-pricing-cache.test.ts @@ -456,6 +456,84 @@ describe("model-pricing-cache", () => { }); }); + it("records and clears scheduled refresh rejections for health surfaces", async () => { + vi.useFakeTimers(); + try { + const manifestRegistry: PluginManifestRegistry = { diagnostics: [], plugins: [] }; + let failManifestRead = false; + const pluginMetadataSnapshot = { + index: { plugins: [] } as never, + get manifestRegistry() { + if (failManifestRead) { + throw new Error("manifest metadata failed"); + } + return manifestRegistry; + }, + }; + const config = { + agents: { + defaults: { + model: { primary: "custom/gpt-remote" }, + }, + }, + models: { + providers: { + custom: { + baseUrl: "https://models.example/v1", + api: "openai-completions", + models: [{ id: "gpt-remote" }], + }, + }, + }, + } as unknown as OpenClawConfig; + const fetchImpl = withFetchPreconnect(async (input: RequestInfo | URL) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + return new Response(JSON.stringify(url.includes("openrouter.ai") ? { data: [] } : {}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await refreshGatewayModelPricingCache({ + config, + fetchImpl, + pluginMetadataSnapshot, + }); + expect(getGatewayModelPricingHealth()).toEqual({ + state: "ok", + sources: [], + }); + + failManifestRead = true; + await vi.runOnlyPendingTimersAsync(); + + expect(getGatewayModelPricingHealth()).toMatchObject({ + state: "degraded", + sources: [ + { + source: "refresh", + state: "degraded", + detail: "pricing refresh failed: Error: manifest metadata failed", + }, + ], + }); + + failManifestRead = false; + await refreshGatewayModelPricingCache({ + config, + fetchImpl, + pluginMetadataSnapshot, + }); + expect(getGatewayModelPricingHealth()).toEqual({ + state: "ok", + sources: [], + }); + } finally { + vi.useRealTimers(); + } + }); + it("seeds pricing from explicit configured model cost without external catalog fetches", async () => { const config = { agents: { diff --git a/src/gateway/model-pricing-cache.ts b/src/gateway/model-pricing-cache.ts index 9b8f4ce805c..a85e8cc97a4 100644 --- a/src/gateway/model-pricing-cache.ts +++ b/src/gateway/model-pricing-cache.ts @@ -1125,7 +1125,11 @@ function scheduleRefresh( return; } void refreshGatewayModelPricingCache(params).catch((error: unknown) => { - log.warn(`pricing refresh failed: ${String(error)}`); + const message = `pricing refresh failed: ${String(error)}`; + log.warn(message); + if (!params.signal?.aborted) { + recordGatewayModelPricingSourceFailure("refresh", message); + } }); }, CACHE_TTL_MS); refreshTimer.unref?.(); @@ -1340,6 +1344,7 @@ export async function refreshGatewayModelPricingCache( return; } clearGatewayModelPricingSourceFailure("bootstrap"); + clearGatewayModelPricingSourceFailure("refresh"); replaceGatewayModelPricingCache(nextPricing); scheduleRefresh({ ...params, fetchImpl }); })();