From 35b6ae39848fb3f6150bec9b14da09de3b4eab2a Mon Sep 17 00:00:00 2001 From: Josh Palmer Date: Tue, 3 Feb 2026 00:26:46 +0100 Subject: [PATCH] =?UTF-8?q?feat(models):=20tech=20preview=20=E2=80=93=20op?= =?UTF-8?q?enclaw=20models=20sync=20openrouter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds opt-in CLI command to fetch OpenRouter /models catalog and write matching model definitions into models.json. Shared parsing extracted into openrouter-catalog.ts to avoid duplication with model-scan. Draft POC – do not merge. --- docs/cli/models.md | 13 ++ src/agents/model-scan.ts | 197 ++------------------------- src/agents/openrouter-catalog.ts | 227 +++++++++++++++++++++++++++++++ src/cli/models-cli.ts | 25 ++++ src/commands/models.sync.test.ts | 127 +++++++++++++++++ src/commands/models.ts | 1 + src/commands/models/sync.ts | 217 +++++++++++++++++++++++++++++ 7 files changed, 618 insertions(+), 189 deletions(-) create mode 100644 src/agents/openrouter-catalog.ts create mode 100644 src/commands/models.sync.test.ts create mode 100644 src/commands/models/sync.ts diff --git a/docs/cli/models.md b/docs/cli/models.md index 4147c6f277..f5f62b4d30 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -22,6 +22,7 @@ openclaw models status openclaw models list openclaw models set openclaw models scan +openclaw models sync openrouter ``` `openclaw models status` shows the resolved default/fallbacks plus an auth overview. @@ -54,6 +55,18 @@ Options: - `--probe-max-tokens ` - `--agent ` (configured agent id; overrides `OPENCLAW_AGENT_DIR`/`PI_CODING_AGENT_DIR`) +### `models sync openrouter` + +Fetches the OpenRouter `/models` catalog and writes it to the agent `models.json` +file (under `OPENCLAW_AGENT_DIR`/`PI_CODING_AGENT_DIR`, or the default agent path). +Restart the gateway after syncing so `/model` and the picker reload the catalog. + +Options: + +- `--free-only` (only include free models) +- `--provider ` (filter by provider prefix) +- `--json` (machine-readable output) + ## Aliases + fallbacks ```bash diff --git a/src/agents/model-scan.ts b/src/agents/model-scan.ts index 996a367278..2c7973487e 100644 --- a/src/agents/model-scan.ts +++ b/src/agents/model-scan.ts @@ -8,8 +8,14 @@ import { type Tool, } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; +import { + fetchOpenRouterModels, + isFreeOpenRouterModel, + parseModality, + type OpenRouterModelMeta, + type OpenRouterModelPricing, +} from "./openrouter-catalog.js"; -const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; const DEFAULT_TIMEOUT_MS = 12_000; const DEFAULT_CONCURRENCY = 3; @@ -22,29 +28,6 @@ const TOOL_PING: Tool = { 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; -}; - export type ProbeResult = { ok: boolean; latencyMs: number | null; @@ -84,102 +67,6 @@ export type OpenRouterScanOptions = { 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; - } - if (value > 1e12) { - return Math.round(value); - } - return Math.round(value * 1000); -} - -function inferParamBFromIdOrName(text: string): number | null { - const raw = text.toLowerCase(); - const matches = raw.matchAll(/(?:^|[^a-z0-9])[a-z]?(\d+(?:\.\d+)?)b(?:[^a-z0-9]|$)/g); - let best: number | null = null; - for (const match of matches) { - const numRaw = match[1]; - if (!numRaw) { - continue; - } - const value = Number(numRaw); - if (!Number.isFinite(value) || value <= 0) { - continue; - } - if (best === null || value > best) { - best = value; - } - } - return best; -} - -function parseModality(modality: string | null): Array<"text" | "image"> { - if (!modality) { - return ["text"]; - } - const normalized = modality.toLowerCase(); - 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; - 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( timeoutMs: number, fn: (signal: AbortSignal) => Promise, @@ -193,74 +80,6 @@ async function withTimeout( } } -async function fetchOpenRouterModels(fetchImpl: typeof fetch): Promise { - const res = await fetchImpl(OPENROUTER_MODELS_URL, { - headers: { Accept: "application/json" }, - }); - if (!res.ok) { - throw new Error(`OpenRouter /models failed: HTTP ${res.status}`); - } - const payload = (await res.json()) 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; - const id = typeof obj.id === "string" ? obj.id.trim() : ""; - 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) - ? obj.supported_parameters - .filter((value): value is string => typeof value === "string") - .map((value) => value.trim()) - .filter(Boolean) - : []; - - 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)); -} - async function probeTool( model: OpenAIModel, apiKey: string, @@ -509,5 +328,5 @@ export async function scanOpenRouterModels( ); } -export { OPENROUTER_MODELS_URL }; +export { OPENROUTER_MODELS_URL } from "./openrouter-catalog.js"; export type { OpenRouterModelMeta, OpenRouterModelPricing }; diff --git a/src/agents/openrouter-catalog.ts b/src/agents/openrouter-catalog.ts new file mode 100644 index 0000000000..c046b3b9a5 --- /dev/null +++ b/src/agents/openrouter-catalog.ts @@ -0,0 +1,227 @@ +import type { Model } from "@mariozechner/pi-ai"; +import type { ModelDefinitionConfig } from "../config/types.models.js"; + +export const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; + +export type OpenRouterModelPricing = { + prompt: number; + completion: number; + request: number; + image: number; + webSearch: number; + internalReasoning: number; +}; + +export 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; +}; + +export function normalizeCreatedAtMs(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) { + return null; + } + if (value <= 0) { + return null; + } + if (value > 1e12) { + return Math.round(value); + } + return Math.round(value * 1000); +} + +export function inferParamBFromIdOrName(text: string): number | null { + const raw = text.toLowerCase(); + const matches = raw.matchAll(/(?:^|[^a-z0-9])[a-z]?(\d+(?:\.\d+)?)b(?:[^a-z0-9]|$)/g); + let best: number | null = null; + for (const match of matches) { + const numRaw = match[1]; + if (!numRaw) { + continue; + } + const value = Number(numRaw); + if (!Number.isFinite(value) || value <= 0) { + continue; + } + if (best === null || value > best) { + best = value; + } + } + return best; +} + +export function parseModality(modality: string | null): Array<"text" | "image"> { + if (!modality) { + return ["text"]; + } + const normalized = modality.toLowerCase(); + 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; +} + +export function parseOpenRouterPricing(value: unknown): OpenRouterModelPricing | null { + if (!value || typeof value !== "object") { + return null; + } + const obj = value as Record; + 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, + }; +} + +export 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; +} + +export async function fetchOpenRouterModels( + fetchImpl: typeof fetch, +): Promise { + const res = await fetchImpl(OPENROUTER_MODELS_URL, { + headers: { Accept: "application/json" }, + }); + if (!res.ok) { + throw new Error(`OpenRouter /models failed: HTTP ${res.status}`); + } + const payload = (await res.json()) 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; + const id = typeof obj.id === "string" ? obj.id.trim() : ""; + 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) + ? obj.supported_parameters + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean) + : []; + + 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)); +} + +function resolvePositiveNumber(value: number | null | undefined, fallback: number): number { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return Math.round(value); + } + return Math.round(fallback); +} + +const REASONING_HINTS = ["reasoning", "reasoning_effort"]; + +export function buildOpenRouterModelDefinition(params: { + entry: OpenRouterModelMeta; + baseModel: Model<"openai-completions">; +}): ModelDefinitionConfig { + const { entry, baseModel } = params; + const reasoning = entry.supportedParameters.some((param) => + REASONING_HINTS.some((hint) => param.toLowerCase().includes(hint)), + ); + const pricing = entry.pricing; + return { + id: entry.id, + name: entry.name || entry.id, + reasoning, + input: parseModality(entry.modality), + cost: { + input: pricing?.prompt ?? 0, + output: pricing?.completion ?? 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: resolvePositiveNumber(entry.contextLength, baseModel.contextWindow), + maxTokens: resolvePositiveNumber(entry.maxCompletionTokens, baseModel.maxTokens), + } satisfies ModelDefinitionConfig; +} diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index d3be2d6c13..b92e21fc78 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -24,6 +24,7 @@ import { modelsSetCommand, modelsSetImageCommand, modelsStatusCommand, + modelsSyncOpenRouterCommand, } from "../commands/models.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; @@ -275,6 +276,30 @@ export function registerModelsCli(program: Command) { }); }); + const sync = models.command("sync").description("Sync remote model catalogs"); + sync.action(() => { + sync.help(); + }); + + sync + .command("openrouter") + .description("Sync OpenRouter model catalog into models.json") + .option("--provider ", "Filter by provider prefix") + .option("--free-only", "Only include free OpenRouter models", false) + .option("--json", "Output JSON", false) + .action(async (opts) => { + await runModelsCommand(async () => { + await modelsSyncOpenRouterCommand( + { + provider: opts.provider as string | undefined, + freeOnly: Boolean(opts.freeOnly), + json: Boolean(opts.json), + }, + defaultRuntime, + ); + }); + }); + models.action(async (opts) => { await runModelsCommand(async () => { await modelsStatusCommand( diff --git a/src/commands/models.sync.test.ts b/src/commands/models.sync.test.ts new file mode 100644 index 0000000000..052feff63c --- /dev/null +++ b/src/commands/models.sync.test.ts @@ -0,0 +1,127 @@ +import type { Model } from "@mariozechner/pi-ai"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const loadConfig = vi.fn().mockReturnValue({}); +const ensureOpenClawModelsJson = vi.fn().mockResolvedValue(undefined); +const resolveOpenClawAgentDir = vi.fn(); +const fetchOpenRouterModels = vi.fn(); +const getModel = vi.fn(); + +vi.mock("../config/config.js", () => ({ + CONFIG_PATH: "/tmp/openclaw.json", + loadConfig, +})); + +vi.mock("../agents/models-config.js", () => ({ + ensureOpenClawModelsJson, +})); + +vi.mock("../agents/agent-paths.js", () => ({ + resolveOpenClawAgentDir, +})); + +vi.mock("@mariozechner/pi-ai", () => ({ + getModel, +})); + +vi.mock("../agents/openrouter-catalog.js", async () => { + const actual = await vi.importActual( + "../agents/openrouter-catalog.js", + ); + return { ...actual, fetchOpenRouterModels }; +}); + +function makeRuntime() { + return { log: vi.fn(), error: vi.fn() }; +} + +describe("models sync openrouter", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-models-")); + resolveOpenClawAgentDir.mockReturnValue(tempDir); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + vi.clearAllMocks(); + }); + + it("writes filtered OpenRouter models to models.json", async () => { + const baseModel = { + id: "openrouter/auto", + name: "OpenRouter: Auto Router", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 2000000, + maxTokens: 30000, + } satisfies Model<"openai-completions">; + + getModel.mockReturnValue(baseModel); + fetchOpenRouterModels.mockResolvedValue([ + { + id: "anthropic/claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + contextLength: 200000, + maxCompletionTokens: 8192, + supportedParameters: ["tools"], + supportedParametersCount: 1, + supportsToolsMeta: true, + modality: "text+image", + inferredParamB: 80, + createdAtMs: null, + pricing: { + prompt: 0, + completion: 0, + request: 0, + image: 0, + webSearch: 0, + internalReasoning: 0, + }, + }, + { + id: "openai/gpt-5.2", + name: "GPT-5.2", + contextLength: 200000, + maxCompletionTokens: 8192, + supportedParameters: ["tools"], + supportedParametersCount: 1, + supportsToolsMeta: true, + modality: "text", + inferredParamB: 0, + createdAtMs: null, + pricing: { + prompt: 1, + completion: 2, + request: 0, + image: 0, + webSearch: 0, + internalReasoning: 0, + }, + }, + ]); + + const runtime = makeRuntime(); + const { modelsSyncOpenRouterCommand } = await import("./models/sync.js"); + + await modelsSyncOpenRouterCommand({ provider: "anthropic", freeOnly: true }, runtime as never); + + const modelsPath = path.join(tempDir, "models.json"); + const raw = await fs.readFile(modelsPath, "utf8"); + const parsed = JSON.parse(raw) as { + providers?: Record }>; + }; + + const models = parsed.providers?.openrouter?.models ?? []; + const ids = models.map((entry) => entry.id); + expect(ids).toContain("openrouter/auto"); + expect(ids).toContain("anthropic/claude-sonnet-4-5"); + expect(ids).not.toContain("openai/gpt-5.2"); + expect(runtime.log).toHaveBeenCalled(); + }); +}); diff --git a/src/commands/models.ts b/src/commands/models.ts index 5a1c103c8f..acabe47ab1 100644 --- a/src/commands/models.ts +++ b/src/commands/models.ts @@ -31,3 +31,4 @@ export { modelsListCommand, modelsStatusCommand } from "./models/list.js"; export { modelsScanCommand } from "./models/scan.js"; export { modelsSetCommand } from "./models/set.js"; export { modelsSetImageCommand } from "./models/set-image.js"; +export { modelsSyncOpenRouterCommand } from "./models/sync.js"; diff --git a/src/commands/models/sync.ts b/src/commands/models/sync.ts new file mode 100644 index 0000000000..7b681bdc44 --- /dev/null +++ b/src/commands/models/sync.ts @@ -0,0 +1,217 @@ +import { getModel, type Model } from "@mariozechner/pi-ai"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { ModelApi, ModelDefinitionConfig } from "../../config/types.models.js"; +import type { RuntimeEnv } from "../../runtime.js"; +import { resolveOpenClawAgentDir } from "../../agents/agent-paths.js"; +import { ensureOpenClawModelsJson } from "../../agents/models-config.js"; +import { + buildOpenRouterModelDefinition, + fetchOpenRouterModels, + isFreeOpenRouterModel, + type OpenRouterModelMeta, +} from "../../agents/openrouter-catalog.js"; +import { withProgressTotals } from "../../cli/progress.js"; +import { loadConfig } from "../../config/config.js"; + +const DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; +const DEFAULT_OPENROUTER_API_KEY_REF = "OPENROUTER_API_KEY"; +const DEFAULT_OPENROUTER_API: ModelApi = "openai-completions"; + +const PROGRESS_STEP = 50; + +type ModelsJson = { + providers?: Record; +}; + +type ModelsJsonProvider = { + baseUrl?: string; + apiKey?: string; + api?: ModelApi; + headers?: Record; + authHeader?: boolean; + models?: ModelDefinitionConfig[]; +}; + +function normalizeProviderFilter(value?: string): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + return trimmed.toLowerCase(); +} + +async function readModelsJson(filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, "utf8"); + if (!raw.trim()) { + return { providers: {} }; + } + const parsed = JSON.parse(raw) as ModelsJson; + if (!parsed || typeof parsed !== "object") { + return { providers: {} }; + } + return parsed; + } catch (err) { + if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") { + return { providers: {} }; + } + throw err; + } +} + +async function writeModelsJson(filePath: string, payload: ModelsJson): Promise { + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + const raw = `${JSON.stringify(payload, null, 2)}\n`; + await fs.writeFile(filePath, raw, { mode: 0o600 }); +} + +function buildOpenRouterAutoModel( + baseModel: Model<"openai-completions"> | undefined, +): ModelDefinitionConfig { + if (!baseModel) { + throw new Error("Missing base OpenRouter model (openrouter/auto)."); + } + return { + id: baseModel.id, + name: baseModel.name || baseModel.id, + reasoning: baseModel.reasoning ?? false, + input: baseModel.input ?? ["text"], + cost: baseModel.cost ?? { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: baseModel.contextWindow ?? 1, + maxTokens: baseModel.maxTokens ?? 1, + } satisfies ModelDefinitionConfig; +} + +function filterOpenRouterCatalog(params: { + catalog: OpenRouterModelMeta[]; + providerFilter?: string; + freeOnly?: boolean; +}) { + const providerFilter = normalizeProviderFilter(params.providerFilter); + return params.catalog.filter((entry) => { + if (params.freeOnly && !isFreeOpenRouterModel(entry)) { + return false; + } + if (providerFilter) { + const prefix = entry.id.split("/")[0]?.toLowerCase() ?? ""; + if (prefix !== providerFilter) { + return false; + } + } + return true; + }); +} + +export async function modelsSyncOpenRouterCommand( + opts: { + provider?: string; + freeOnly?: boolean; + json?: boolean; + }, + runtime: RuntimeEnv, +) { + const cfg = loadConfig(); + await ensureOpenClawModelsJson(cfg); + + const baseModel = getModel("openrouter", "openrouter/auto") as + | Model<"openai-completions"> + | undefined; + if (!baseModel) { + throw new Error("Missing built-in OpenRouter base model definition."); + } + + const { models, filteredCount } = await withProgressTotals( + { + label: "Fetching OpenRouter models...", + indeterminate: true, + enabled: opts.json !== true, + }, + async (update, progress) => { + const catalog = await fetchOpenRouterModels(fetch); + const filtered = filterOpenRouterCatalog({ + catalog, + providerFilter: opts.provider, + freeOnly: opts.freeOnly, + }).toSorted((a, b) => a.id.localeCompare(b.id)); + progress.setLabel(`Building OpenRouter catalog (${filtered.length})`); + const total = filtered.length + 1; + let completed = 0; + const nextModels: ModelDefinitionConfig[] = []; + + for (const entry of filtered) { + nextModels.push(buildOpenRouterModelDefinition({ entry, baseModel })); + completed += 1; + if (completed % PROGRESS_STEP === 0 || completed === total) { + update({ completed, total }); + } + } + + const autoModel = buildOpenRouterAutoModel(baseModel); + if (!nextModels.some((entry) => entry.id === autoModel.id)) { + nextModels.unshift(autoModel); + } + + update({ completed: total, total }); + return { models: nextModels, filteredCount: filtered.length }; + }, + ); + + const agentDir = resolveOpenClawAgentDir(); + const modelsPath = path.join(agentDir, "models.json"); + const existing = await readModelsJson(modelsPath); + const providers = existing.providers ? { ...existing.providers } : {}; + const existingProvider = providers.openrouter ?? {}; + + providers.openrouter = { + baseUrl: existingProvider.baseUrl ?? DEFAULT_OPENROUTER_BASE_URL, + apiKey: existingProvider.apiKey ?? DEFAULT_OPENROUTER_API_KEY_REF, + api: existingProvider.api ?? DEFAULT_OPENROUTER_API, + headers: existingProvider.headers, + authHeader: existingProvider.authHeader, + models, + } satisfies ModelsJsonProvider; + + const nextPayload: ModelsJson = { + ...existing, + providers, + }; + + await writeModelsJson(modelsPath, nextPayload); + + if (opts.json) { + runtime.log( + JSON.stringify( + { + ok: true, + provider: "openrouter", + modelCount: models.length, + filteredCount, + path: modelsPath, + freeOnly: Boolean(opts.freeOnly), + providerFilter: normalizeProviderFilter(opts.provider) ?? null, + restartRequired: true, + }, + null, + 2, + ), + ); + return; + } + + runtime.log(`Synced ${models.length} OpenRouter models to ${modelsPath}.`); + if (opts.freeOnly) { + runtime.log(`Filter: free-only (${filteredCount} OpenRouter catalog entries).`); + } else if (opts.provider) { + runtime.log( + `Filter: provider=${normalizeProviderFilter(opts.provider)} (${filteredCount} entries).`, + ); + } + runtime.log("Restart the gateway to pick up the updated catalog."); +}