feat(models): tech preview – openclaw models sync openrouter

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.
This commit is contained in:
Josh Palmer
2026-02-03 00:26:46 +01:00
parent c83bdb73a4
commit 35b6ae3984
7 changed files with 618 additions and 189 deletions

View File

@@ -22,6 +22,7 @@ openclaw models status
openclaw models list
openclaw models set <model-or-alias>
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 <n>`
- `--agent <id>` (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 <name>` (filter by provider prefix)
- `--json` (machine-readable output)
## Aliases + fallbacks
```bash

View File

@@ -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<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>,
@@ -193,74 +80,6 @@ async function withTimeout<T>(
}
}
async function fetchOpenRouterModels(fetchImpl: typeof fetch): Promise<OpenRouterModelMeta[]> {
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<string, unknown>;
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 };

View File

@@ -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<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,
};
}
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<OpenRouterModelMeta[]> {
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<string, unknown>;
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;
}

View File

@@ -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 <name>", "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(

View File

@@ -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<typeof import("../agents/openrouter-catalog.js")>(
"../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<string, { models?: Array<{ id?: string }> }>;
};
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();
});
});

View File

@@ -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";

217
src/commands/models/sync.ts Normal file
View File

@@ -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<string, ModelsJsonProvider>;
};
type ModelsJsonProvider = {
baseUrl?: string;
apiKey?: string;
api?: ModelApi;
headers?: Record<string, string>;
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<ModelsJson> {
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<void> {
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.");
}