Files
openclaw/src/agents/model-scan.ts
T
Alix-007 fece179cf9 fix(models): honor scan timeout while reading OpenRouter catalog (#108972)
* fix(models): bound OpenRouter catalog response reads

* refactor(models): reuse catalog timeout helper

Co-authored-by: Alix-007 <li.long15@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-16 06:01:40 -07:00

526 lines
16 KiB
TypeScript

import type { OpenAICompletionsOptions } from "@openclaw/ai/internal/openai";
import { getEnvApiKey } from "@openclaw/ai/internal/runtime";
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import {
asDateTimestampMs,
resolveTimerTimeoutMs,
} from "@openclaw/normalization-core/number-coercion";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import {
normalizeStringEntries,
uniqueStrings,
} from "@openclaw/normalization-core/string-normalization";
import pMap from "p-map";
import { Type } from "typebox";
import { formatErrorMessage } from "../infra/errors.js";
/**
* Scans remote provider model catalogs for configured providers.
*/
import { readResponseWithLimit } from "../infra/http-body.js";
import { complete } from "../llm/stream.js";
import type { Context, Model, Tool } from "../llm/types.js";
import { inferParamBFromIdOrName } from "../shared/model-param-b.js";
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
const DEFAULT_TIMEOUT_MS = 12_000;
const DEFAULT_CONCURRENCY = 3;
// The OpenRouter /models catalog is a provider-controlled, runtime-fetched body
// (already >100 KB and growing). Read it under a byte cap before JSON.parse so a
// faulty or hostile provider cannot stream an unbounded document and exhaust
// process memory. Keep this aligned with the runtime capability cache for the
// same endpoint so scan and runtime discovery fail at the same boundary.
const OPENROUTER_MODELS_BODY_MAX_BYTES = 16 * 1024 * 1024;
const BASE_IMAGE_PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII=";
const TOOL_PING: Tool = {
name: "ping",
description: "Return OK.",
parameters: Type.Object({}),
};
type OpenRouterModelMeta = {
id: string;
name: string;
contextLength: number | null;
maxCompletionTokens: number | null;
supportedParameters: string[];
supportedParametersCount: number;
supportsToolsMeta: boolean;
modality: string | null;
inferredParamB: number | null;
createdAtMs: number | null;
pricing: OpenRouterModelPricing | null;
};
type OpenRouterModelPricing = {
prompt: number;
completion: number;
request: number;
image: number;
webSearch: number;
internalReasoning: number;
};
type ProbeResult = {
ok: boolean;
latencyMs: number | null;
error?: string;
skipped?: boolean;
};
export type ModelScanResult = {
id: string;
name: string;
provider: string;
modelRef: string;
contextLength: number | null;
maxCompletionTokens: number | null;
supportedParametersCount: number;
supportsToolsMeta: boolean;
modality: string | null;
inferredParamB: number | null;
createdAtMs: number | null;
pricing: OpenRouterModelPricing | null;
isFree: boolean;
tool: ProbeResult;
image: ProbeResult;
};
type OpenRouterScanOptions = {
apiKey?: string;
fetchImpl?: typeof fetch;
timeoutMs?: number;
concurrency?: number;
minParamB?: number;
maxAgeDays?: number;
providerFilter?: string;
probe?: boolean;
onProgress?: (update: { phase: "catalog" | "probe"; completed: number; total: number }) => void;
};
type OpenAIModel = Model<"openai-completions">;
function normalizeCreatedAtMs(value: unknown): number | null {
if (typeof value !== "number" || !Number.isFinite(value)) {
return null;
}
if (value <= 0) {
return null;
}
const timestampMs = value > 1e12 ? Math.round(value) : Math.round(value * 1000);
return asDateTimestampMs(timestampMs) ?? null;
}
function parseModality(modality: string | null): Array<"text" | "image"> {
if (!modality) {
return ["text"];
}
const normalized = normalizeLowercaseStringOrEmpty(modality);
const parts = normalized.split(/[^a-z]+/).filter(Boolean);
const hasImage = parts.includes("image");
return hasImage ? ["text", "image"] : ["text"];
}
function parseNumberString(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const num = Number(trimmed);
if (!Number.isFinite(num)) {
return null;
}
return num;
}
function parseOpenRouterPricing(value: unknown): OpenRouterModelPricing | null {
if (!value || typeof value !== "object") {
return null;
}
const obj = value as Record<string, unknown>;
const prompt = parseNumberString(obj.prompt);
const completion = parseNumberString(obj.completion);
const request = parseNumberString(obj.request) ?? 0;
const image = parseNumberString(obj.image) ?? 0;
const webSearch = parseNumberString(obj.web_search) ?? 0;
const internalReasoning = parseNumberString(obj.internal_reasoning) ?? 0;
if (prompt === null || completion === null) {
return null;
}
return {
prompt,
completion,
request,
image,
webSearch,
internalReasoning,
};
}
function isFreeOpenRouterModel(entry: OpenRouterModelMeta): boolean {
if (entry.id.endsWith(":free")) {
return true;
}
if (!entry.pricing) {
return false;
}
return entry.pricing.prompt === 0 && entry.pricing.completion === 0;
}
async function withTimeout<T>(
timeoutMs: number,
fn: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(controller.abort.bind(controller), timeoutMs);
try {
return await fn(controller.signal);
} finally {
clearTimeout(timer);
}
}
// Reads the OpenRouter /models success body under a byte cap before JSON.parse.
// The success path was previously buffered with an unbounded res.json(); a faulty
// or hostile provider could stream an effectively endless document and exhaust
// memory. readResponseWithLimit caps the read, cancels the stream on overflow,
// and bounds idle stalls with the call's existing timeout.
async function readOpenRouterModelsJson(response: Response, timeoutMs: number): Promise<unknown> {
const buffer = await readResponseWithLimit(response, OPENROUTER_MODELS_BODY_MAX_BYTES, {
chunkTimeoutMs: timeoutMs,
onOverflow: ({ size, maxBytes }) =>
new Error(`OpenRouter /models response too large: ${size} bytes (limit ${maxBytes} bytes)`),
onIdleTimeout: ({ chunkTimeoutMs }) =>
new Error(`OpenRouter /models response stalled after ${chunkTimeoutMs}ms`),
});
try {
return JSON.parse(buffer.toString("utf8")) as unknown;
} catch (cause) {
throw new Error("OpenRouter /models response is malformed JSON", { cause });
}
}
async function fetchOpenRouterModels(
fetchImpl: typeof fetch,
timeoutMs: number,
): Promise<OpenRouterModelMeta[]> {
let res: Response | undefined;
try {
// fetch resolves after headers, so keep the shared timeout active until
// the provider-controlled catalog body has been consumed.
return await withTimeout(timeoutMs, async (signal) => {
res = await fetchImpl(OPENROUTER_MODELS_URL, {
headers: { Accept: "application/json" },
signal,
});
if (!res.ok) {
throw new Error(`OpenRouter /models failed: HTTP ${res.status}`);
}
const payload = (await readOpenRouterModelsJson(res, timeoutMs)) as { data?: unknown };
const entries = Array.isArray(payload.data) ? payload.data : [];
return entries
.map((entry) => {
if (!entry || typeof entry !== "object") {
return null;
}
const obj = entry as Record<string, unknown>;
const id = normalizeOptionalString(obj.id) ?? "";
if (!id) {
return null;
}
const name = typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : id;
const contextLength =
typeof obj.context_length === "number" && Number.isFinite(obj.context_length)
? obj.context_length
: null;
const maxCompletionTokens =
typeof obj.max_completion_tokens === "number" &&
Number.isFinite(obj.max_completion_tokens)
? obj.max_completion_tokens
: typeof obj.max_output_tokens === "number" && Number.isFinite(obj.max_output_tokens)
? obj.max_output_tokens
: null;
const supportedParameters = Array.isArray(obj.supported_parameters)
? normalizeStringEntries(
obj.supported_parameters.filter((value) => typeof value === "string"),
)
: [];
const supportedParametersCount = supportedParameters.length;
const supportsToolsMeta = supportedParameters.includes("tools");
const modality =
typeof obj.modality === "string" && obj.modality.trim() ? obj.modality.trim() : null;
const inferredParamB = inferParamBFromIdOrName(`${id} ${name}`);
const createdAtMs = normalizeCreatedAtMs(obj.created_at);
const pricing = parseOpenRouterPricing(obj.pricing);
return {
id,
name,
contextLength,
maxCompletionTokens,
supportedParameters,
supportedParametersCount,
supportsToolsMeta,
modality,
inferredParamB,
createdAtMs,
pricing,
} satisfies OpenRouterModelMeta;
})
.filter((entry): entry is OpenRouterModelMeta => Boolean(entry));
});
} finally {
if (res && !res.bodyUsed) {
await res.body?.cancel().catch(() => undefined);
}
}
}
async function probeTool(
model: OpenAIModel,
apiKey: string,
timeoutMs: number,
): Promise<ProbeResult> {
const context: Context = {
messages: [
{
role: "user",
content: "Call the ping tool with {} and nothing else.",
timestamp: Date.now(),
},
],
tools: [TOOL_PING],
};
const startedAt = Date.now();
try {
const message = await withTimeout(timeoutMs, (signal) =>
complete(model, context, {
apiKey,
maxTokens: 256,
temperature: 0,
toolChoice: "required",
signal,
} satisfies OpenAICompletionsOptions),
);
const hasToolCall = message.content.some((block) => block.type === "toolCall");
if (!hasToolCall) {
return {
ok: false,
latencyMs: Date.now() - startedAt,
error: "No tool call returned",
};
}
return { ok: true, latencyMs: Date.now() - startedAt };
} catch (err) {
return {
ok: false,
latencyMs: Date.now() - startedAt,
error: formatErrorMessage(err),
};
}
}
async function probeImage(
model: OpenAIModel,
apiKey: string,
timeoutMs: number,
): Promise<ProbeResult> {
const context: Context = {
messages: [
{
role: "user",
content: [
{ type: "text", text: "Reply with OK." },
{ type: "image", data: BASE_IMAGE_PNG, mimeType: "image/png" },
],
timestamp: Date.now(),
},
],
};
const startedAt = Date.now();
try {
await withTimeout(timeoutMs, (signal) =>
complete(model, context, {
apiKey,
maxTokens: 16,
temperature: 0,
signal,
} satisfies OpenAICompletionsOptions),
);
return { ok: true, latencyMs: Date.now() - startedAt };
} catch (err) {
return {
ok: false,
latencyMs: Date.now() - startedAt,
error: formatErrorMessage(err),
};
}
}
function ensureImageInput(model: OpenAIModel): OpenAIModel {
if (model.input?.includes("image")) {
return model;
}
return {
...model,
input: uniqueStrings([...(model.input ?? []), "image"]) as OpenAIModel["input"],
};
}
function buildOpenRouterScanResult(params: {
entry: OpenRouterModelMeta;
isFree: boolean;
tool: ProbeResult;
image: ProbeResult;
}): ModelScanResult {
const { entry, isFree } = params;
return {
id: entry.id,
name: entry.name,
provider: "openrouter",
modelRef: `openrouter/${entry.id}`,
contextLength: entry.contextLength,
maxCompletionTokens: entry.maxCompletionTokens,
supportedParametersCount: entry.supportedParametersCount,
supportsToolsMeta: entry.supportsToolsMeta,
modality: entry.modality,
inferredParamB: entry.inferredParamB,
createdAtMs: entry.createdAtMs,
pricing: entry.pricing,
isFree,
tool: params.tool,
image: params.image,
};
}
export async function scanOpenRouterModels(
options: OpenRouterScanOptions = {},
): Promise<ModelScanResult[]> {
const fetchImpl = options.fetchImpl ?? fetch;
const probe = options.probe ?? true;
const apiKey = options.apiKey?.trim() || getEnvApiKey("openrouter") || "";
if (probe && !apiKey) {
throw new Error(
"Missing OpenRouter API key. Free OpenRouter models still require OPENROUTER_API_KEY for live probes and inference; call with probe:false to list public catalog metadata.",
);
}
const timeoutMs = resolveTimerTimeoutMs(options.timeoutMs, DEFAULT_TIMEOUT_MS);
const concurrency = Math.max(1, Math.floor(options.concurrency ?? DEFAULT_CONCURRENCY));
const minParamB = Math.max(0, Math.floor(options.minParamB ?? 0));
const maxAgeDays = Math.max(0, Math.floor(options.maxAgeDays ?? 0));
const providerFilter = normalizeProviderId(options.providerFilter ?? "");
const catalog = await fetchOpenRouterModels(fetchImpl, timeoutMs);
const now = Date.now();
const filtered = catalog.filter((entry) => {
if (!isFreeOpenRouterModel(entry)) {
return false;
}
if (providerFilter) {
const prefix = normalizeProviderId(entry.id.split("/")[0] ?? "");
if (prefix !== providerFilter) {
return false;
}
}
if (minParamB > 0) {
const params = entry.inferredParamB ?? 0;
if (params < minParamB) {
return false;
}
}
if (maxAgeDays > 0 && entry.createdAtMs) {
const ageMs = now - entry.createdAtMs;
const ageDays = ageMs / (24 * 60 * 60 * 1000);
if (ageDays > maxAgeDays) {
return false;
}
}
return true;
});
const baseModel: OpenAIModel = {
id: "openrouter/auto",
name: "OpenRouter Auto",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
};
options.onProgress?.({
phase: "probe",
completed: 0,
total: filtered.length,
});
let completed = 0;
return pMap(
filtered,
async (entry) => {
const isFree = isFreeOpenRouterModel(entry);
let result: ModelScanResult;
if (!probe) {
result = buildOpenRouterScanResult({
entry,
isFree,
tool: { ok: false, latencyMs: null, skipped: true },
image: { ok: false, latencyMs: null, skipped: true },
});
} else {
const model: OpenAIModel = {
...baseModel,
id: entry.id,
name: entry.name || entry.id,
contextWindow: entry.contextLength ?? baseModel.contextWindow,
maxTokens: entry.maxCompletionTokens ?? baseModel.maxTokens,
input: parseModality(entry.modality),
reasoning: baseModel.reasoning,
};
const toolResult = await probeTool(model, apiKey, timeoutMs);
const imageResult = model.input?.includes("image")
? await probeImage(ensureImageInput(model), apiKey, timeoutMs)
: { ok: false, latencyMs: null, skipped: true };
result = buildOpenRouterScanResult({
entry,
isFree,
tool: toolResult,
image: imageResult,
});
}
completed += 1;
options.onProgress?.({ phase: "probe", completed, total: filtered.length });
return result;
},
{ concurrency, stopOnError: true },
);
}