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:
@@ -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();
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user