diff --git a/packages/cli/src/commands/__tests__/startup-model-sync.test.ts b/packages/cli/src/commands/__tests__/startup-model-sync.test.ts index 92a96fc7b..24faa0e6f 100644 --- a/packages/cli/src/commands/__tests__/startup-model-sync.test.ts +++ b/packages/cli/src/commands/__tests__/startup-model-sync.test.ts @@ -30,6 +30,18 @@ describe("startup-model-sync", () => { mockSpawn.mockReset(); }); + function mockOpenRouterFetchSequence(...responses: Array<{ ok: boolean; status?: number; body?: unknown }>): void { + const fetchMock = vi.fn(); + for (const response of responses) { + fetchMock.mockResolvedValueOnce({ + ok: response.ok, + status: response.status ?? (response.ok ? 200 : 500), + json: vi.fn().mockResolvedValue(response.body ?? { data: [] }), + }); + } + vi.stubGlobal("fetch", fetchMock); + } + afterEach(() => { vi.unstubAllGlobals(); }); @@ -44,12 +56,10 @@ describe("startup-model-sync", () => { return proc; }); - vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + mockOpenRouterFetchSequence({ ok: true, - json: vi.fn().mockResolvedValue({ - data: [{ id: "openai/gpt-4o", name: "GPT-4o", context_length: 128000 }], - }), - })); + body: { data: [{ id: "openai/gpt-4o", name: "GPT-4o", context_length: 128000 }] }, + }); const registerProvider = vi.fn(); const log = vi.fn(); @@ -89,6 +99,149 @@ describe("startup-model-sync", () => { expect(registerProvider).not.toHaveBeenCalled(); }); + it("sends default OpenRouter attribution headers", async () => { + mockOpenRouterFetchSequence({ ok: true }); + + await syncStartupModels({ + getSettings: vi.fn().mockResolvedValue({ openrouterModelSync: true, opencodeGoModelSync: false }), + authStorage: { getApiKey: vi.fn().mockResolvedValue(undefined) }, + modelRegistry: { registerProvider: vi.fn() }, + log: vi.fn(), + }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/models"), + expect.objectContaining({ + headers: expect.objectContaining({ + "HTTP-Referer": "https://runfusion.ai", + "X-Title": "Fusion", + }), + }), + ); + }); + + it("uses custom OpenRouter attribution headers", async () => { + mockOpenRouterFetchSequence({ ok: true }); + + await syncStartupModels({ + getSettings: vi.fn().mockResolvedValue({ + openrouterModelSync: true, + opencodeGoModelSync: false, + openrouterAppAttribution: { referer: "https://example.com", title: "ExampleApp" }, + }), + authStorage: { getApiKey: vi.fn().mockResolvedValue(undefined) }, + modelRegistry: { registerProvider: vi.fn() }, + log: vi.fn(), + }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + "HTTP-Referer": "https://example.com", + "X-Title": "ExampleApp", + }), + }), + ); + }); + + it("uses OpenRouter user models endpoint when API key is present", async () => { + mockOpenRouterFetchSequence({ ok: true }); + + await syncStartupModels({ + getSettings: vi.fn().mockResolvedValue({ openrouterModelSync: true, opencodeGoModelSync: false }), + authStorage: { getApiKey: vi.fn().mockResolvedValue("key") }, + modelRegistry: { registerProvider: vi.fn() }, + log: vi.fn(), + }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/models/user"), + expect.any(Object), + ); + }); + + it("falls back to public OpenRouter endpoint when user endpoint fails", async () => { + const log = vi.fn(); + mockOpenRouterFetchSequence( + { ok: false, status: 401 }, + { ok: true, body: { data: [] } }, + ); + + await syncStartupModels({ + getSettings: vi.fn().mockResolvedValue({ openrouterModelSync: true, opencodeGoModelSync: false }), + authStorage: { getApiKey: vi.fn().mockResolvedValue("key") }, + modelRegistry: { registerProvider: vi.fn() }, + log, + }); + + const calls = (globalThis.fetch as ReturnType).mock.calls; + expect(calls[0]?.[0]).toContain("/api/v1/models/user"); + expect(calls[1]?.[0]).toContain("/api/v1/models"); + expect(log).toHaveBeenCalledWith("openrouter", expect.stringContaining("falling back")); + }); + + it("applies OpenRouter model filters as comma-joined query params", async () => { + mockOpenRouterFetchSequence({ ok: true }); + + await syncStartupModels({ + getSettings: vi.fn().mockResolvedValue({ + openrouterModelSync: true, + opencodeGoModelSync: false, + openrouterModelFilters: { + supported_parameters: ["tools", "structured_outputs"], + output_modalities: ["text"], + }, + }), + authStorage: { getApiKey: vi.fn().mockResolvedValue(undefined) }, + modelRegistry: { registerProvider: vi.fn() }, + log: vi.fn(), + }); + + const requestUrl = new URL((globalThis.fetch as ReturnType).mock.calls[0]?.[0] as string); + expect(requestUrl.searchParams.get("supported_parameters")).toBe("tools,structured_outputs"); + expect(requestUrl.searchParams.get("output_modalities")).toBe("text"); + }); + + it("passes OpenRouter routing compat and provider headers to model registry", async () => { + mockOpenRouterFetchSequence({ ok: true }); + const registerProvider = vi.fn(); + + await syncStartupModels({ + getSettings: vi.fn().mockResolvedValue({ + openrouterModelSync: true, + opencodeGoModelSync: false, + openrouterProviderPreferences: { + order: ["openai"], + allow_fallbacks: false, + sort: "price", + require_parameters: true, + }, + }), + authStorage: { getApiKey: vi.fn().mockResolvedValue("key") }, + modelRegistry: { registerProvider }, + log: vi.fn(), + }); + + expect(registerProvider).toHaveBeenCalledWith( + "openrouter", + expect.objectContaining({ + headers: { + "HTTP-Referer": "https://runfusion.ai", + "X-Title": "Fusion", + }, + compat: { + openRouterRouting: expect.objectContaining({ + order: ["openai"], + allow_fallbacks: false, + sort: "price", + require_parameters: true, + }), + }, + }), + ); + }); + it("logs failures and continues", async () => { mockSpawn.mockImplementation(() => { const proc = createSpawnProcess(); diff --git a/packages/cli/src/commands/startup-model-sync.ts b/packages/cli/src/commands/startup-model-sync.ts index 53b58f2d3..4f6140137 100644 --- a/packages/cli/src/commands/startup-model-sync.ts +++ b/packages/cli/src/commands/startup-model-sync.ts @@ -1,6 +1,9 @@ import { spawn } from "node:child_process"; -const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; +const OPENROUTER_PUBLIC_MODELS_URL = "https://openrouter.ai/api/v1/models"; +const OPENROUTER_USER_MODELS_URL = "https://openrouter.ai/api/v1/models/user"; +const OPENROUTER_DEFAULT_REFERER = "https://runfusion.ai"; +const OPENROUTER_DEFAULT_TITLE = "Fusion"; const OPENCODE_MODELS_TIMEOUT_MS = 15_000; type ModelConfig = { @@ -24,6 +27,8 @@ interface ModelRegistryLike { api: string; apiKey?: string; models: ModelConfig[]; + headers?: Record; + compat?: { openRouterRouting?: OpenRouterProviderPreferences }; }) => void; } @@ -31,9 +36,26 @@ interface AuthStorageLike { getApiKey: (provider: string) => Promise; } +interface OpenRouterModelFilters { + supported_parameters?: string[]; + output_modalities?: string[]; +} + +interface OpenRouterProviderPreferences { + order?: string[]; + ignore?: string[]; + only?: string[]; + allow_fallbacks?: boolean; + sort?: "price" | "throughput" | "latency"; + require_parameters?: boolean; +} + interface SettingsLike { openrouterModelSync?: boolean; opencodeGoModelSync?: boolean; + openrouterAppAttribution?: { referer?: string; title?: string }; + openrouterModelFilters?: OpenRouterModelFilters; + openrouterProviderPreferences?: OpenRouterProviderPreferences; } interface StartupSyncOptions { @@ -91,15 +113,58 @@ function toOpenRouterModels(json: { }); } -async function syncOpenRouterModels(options: StartupSyncOptions): Promise { +function withOpenRouterFilters(baseUrl: string, filters?: OpenRouterModelFilters): string { + const url = new URL(baseUrl); + if (filters?.supported_parameters?.length) { + url.searchParams.set("supported_parameters", filters.supported_parameters.join(",")); + } + if (filters?.output_modalities?.length) { + url.searchParams.set("output_modalities", filters.output_modalities.join(",")); + } + return url.toString(); +} + +function hasOpenRouterRoutingPreferences(preferences?: OpenRouterProviderPreferences): preferences is OpenRouterProviderPreferences { + return Boolean( + preferences + && Object.values(preferences).some((value) => { + if (Array.isArray(value)) { + return value.length > 0; + } + return value !== undefined; + }), + ); +} + +async function syncOpenRouterModels(options: StartupSyncOptions, settings: SettingsLike): Promise { const { authStorage, modelRegistry, log } = options; const apiKey = await authStorage.getApiKey("openrouter"); + const referer = settings.openrouterAppAttribution?.referer ?? OPENROUTER_DEFAULT_REFERER; + const title = settings.openrouterAppAttribution?.title ?? OPENROUTER_DEFAULT_TITLE; const headers: Record = {}; if (apiKey) { headers.Authorization = `Bearer ${apiKey}`; } + if (referer !== "") { + headers["HTTP-Referer"] = referer; + } + if (title !== "") { + headers["X-Title"] = title; + } + + const fetchModels = async (url: string) => { + const response = await fetch(withOpenRouterFilters(url, settings.openrouterModelFilters), { headers }); + return response; + }; + + const primaryUrl = apiKey ? OPENROUTER_USER_MODELS_URL : OPENROUTER_PUBLIC_MODELS_URL; + let response = await fetchModels(primaryUrl); + + if (!response.ok && apiKey && primaryUrl === OPENROUTER_USER_MODELS_URL) { + log("openrouter", `OpenRouter /models/user returned HTTP ${response.status}; falling back to public catalog`); + response = await fetchModels(OPENROUTER_PUBLIC_MODELS_URL); + } - const response = await fetch(OPENROUTER_MODELS_URL, { headers }); if (!response.ok) { log("openrouter", `Failed to sync models: HTTP ${response.status}`); return; @@ -122,6 +187,12 @@ async function syncOpenRouterModels(options: StartupSyncOptions): Promise apiKey: "OPENROUTER_API_KEY", api: "openai-completions", models, + headers: Object.keys(headers).filter((key) => key !== "Authorization").length > 0 + ? Object.fromEntries(Object.entries(headers).filter(([key]) => key !== "Authorization")) + : undefined, + compat: hasOpenRouterRoutingPreferences(settings.openrouterProviderPreferences) + ? { openRouterRouting: settings.openrouterProviderPreferences } + : undefined, }); log("openrouter", `Synced ${models.length} models from OpenRouter API`); } @@ -215,7 +286,7 @@ export async function syncStartupModels(options: StartupSyncOptions): Promise