feat(models): improve OpenRouter free scan

This commit is contained in:
Peter Steinberger
2026-01-08 04:20:34 +01:00
parent 6feeb651ee
commit 3178a3014d
4 changed files with 217 additions and 17 deletions

View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { scanOpenRouterModels } from "./model-scan.js";
function createFetchFixture(payload: unknown): typeof fetch {
return async () =>
new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}
describe("scanOpenRouterModels", () => {
it("lists free models without probing", async () => {
const fetchImpl = createFetchFixture({
data: [
{
id: "acme/free-by-pricing",
name: "Free By Pricing",
context_length: 16_384,
max_completion_tokens: 1024,
supported_parameters: ["tools", "tool_choice", "temperature"],
modality: "text",
pricing: { prompt: "0", completion: "0", request: "0", image: "0" },
created_at: 1_700_000_000,
},
{
id: "acme/free-by-suffix:free",
name: "Free By Suffix",
context_length: 8_192,
supported_parameters: [],
modality: "text",
pricing: { prompt: "0", completion: "0" },
},
{
id: "acme/paid",
name: "Paid",
context_length: 4_096,
supported_parameters: ["tools"],
modality: "text",
pricing: { prompt: "0.000001", completion: "0.000002" },
},
],
});
const results = await scanOpenRouterModels({
fetchImpl,
probe: false,
});
expect(results.map((entry) => entry.id)).toEqual([
"acme/free-by-pricing",
"acme/free-by-suffix:free",
]);
const byPricing = results[0]!;
expect(byPricing.supportsToolsMeta).toBe(true);
expect(byPricing.supportedParametersCount).toBe(3);
expect(byPricing.isFree).toBe(true);
expect(byPricing.tool.skipped).toBe(true);
expect(byPricing.image.skipped).toBe(true);
});
it("requires an API key when probing", async () => {
const fetchImpl = createFetchFixture({ data: [] });
const previousKey = process.env.OPENROUTER_API_KEY;
try {
delete process.env.OPENROUTER_API_KEY;
await expect(
scanOpenRouterModels({
fetchImpl,
probe: true,
apiKey: "",
}),
).rejects.toThrow(/Missing OpenRouter API key/);
} finally {
if (previousKey === undefined) {
delete process.env.OPENROUTER_API_KEY;
} else {
process.env.OPENROUTER_API_KEY = previousKey;
}
}
});
});

View File

