* fix: fetch OpenRouter model capabilities at runtime for unknown models When an OpenRouter model is not in the built-in static snapshot from pi-ai, the fallback hardcodes input: ["text"], silently dropping images. Query the OpenRouter API at runtime to detect actual capabilities (image support, reasoning, context window) for models not in the built-in list. Results are cached in memory for 1 hour. On API failure/timeout, falls back to text-only (no regression). * feat(openrouter): add disk cache for OpenRouter model capabilities Persist the OpenRouter model catalog to ~/.openclaw/cache/openrouter-models.json so it survives process restarts. Cache lookup order: 1. In-memory Map (instant) 2. On-disk JSON file (avoids network on restart) 3. OpenRouter API fetch (populates both layers) Also triggers a background refresh when a model is not found in the cache, in case it was newly added to OpenRouter. * refactor(openrouter): remove pre-warm, use pure lazy-load with disk cache - Remove eager ensureOpenRouterModelCache() from run.ts - Remove TTL — model capabilities are stable, no periodic re-fetching - Cache lookup: in-memory → disk → API fetch (only when needed) - API is only called when no cache exists or a model is not found - Disk cache persists across gateway restarts * fix(openrouter): address review feedback - Fix timer leak: move clearTimeout to finally block - Fix modality check: only check input side of "->" separator to avoid matching image-generation models (text->image) - Use resolveStateDir() instead of hardcoded homedir()/.openclaw - Separate cache dir and filename constants - Add utf-8 encoding to writeFileSync for consistency - Add data validation when reading disk cache * ci: retrigger checks * fix: preload unknown OpenRouter model capabilities before resolve * fix: accept top-level OpenRouter max token metadata * fix: update changelog for OpenRouter runtime capability lookup (#45824) (thanks @DJjjjhao) * fix: avoid redundant OpenRouter refetches and preserve suppression guards --------- Co-authored-by: Ayaan Zaidi <hi@obviy.us>
302 lines
9.0 KiB
TypeScript
302 lines
9.0 KiB
TypeScript
/**
|
|
* Runtime OpenRouter model capability detection.
|
|
*
|
|
* When an OpenRouter model is not in the built-in static list, we look up its
|
|
* actual capabilities from a cached copy of the OpenRouter model catalog.
|
|
*
|
|
* Cache layers (checked in order):
|
|
* 1. In-memory Map (instant, cleared on process restart)
|
|
* 2. On-disk JSON file (<stateDir>/cache/openrouter-models.json)
|
|
* 3. OpenRouter API fetch (populates both layers)
|
|
*
|
|
* Model capabilities are assumed stable — the cache has no TTL expiry.
|
|
* A background refresh is triggered only when a model is not found in
|
|
* the cache (i.e. a newly added model on OpenRouter).
|
|
*
|
|
* Sync callers can read whatever is already cached. Async callers can await a
|
|
* one-time fetch so the first unknown-model lookup resolves with real
|
|
* capabilities instead of the text-only fallback.
|
|
*/
|
|
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { resolveStateDir } from "../../config/paths.js";
|
|
import { resolveProxyFetchFromEnv } from "../../infra/net/proxy-fetch.js";
|
|
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
|
|
|
const log = createSubsystemLogger("openrouter-model-capabilities");
|
|
|
|
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
|
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
const DISK_CACHE_FILENAME = "openrouter-models.json";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface OpenRouterApiModel {
|
|
id: string;
|
|
name?: string;
|
|
modality?: string;
|
|
architecture?: {
|
|
modality?: string;
|
|
};
|
|
supported_parameters?: string[];
|
|
context_length?: number;
|
|
max_completion_tokens?: number;
|
|
max_output_tokens?: number;
|
|
top_provider?: {
|
|
max_completion_tokens?: number;
|
|
};
|
|
pricing?: {
|
|
prompt?: string;
|
|
completion?: string;
|
|
input_cache_read?: string;
|
|
input_cache_write?: string;
|
|
};
|
|
}
|
|
|
|
export interface OpenRouterModelCapabilities {
|
|
name: string;
|
|
input: Array<"text" | "image">;
|
|
reasoning: boolean;
|
|
contextWindow: number;
|
|
maxTokens: number;
|
|
cost: {
|
|
input: number;
|
|
output: number;
|
|
cacheRead: number;
|
|
cacheWrite: number;
|
|
};
|
|
}
|
|
|
|
interface DiskCachePayload {
|
|
models: Record<string, OpenRouterModelCapabilities>;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Disk cache
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function resolveDiskCacheDir(): string {
|
|
return join(resolveStateDir(), "cache");
|
|
}
|
|
|
|
function resolveDiskCachePath(): string {
|
|
return join(resolveDiskCacheDir(), DISK_CACHE_FILENAME);
|
|
}
|
|
|
|
function writeDiskCache(map: Map<string, OpenRouterModelCapabilities>): void {
|
|
try {
|
|
const cacheDir = resolveDiskCacheDir();
|
|
if (!existsSync(cacheDir)) {
|
|
mkdirSync(cacheDir, { recursive: true });
|
|
}
|
|
const payload: DiskCachePayload = {
|
|
models: Object.fromEntries(map),
|
|
};
|
|
writeFileSync(resolveDiskCachePath(), JSON.stringify(payload), "utf-8");
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
log.debug(`Failed to write OpenRouter disk cache: ${message}`);
|
|
}
|
|
}
|
|
|
|
function isValidCapabilities(value: unknown): value is OpenRouterModelCapabilities {
|
|
if (!value || typeof value !== "object") {
|
|
return false;
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
return (
|
|
typeof record.name === "string" &&
|
|
Array.isArray(record.input) &&
|
|
typeof record.reasoning === "boolean" &&
|
|
typeof record.contextWindow === "number" &&
|
|
typeof record.maxTokens === "number"
|
|
);
|
|
}
|
|
|
|
function readDiskCache(): Map<string, OpenRouterModelCapabilities> | undefined {
|
|
try {
|
|
const cachePath = resolveDiskCachePath();
|
|
if (!existsSync(cachePath)) {
|
|
return undefined;
|
|
}
|
|
const raw = readFileSync(cachePath, "utf-8");
|
|
const payload = JSON.parse(raw) as unknown;
|
|
if (!payload || typeof payload !== "object") {
|
|
return undefined;
|
|
}
|
|
const models = (payload as DiskCachePayload).models;
|
|
if (!models || typeof models !== "object") {
|
|
return undefined;
|
|
}
|
|
const map = new Map<string, OpenRouterModelCapabilities>();
|
|
for (const [id, caps] of Object.entries(models)) {
|
|
if (isValidCapabilities(caps)) {
|
|
map.set(id, caps);
|
|
}
|
|
}
|
|
return map.size > 0 ? map : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// In-memory cache state
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let cache: Map<string, OpenRouterModelCapabilities> | undefined;
|
|
let fetchInFlight: Promise<void> | undefined;
|
|
const skipNextMissRefresh = new Set<string>();
|
|
|
|
function parseModel(model: OpenRouterApiModel): OpenRouterModelCapabilities {
|
|
const input: Array<"text" | "image"> = ["text"];
|
|
const modality = model.architecture?.modality ?? model.modality ?? "";
|
|
const inputModalities = modality.split("->")[0] ?? "";
|
|
if (inputModalities.includes("image")) {
|
|
input.push("image");
|
|
}
|
|
|
|
return {
|
|
name: model.name || model.id,
|
|
input,
|
|
reasoning: model.supported_parameters?.includes("reasoning") ?? false,
|
|
contextWindow: model.context_length || 128_000,
|
|
maxTokens:
|
|
model.top_provider?.max_completion_tokens ??
|
|
model.max_completion_tokens ??
|
|
model.max_output_tokens ??
|
|
8192,
|
|
cost: {
|
|
input: parseFloat(model.pricing?.prompt || "0") * 1_000_000,
|
|
output: parseFloat(model.pricing?.completion || "0") * 1_000_000,
|
|
cacheRead: parseFloat(model.pricing?.input_cache_read || "0") * 1_000_000,
|
|
cacheWrite: parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// API fetch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function doFetch(): Promise<void> {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
try {
|
|
const fetchFn = resolveProxyFetchFromEnv() ?? globalThis.fetch;
|
|
|
|
const response = await fetchFn(OPENROUTER_MODELS_URL, {
|
|
signal: controller.signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
log.warn(`OpenRouter models API returned ${response.status}`);
|
|
return;
|
|
}
|
|
|
|
const data = (await response.json()) as { data?: OpenRouterApiModel[] };
|
|
const models = data.data ?? [];
|
|
const map = new Map<string, OpenRouterModelCapabilities>();
|
|
|
|
for (const model of models) {
|
|
if (!model.id) {
|
|
continue;
|
|
}
|
|
map.set(model.id, parseModel(model));
|
|
}
|
|
|
|
cache = map;
|
|
writeDiskCache(map);
|
|
log.debug(`Cached ${map.size} OpenRouter models from API`);
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
log.warn(`Failed to fetch OpenRouter models: ${message}`);
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function triggerFetch(): void {
|
|
if (fetchInFlight) {
|
|
return;
|
|
}
|
|
fetchInFlight = doFetch().finally(() => {
|
|
fetchInFlight = undefined;
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Ensure the cache is populated. Checks in-memory first, then disk, then
|
|
* triggers a background API fetch as a last resort.
|
|
* Does not block — returns immediately.
|
|
*/
|
|
export function ensureOpenRouterModelCache(): void {
|
|
if (cache) {
|
|
return;
|
|
}
|
|
|
|
// Try loading from disk before hitting the network.
|
|
const disk = readDiskCache();
|
|
if (disk) {
|
|
cache = disk;
|
|
log.debug(`Loaded ${disk.size} OpenRouter models from disk cache`);
|
|
return;
|
|
}
|
|
|
|
triggerFetch();
|
|
}
|
|
|
|
/**
|
|
* Ensure capabilities for a specific model are available before first use.
|
|
*
|
|
* Known cached entries return immediately. Unknown entries wait for at most
|
|
* one catalog fetch, then leave sync resolution to read from the populated
|
|
* cache on the same request.
|
|
*/
|
|
export async function loadOpenRouterModelCapabilities(modelId: string): Promise<void> {
|
|
ensureOpenRouterModelCache();
|
|
if (cache?.has(modelId)) {
|
|
return;
|
|
}
|
|
let fetchPromise = fetchInFlight;
|
|
if (!fetchPromise) {
|
|
triggerFetch();
|
|
fetchPromise = fetchInFlight;
|
|
}
|
|
await fetchPromise;
|
|
if (!cache?.has(modelId)) {
|
|
skipNextMissRefresh.add(modelId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Synchronously look up model capabilities from the cache.
|
|
*
|
|
* If a model is not found but the cache exists, a background refresh is
|
|
* triggered in case it's a newly added model not yet in the cache.
|
|
*/
|
|
export function getOpenRouterModelCapabilities(
|
|
modelId: string,
|
|
): OpenRouterModelCapabilities | undefined {
|
|
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)) {
|
|
return undefined;
|
|
}
|
|
if (!result && cache && !fetchInFlight) {
|
|
triggerFetch();
|
|
}
|
|
|
|
return result;
|
|
}
|