diff --git a/src/agents/embedded-agent-runner/openrouter-model-capabilities.test.ts b/src/agents/embedded-agent-runner/openrouter-model-capabilities.test.ts index 270d74e7060b..ed42b8188ecb 100644 --- a/src/agents/embedded-agent-runner/openrouter-model-capabilities.test.ts +++ b/src/agents/embedded-agent-runner/openrouter-model-capabilities.test.ts @@ -312,6 +312,104 @@ describe("openrouter-model-capabilities", () => { }); }); + it("bounds an oversized streamed OpenRouter catalog instead of buffering it whole", async () => { + await withOpenRouterStateDir(async () => { + // First pull emits a chunk larger than the cap; a well-behaved bounded read + // must cancel before requesting the (effectively infinite) second chunk. + let pullCount = 0; + const cancel = vi.fn(async () => undefined); + const stream = new ReadableStream({ + pull(controller) { + pullCount += 1; + controller.enqueue(new Uint8Array(pullCount === 1 ? 16 * 1024 * 1024 + 1 : 1)); + }, + cancel, + }); + const fetchSpy = vi.fn( + async () => + new Response(stream, { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchSpy); + + const module = await importOpenRouterModelCapabilities("oversized-stream"); + await module.loadOpenRouterModelCapabilities("acme/anything"); + + // The body was cancelled after the first oversized chunk rather than read + // to completion, and the overflow left no poisoned cache entry behind. + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(pullCount).toBeLessThanOrEqual(2); + expect(cancel).toHaveBeenCalledOnce(); + expect(module.getOpenRouterModelCapabilities("acme/anything")).toBeUndefined(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + }); + + it("round-trips a chunked under-cap catalog through the SQLite cache", async () => { + await withOpenRouterStateDir(async () => { + // Stream the payload across several small chunks so the bounded reader has to + // reassemble it; the reassembled bytes must parse and survive a cross-import + // SQLite read-back identical to the source catalog. + const payload = JSON.stringify({ + data: [ + { + id: "acme/chunked-model", + name: "Chunked Model", + architecture: { modality: "text+image->text" }, + supported_parameters: ["reasoning", "tools"], + context_length: 13579, + max_completion_tokens: 2468, + pricing: { prompt: "0.000007", completion: "0.000008" }, + }, + ], + }); + const encoded = new TextEncoder().encode(payload); + const fetchSpy = vi.fn(async () => { + let offset = 0; + const stream = new ReadableStream({ + pull(controller) { + if (offset >= encoded.length) { + controller.close(); + return; + } + const end = Math.min(offset + 8, encoded.length); + controller.enqueue(encoded.subarray(offset, end)); + offset = end; + }, + }); + return new Response(stream, { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + vi.stubGlobal("fetch", fetchSpy); + + const writer = await importOpenRouterModelCapabilities("chunked-sqlite-writer"); + await writer.loadOpenRouterModelCapabilities("acme/chunked-model"); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(writer.getOpenRouterModelCapabilities("acme/chunked-model")).toMatchObject({ + input: ["text", "image"], + reasoning: true, + supportsTools: true, + contextWindow: 13579, + maxTokens: 2468, + }); + + // Fresh import reads only from the SQLite cache the bounded read populated. + const reader = await importOpenRouterModelCapabilities("chunked-sqlite-reader"); + expect(reader.getOpenRouterModelCapabilities("acme/chunked-model")).toMatchObject({ + input: ["text", "image"], + reasoning: true, + supportsTools: true, + contextWindow: 13579, + maxTokens: 2468, + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + }); + it("does not refetch immediately after an awaited miss for the same model id", async () => { await withOpenRouterStateDir(async () => { const fetchSpy = vi.fn( diff --git a/src/agents/embedded-agent-runner/openrouter-model-capabilities.ts b/src/agents/embedded-agent-runner/openrouter-model-capabilities.ts index 5cb8c0d93158..ea7fed980ba9 100644 --- a/src/agents/embedded-agent-runner/openrouter-model-capabilities.ts +++ b/src/agents/embedded-agent-runner/openrouter-model-capabilities.ts @@ -18,6 +18,7 @@ * capabilities instead of the text-only fallback. */ +import { readResponseWithLimit } from "@openclaw/media-core/read-response-with-limit"; import { formatErrorMessage } from "../../infra/errors.js"; import { resolveProxyFetchFromEnv } from "../../infra/net/proxy-fetch.js"; import { parseStrictFiniteNumber } from "../../infra/parse-finite-number.js"; @@ -28,6 +29,10 @@ const log = createSubsystemLogger("openrouter-model-capabilities"); const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; const FETCH_TIMEOUT_MS = 10_000; +// Cap the catalog body so an untrusted/oversized OpenRouter response cannot force +// the runtime to buffer an unbounded payload before parsing. Mirrors the bound +// applied to the sibling pricing-cache endpoint (16 MiB). +const OPENROUTER_MODELS_RESPONSE_MAX_BYTES = 16 * 1024 * 1024; const SQLITE_CACHE_OWNER_ID = "core:openrouter-model-capabilities"; const SQLITE_CACHE_NAMESPACE = "models.v3"; const SQLITE_CACHE_MAX_ENTRIES = 10_000; @@ -198,7 +203,10 @@ async function doFetch(): Promise { return; } - const data = (await response.json()) as { data?: OpenRouterApiModel[] }; + const bytes = await readResponseWithLimit(response, OPENROUTER_MODELS_RESPONSE_MAX_BYTES, { + onOverflow: ({ size }) => new Error(`OpenRouter models response too large: ${size} bytes`), + }); + const data = JSON.parse(bytes.toString("utf8")) as { data?: OpenRouterApiModel[] }; const models = data.data ?? []; const map = new Map(); @@ -290,12 +298,17 @@ export async function loadOpenRouterModelCapabilities(modelId: string): Promise< export function getOpenRouterModelCapabilities( modelId: string, ): OpenRouterModelCapabilities | undefined { - ensureOpenRouterModelCache(); + // A failed awaited load, such as an oversized catalog body, already attempted + // a refresh. Do not let the follow-up sync lookup immediately retry it. + const skipMissRefresh = skipNextMissRefresh.delete(modelId); + if (!skipMissRefresh) { + ensureOpenRouterModelCache(); + } const result = cache?.get(modelId); // Model not found but cache exists — may be a newly added model. // Trigger a refresh so the next call picks it up. - if (!result && skipNextMissRefresh.delete(modelId)) { + if (!result && skipMissRefresh) { return undefined; } if (!result && cache && !fetchInFlight) {