@@ -27,10 +27,22 @@ type OpenRouterModelMeta = {
name: string; name: string;
contextLength: number | null; contextLength: number | null;
maxCompletionTokens: number | null; maxCompletionTokens: number | null;
supportedParameters: string[];
supportedParametersCount: number; supportedParametersCount: number;
supportsToolsMeta: boolean;
modality: string | null; modality: string | null;
inferredParamB: number | null; inferredParamB: number | null;
createdAtMs: 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 = { export type ProbeResult = {
@@ -48,9 +60,12 @@ export type ModelScanResult = {
contextLength: number | null; contextLength: number | null;
maxCompletionTokens: number | null; maxCompletionTokens: number | null;
supportedParametersCount: number; supportedParametersCount: number;
supportsToolsMeta: boolean;
modality: string | null; modality: string | null;
inferredParamB: number | null; inferredParamB: number | null;
createdAtMs: number | null; createdAtMs: number | null;
pricing: OpenRouterModelPricing | null;
isFree: boolean;
tool: ProbeResult; tool: ProbeResult;
image: ProbeResult; image: ProbeResult;
}; };
@@ -63,6 +78,7 @@ export type OpenRouterScanOptions = {
minParamB?: number; minParamB?: number;
maxAgeDays?: number; maxAgeDays?: number;
providerFilter?: string; providerFilter?: string;
probe?: boolean;
}; };
type OpenAIModel = Model<"openai-completions">; type OpenAIModel = Model<"openai-completions">;
@@ -98,6 +114,43 @@ function parseModality(modality: string | null): Array<"text" | "image"> {
return hasImage ? ["text", "image"] : ["text"]; 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>( async function withTimeout<T>(
timeoutMs: number, timeoutMs: number,
fn: (signal: AbortSignal) => Promise<T>, fn: (signal: AbortSignal) => Promise<T>,
@@ -147,9 +200,15 @@ async function fetchOpenRouterModels(
? obj.max_output_tokens ? obj.max_output_tokens
: null; : null;
const supportedParametersCount = Array.isArray(obj.supported_parameters) const supportedParameters = Array.isArray(obj.supported_parameters)
? obj.supported_parameters.length ? obj.supported_parameters
: 0; .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 = const modality =
typeof obj.modality === "string" && obj.modality.trim() typeof obj.modality === "string" && obj.modality.trim()
@@ -158,16 +217,20 @@ async function fetchOpenRouterModels(
const inferredParamB = inferParamBFromIdOrName(`${id} ${name}`); const inferredParamB = inferParamBFromIdOrName(`${id} ${name}`);
const createdAtMs = normalizeCreatedAtMs(obj.created_at); const createdAtMs = normalizeCreatedAtMs(obj.created_at);
const pricing = parseOpenRouterPricing(obj.pricing);
return { return {
id, id,
name, name,
contextLength, contextLength,
maxCompletionTokens, maxCompletionTokens,
supportedParameters,
supportedParametersCount, supportedParametersCount,
supportsToolsMeta,
modality, modality,
inferredParamB, inferredParamB,
createdAtMs, createdAtMs,
pricing,
} satisfies OpenRouterModelMeta; } satisfies OpenRouterModelMeta;
}) })
.filter((entry): entry is OpenRouterModelMeta => Boolean(entry)); .filter((entry): entry is OpenRouterModelMeta => Boolean(entry));
@@ -294,8 +357,9 @@ export async function scanOpenRouterModels(
options: OpenRouterScanOptions = {}, options: OpenRouterScanOptions = {},
): Promise<ModelScanResult[]> { ): Promise<ModelScanResult[]> {
const fetchImpl = options.fetchImpl ?? fetch; const fetchImpl = options.fetchImpl ?? fetch;
const probe = options.probe ?? true;
const apiKey = options.apiKey?.trim() || getEnvApiKey("openrouter") || ""; const apiKey = options.apiKey?.trim() || getEnvApiKey("openrouter") || "";
if (!apiKey) { if (probe && !apiKey) {
throw new Error( throw new Error(
"Missing OpenRouter API key. Set OPENROUTER_API_KEY to run models scan.", "Missing OpenRouter API key. Set OPENROUTER_API_KEY to run models scan.",
); );
@@ -317,7 +381,7 @@ export async function scanOpenRouterModels(
const now = Date.now(); const now = Date.now();
const filtered = catalog.filter((entry) => { const filtered = catalog.filter((entry) => {
if (!entry.id.endsWith(":free")) return false; if (!isFreeOpenRouterModel(entry)) return false;
if (providerFilter) { if (providerFilter) {
const prefix = entry.id.split("/")[0]?.toLowerCase() ?? ""; const prefix = entry.id.split("/")[0]?.toLowerCase() ?? "";
if (prefix !== providerFilter) return false; if (prefix !== providerFilter) return false;
@@ -337,6 +401,27 @@ export async function scanOpenRouterModels(
const baseModel = getModel("openrouter", "openrouter/auto") as OpenAIModel; const baseModel = getModel("openrouter", "openrouter/auto") as OpenAIModel;
return mapWithConcurrency(filtered, concurrency, async (entry) => { return mapWithConcurrency(filtered, concurrency, async (entry) => {
const isFree = isFreeOpenRouterModel(entry);
if (!probe) {
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: { ok: false, latencyMs: null, skipped: true },
image: { ok: false, latencyMs: null, skipped: true },
} satisfies ModelScanResult;
}
const model: OpenAIModel = { const model: OpenAIModel = {
...baseModel, ...baseModel,
id: entry.id, id: entry.id,
@@ -360,9 +445,12 @@ export async function scanOpenRouterModels(
contextLength: entry.contextLength, contextLength: entry.contextLength,
maxCompletionTokens: entry.maxCompletionTokens, maxCompletionTokens: entry.maxCompletionTokens,
supportedParametersCount: entry.supportedParametersCount, supportedParametersCount: entry.supportedParametersCount,
supportsToolsMeta: entry.supportsToolsMeta,
modality: entry.modality, modality: entry.modality,
inferredParamB: entry.inferredParamB, inferredParamB: entry.inferredParamB,
createdAtMs: entry.createdAtMs, createdAtMs: entry.createdAtMs,
pricing: entry.pricing,
isFree,
tool: toolResult, tool: toolResult,
image: imageResult, image: imageResult,
} satisfies ModelScanResult; } satisfies ModelScanResult;
@@ -370,4 +458,4 @@ export async function scanOpenRouterModels(
} }
export { OPENROUTER_MODELS_URL }; export { OPENROUTER_MODELS_URL };
export type { OpenRouterModelMeta }; export type { OpenRouterModelMeta, OpenRouterModelPricing };

View File

@@ -24,9 +24,13 @@ export function registerModelsCli(program: Command) {
const models = program const models = program
.command("models") .command("models")
.description("Model discovery, scanning, and configuration") .description("Model discovery, scanning, and configuration")
.option("--json", "Output JSON (alias for `models status --json`)", false)
.option( .option(
"--plain", "--status-json",
"Output JSON (alias for `models status --json`)",
false,
)
.option(
"--status-plain",
"Plain output (alias for `models status --plain`)", "Plain output (alias for `models status --plain`)",
false, false,
); );
@@ -252,6 +256,7 @@ export function registerModelsCli(program: Command) {
.option("--max-candidates <n>", "Max fallback candidates", "6") .option("--max-candidates <n>", "Max fallback candidates", "6")
.option("--timeout <ms>", "Per-probe timeout in ms") .option("--timeout <ms>", "Per-probe timeout in ms")
.option("--concurrency <n>", "Probe concurrency") .option("--concurrency <n>", "Probe concurrency")
.option("--no-probe", "Skip live probes; list free candidates only")
.option("--yes", "Accept defaults without prompting", false) .option("--yes", "Accept defaults without prompting", false)
.option("--no-input", "Disable prompts (use defaults)") .option("--no-input", "Disable prompts (use defaults)")
.option("--set-default", "Set agent.model to the first selection", false) .option("--set-default", "Set agent.model to the first selection", false)
@@ -272,7 +277,13 @@ export function registerModelsCli(program: Command) {
models.action(async (opts) => { models.action(async (opts) => {
try { try {
await modelsStatusCommand(opts ?? {}, defaultRuntime); await modelsStatusCommand(
{
json: Boolean(opts?.statusJson),
plain: Boolean(opts?.statusPlain),
},
defaultRuntime,
);
} catch (err) { } catch (err) {
defaultRuntime.error(String(err)); defaultRuntime.error(String(err));
defaultRuntime.exit(1); defaultRuntime.exit(1);

View File

@@ -140,6 +140,7 @@ export async function modelsScanCommand(
setDefault?: boolean; setDefault?: boolean;
setImage?: boolean; setImage?: boolean;
json?: boolean; json?: boolean;
probe?: boolean;
}, },
runtime: RuntimeEnv, runtime: RuntimeEnv,
) { ) {
@@ -174,15 +175,18 @@ export async function modelsScanCommand(
} }
const cfg = loadConfig(); const cfg = loadConfig();
const probe = opts.probe ?? true;
let storedKey: string | undefined; let storedKey: string | undefined;
try { if (probe) {
const resolved = await resolveApiKeyForProvider({ try {
provider: "openrouter", const resolved = await resolveApiKeyForProvider({
cfg, provider: "openrouter",
}); cfg,
storedKey = resolved.apiKey; });
} catch { storedKey = resolved.apiKey;
storedKey = undefined; } catch {
storedKey = undefined;
}
} }
const results = await scanOpenRouterModels({ const results = await scanOpenRouterModels({
apiKey: storedKey ?? undefined, apiKey: storedKey ?? undefined,
@@ -191,8 +195,21 @@ export async function modelsScanCommand(
providerFilter: opts.provider, providerFilter: opts.provider,
timeoutMs: timeout, timeoutMs: timeout,
concurrency, concurrency,
probe,
}); });
if (!probe) {
if (!opts.json) {
runtime.log(
`Found ${results.length} OpenRouter free models (metadata only; pass --probe to test tools/images).`,
);
printScanTable(sortScanResults(results), runtime);
} else {
runtime.log(JSON.stringify(results, null, 2));
}
return;
}
const toolOk = results.filter((entry) => entry.tool.ok); const toolOk = results.filter((entry) => entry.tool.ok);
if (toolOk.length === 0) { if (toolOk.length === 0) {
throw new Error("No tool-capable OpenRouter free models found."); throw new Error("No tool-capable OpenRouter free models found.");