diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 81703836524..2d3d616543e 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -775,6 +775,58 @@ export namespace Provider { } } + function catalog(opts: Record) { + return opts["catalog"] === "all" ? "all" : "user" + } + + function tier(opts: Record) { + if (opts["tier"] === "free") return "free" + if (opts["tier"] === "paid") return "paid" + if (opts["free_only"] === true) return "free" + return "any" + } + + function parse(input: unknown) { + if (!input || typeof input !== "object") return + const data = (input as { data?: unknown }).data + if (!Array.isArray(data)) return + return data + .map((item) => { + if (!item || typeof item !== "object") return + const id = (item as { id?: unknown }).id + if (typeof id !== "string") return + return id + }) + .filter((item): item is string => Boolean(item)) + } + + async function user(provider: Info) { + const key = provider.key ?? provider.options["apiKey"] + if (typeof key !== "string") return + + const base = typeof provider.options["baseURL"] === "string" ? provider.options["baseURL"] : "https://openrouter.ai/api/v1" + const headers = new Headers() + if (provider.options["headers"] && typeof provider.options["headers"] === "object") { + for (const [k, v] of Object.entries(provider.options["headers"])) { + if (typeof v === "string") headers.set(k, v) + } + } + headers.set("Authorization", `Bearer ${key}`) + headers.set("User-Agent", Installation.USER_AGENT) + + const result = await fetch(`${base.replace(/\/+$/, "")}/models/user`, { + headers, + signal: AbortSignal.timeout(10_000), + }).catch(() => undefined) + if (!result?.ok) return + + const body = await result.json().catch(() => undefined) + const ids = parse(body) + if (!ids) return + + return new Set(ids) + } + const state = Instance.state(async () => { using _ = log.time("state") const config = await Config.get() @@ -1014,6 +1066,26 @@ export namespace Provider { const configProvider = config.provider?.[providerID] + if (providerID === "openrouter") { + if (catalog(provider.options) === "user") { + const ids = await user(provider) + if (ids) { + for (const [id, model] of Object.entries(provider.models)) { + if (!ids.has(id) && !ids.has(model.api.id)) delete provider.models[id] + } + } + } + + const mode = tier(provider.options) + if (mode !== "any") { + for (const [id, model] of Object.entries(provider.models)) { + const free = id.endsWith(":free") || model.api.id.endsWith(":free") + if (mode === "free" && !free) delete provider.models[id] + if (mode === "paid" && free) delete provider.models[id] + } + } + } + for (const [modelID, model] of Object.entries(provider.models)) { model.api.id = model.api.id ?? model.id ?? modelID if (modelID === "gpt-5-chat-latest" || (providerID === "openrouter" && modelID === "openai/gpt-5-chat")) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 11c943db6f8..9d98e7f9627 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1810,6 +1810,162 @@ test("custom model inherits api.url from models.dev provider", async () => { }) }) +test("openrouter filters models using user catalog", async () => { + const orig = globalThis.fetch + const stub = (input: RequestInfo | URL, init?: RequestInit | BunFetchRequestInit) => { + const url = input instanceof Request ? input.url : input.toString() + if (url.endsWith("/models/user")) { + return Promise.resolve( + new Response( + JSON.stringify({ + data: [ + { id: "deepseek/deepseek-v3.2" }, + { id: "qwen/qwen3-coder:free" }, + ], + }), + { status: 200 }, + ), + ) + } + return orig(input, init) + } + globalThis.fetch = stub as typeof fetch + + try { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("OPENROUTER_API_KEY", "test-api-key") + }, + fn: async () => { + const providers = await Provider.list() + const models = Object.keys(providers["openrouter"].models) + expect(models).toContain("deepseek/deepseek-v3.2") + expect(models).toContain("qwen/qwen3-coder:free") + expect(models).not.toContain("deepseek/deepseek-r1-0528-qwen3-8b:free") + }, + }) + } finally { + globalThis.fetch = orig + } +}) + +test("openrouter supports tier free filter", async () => { + const orig = globalThis.fetch + const stub = (input: RequestInfo | URL, init?: RequestInit | BunFetchRequestInit) => { + const url = input instanceof Request ? input.url : input.toString() + if (url.endsWith("/models/user")) { + return Promise.resolve( + new Response( + JSON.stringify({ + data: [{ id: "deepseek/deepseek-v3.2" }, { id: "qwen/qwen3-coder:free" }], + }), + { status: 200 }, + ), + ) + } + return orig(input, init) + } + globalThis.fetch = stub as typeof fetch + + try { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + openrouter: { + options: { + tier: "free", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("OPENROUTER_API_KEY", "test-api-key") + }, + fn: async () => { + const providers = await Provider.list() + const models = Object.keys(providers["openrouter"].models) + expect(models).toContain("qwen/qwen3-coder:free") + expect(models).not.toContain("deepseek/deepseek-v3.2") + }, + }) + } finally { + globalThis.fetch = orig + } +}) + +test("openrouter supports tier paid filter", async () => { + const orig = globalThis.fetch + const stub = (input: RequestInfo | URL, init?: RequestInit | BunFetchRequestInit) => { + const url = input instanceof Request ? input.url : input.toString() + if (url.endsWith("/models/user")) { + return Promise.resolve( + new Response( + JSON.stringify({ + data: [{ id: "deepseek/deepseek-v3.2" }, { id: "qwen/qwen3-coder:free" }], + }), + { status: 200 }, + ), + ) + } + return orig(input, init) + } + globalThis.fetch = stub as typeof fetch + + try { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + openrouter: { + options: { + tier: "paid", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("OPENROUTER_API_KEY", "test-api-key") + }, + fn: async () => { + const providers = await Provider.list() + const models = Object.keys(providers["openrouter"].models) + expect(models).toContain("deepseek/deepseek-v3.2") + expect(models).not.toContain("qwen/qwen3-coder:free") + }, + }) + } finally { + globalThis.fetch = orig + } +}) + test("model variants are generated for reasoning models", async () => { await using tmp = await tmpdir({ init: async (dir) => {