feat(models): improve OpenRouter free scan
This commit is contained in:
84
src/agents/model-scan.test.ts
Normal file
84
src/agents/model-scan.test.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
Reference in New Issue
Block a user