feat(FN-4616): complete Step 2 — enhance OpenRouter startup sync behavior

Ref: Runfusion/Fusion#325
Fusion-Task-Id: FN-4616
Fusion-Task-Lineage: ea5ea571-cc40-4ec0-86b4-2f003ac2834c
This commit is contained in:
Fusion
2026-05-15 10:03:54 -07:00
committed by gsxdsm
parent d1909afc2a
commit e6a675a5ab
2 changed files with 233 additions and 9 deletions

View File

@@ -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<typeof vi.fn>).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<typeof vi.fn>).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();

View File

@@ -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<string, string>;
compat?: { openRouterRouting?: OpenRouterProviderPreferences };
}) => void;
}
@@ -31,9 +36,26 @@ interface AuthStorageLike {
getApiKey: (provider: string) => Promise<string | undefined>;
}
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<void> {
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<void> {
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<string, string> = {};
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<void>
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<vo
if (settings.openrouterModelSync !== false) {
try {
await syncOpenRouterModels(options);
await syncOpenRouterModels(options, settings);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.log("openrouter", `Failed to sync models: ${message}`);