mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
fix(agents): bound OpenRouter model catalog response reads (#95420)
* fix(agents): bound OpenRouter model catalog response reads The runtime OpenRouter model-capability detector fetched the full /models catalog with an unbounded `await response.json()`, so a compromised or misbehaving endpoint could stream an arbitrarily large body and force the process to buffer the whole payload before parsing. Read the body through the shared bounded reader instead, capping it at 16 MiB (matching the sibling pricing-cache endpoint hardened in #95103) and cancelling the stream on overflow. This mirrors the symmetric bound-stream fixes in #95103 and #95108. Adds coverage that an oversized streamed catalog is cancelled instead of buffered and that an under-cap chunked body still reassembles, parses, and round-trips through the SQLite cache on a fresh import. * fix(agents): avoid OpenRouter refetch after capped catalog miss --------- Co-authored-by: sallyom <somalley@redhat.com>
This commit is contained in:
@@ -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<Uint8Array>({
|
||||
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<Uint8Array>({
|
||||
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(
|
||||
|
||||
@@ -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<void> {
|
||||
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<string, OpenRouterModelCapabilities>();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user