feat(FN-2962): merge fusion/fn-2962

- Add changeset for `@runfusion/fusion` minor release introducing custom provider registration support

Commits merged:
- feat(FN-2962): complete Step 8 — add changeset and documentation

Files changed:
.changeset/register-custom-providers.md | 5 +++++
 1 file changed, 5 insertions(+)

Fusion-Task-Id: FN-2962
This commit is contained in:
Fusion
2026-04-29 21:42:44 -07:00
committed by gsxdsm
parent 476d17e2ca
commit 38f5159719
33 changed files with 1048 additions and 60 deletions

View File

@@ -0,0 +1,167 @@
import { describe, expect, it, vi } from "vitest";
import type { CustomProvider } from "@fusion/core";
import {
registerCustomProviders,
reregisterCustomProviders,
resolveApiType,
} from "../custom-provider-registry.js";
describe("custom-provider-registry", () => {
it.each([
["openai-compatible", "openai-completions"],
["anthropic-compatible", "anthropic"],
])("resolveApiType maps %s -> %s", (apiType, expectedApi) => {
expect(resolveApiType(apiType)).toBe(expectedApi);
});
it("registers providers with expected config shape", () => {
const registerProvider = vi.fn();
const refresh = vi.fn();
const logFn = vi.fn();
const providers: CustomProvider[] = [
{
id: "openai-custom",
name: "OpenAI Custom",
apiType: "openai-compatible",
baseUrl: "https://example.test/v1",
apiKey: "CUSTOM_KEY",
models: [{ id: "m1", name: "Model 1" }],
},
{
id: "anthropic-custom",
name: "Anthropic Custom",
apiType: "anthropic-compatible",
baseUrl: "https://anthropic.test",
apiKey: "ANTHROPIC_KEY",
models: [{ id: "claude-x", name: "Claude X" }],
},
];
registerCustomProviders({ registerProvider, refresh }, providers, logFn);
expect(registerProvider).toHaveBeenNthCalledWith(1, "openai-custom", expect.objectContaining({
baseUrl: "https://example.test/v1",
api: "openai-completions",
apiKey: "CUSTOM_KEY",
models: [expect.objectContaining({ id: "m1", name: "Model 1" })],
}));
expect(registerProvider).toHaveBeenNthCalledWith(2, "anthropic-custom", expect.objectContaining({
baseUrl: "https://anthropic.test",
api: "anthropic",
apiKey: "ANTHROPIC_KEY",
models: [expect.objectContaining({ id: "claude-x", name: "Claude X" })],
}));
expect(refresh).toHaveBeenCalledTimes(1);
});
it("handles empty provider list and still refreshes", () => {
const registerProvider = vi.fn();
const refresh = vi.fn();
registerCustomProviders({ registerProvider, refresh }, [], vi.fn());
expect(registerProvider).not.toHaveBeenCalled();
expect(refresh).toHaveBeenCalledTimes(1);
});
it("uses empty models when models is missing", () => {
const registerProvider = vi.fn();
const refresh = vi.fn();
registerCustomProviders(
{ registerProvider, refresh },
[{
id: "no-models",
name: "No Models",
apiType: "openai-compatible",
baseUrl: "https://nomodels.test",
}],
vi.fn(),
);
expect(registerProvider).toHaveBeenCalledWith("no-models", expect.objectContaining({ models: [] }));
expect(refresh).toHaveBeenCalledTimes(1);
});
it("continues when one provider registration fails", () => {
const registerProvider = vi
.fn()
.mockImplementationOnce(() => {
throw new Error("boom");
})
.mockImplementationOnce(() => undefined);
const refresh = vi.fn();
const logFn = vi.fn();
registerCustomProviders(
{ registerProvider, refresh },
[
{
id: "bad",
name: "Bad",
apiType: "openai-compatible",
baseUrl: "https://bad.test",
},
{
id: "good",
name: "Good",
apiType: "openai-compatible",
baseUrl: "https://good.test",
},
],
logFn,
);
expect(registerProvider).toHaveBeenCalledTimes(2);
expect(logFn).toHaveBeenCalledWith(expect.stringContaining("Failed to register custom provider bad"));
expect(refresh).toHaveBeenCalledTimes(1);
});
it("reregisters new providers", () => {
const registerProvider = vi.fn();
const refresh = vi.fn();
reregisterCustomProviders(
{ registerProvider, refresh },
[{ id: "old", name: "Old", apiType: "openai-compatible", baseUrl: "https://old.test" }],
[
{ id: "old", name: "Old", apiType: "openai-compatible", baseUrl: "https://old.test" },
{ id: "new", name: "New", apiType: "anthropic-compatible", baseUrl: "https://new.test" },
],
vi.fn(),
);
expect(registerProvider).toHaveBeenCalledTimes(1);
expect(registerProvider).toHaveBeenCalledWith("new", expect.objectContaining({ api: "anthropic" }));
expect(refresh).toHaveBeenCalledTimes(1);
});
it("reregisters changed providers", () => {
const registerProvider = vi.fn();
const refresh = vi.fn();
reregisterCustomProviders(
{ registerProvider, refresh },
[{ id: "same-id", name: "Provider", apiType: "openai-compatible", baseUrl: "https://one.test", apiKey: "A" }],
[{ id: "same-id", name: "Provider", apiType: "openai-compatible", baseUrl: "https://two.test", apiKey: "B" }],
vi.fn(),
);
expect(registerProvider).toHaveBeenCalledTimes(1);
expect(registerProvider).toHaveBeenCalledWith("same-id", expect.objectContaining({
baseUrl: "https://two.test",
apiKey: "B",
}));
expect(refresh).toHaveBeenCalledTimes(1);
});
it("handles empty previous/current arrays", () => {
const registerProvider = vi.fn();
const refresh = vi.fn();
reregisterCustomProviders({ registerProvider, refresh }, [], [], vi.fn());
expect(registerProvider).not.toHaveBeenCalled();
expect(refresh).toHaveBeenCalledTimes(1);
});
});

View File

@@ -86,6 +86,10 @@ function makeMockStore() {
logEntry: vi.fn().mockResolvedValue(undefined),
updateTask: vi.fn().mockResolvedValue({}),
getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"),
getGlobalSettingsStore: vi.fn(() => ({
getSettings: mockGlobalSettingsGetSettings,
updateSettings: mockGlobalSettingsUpdateSettings,
})),
getActiveMergingTask: vi.fn().mockReturnValue(undefined),
getMissionStore: vi.fn().mockReturnValue(mockMissionStore),
close: vi.fn(),

View File

@@ -73,6 +73,9 @@ const mocks = vi.hoisted(() => {
watch: vi.fn().mockResolvedValue(undefined),
close: vi.fn(),
getFusionDir: vi.fn().mockReturnValue(`/repo${projectId ? `/${projectId}` : ""}/.fusion`),
getGlobalSettingsStore: vi.fn(() => ({
getSettings: vi.fn().mockResolvedValue({}),
})),
getMissionStore: vi.fn().mockReturnValue(missionStore),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,

View File

@@ -0,0 +1,96 @@
import type { CustomProvider } from "@fusion/core";
interface ModelRegistryLike {
registerProvider: (name: string, config: {
baseUrl: string;
api: string;
apiKey?: string;
models: Array<{
id: string;
name: string;
reasoning: boolean;
input: ("text" | "image")[];
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow: number;
maxTokens: number;
}>;
}) => void;
refresh: () => void;
}
export function resolveApiType(apiType: string): string {
if (apiType === "anthropic-compatible") {
return "anthropic";
}
return "openai-completions";
}
function toProviderConfig(provider: CustomProvider) {
return {
baseUrl: provider.baseUrl,
api: resolveApiType(provider.apiType),
apiKey: provider.apiKey,
models: (provider.models ?? []).map((model) => ({
id: model.id,
name: model.name,
reasoning: false,
input: ["text" as const],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 16384,
})),
};
}
function providersDiffer(previous: CustomProvider, current: CustomProvider): boolean {
return JSON.stringify(toProviderConfig(previous)) !== JSON.stringify(toProviderConfig(current));
}
export function registerCustomProviders(
modelRegistry: ModelRegistryLike,
customProviders: CustomProvider[] | undefined,
logFn: (message: string) => void,
): void {
for (const provider of customProviders ?? []) {
try {
modelRegistry.registerProvider(provider.id, toProviderConfig(provider));
logFn(`Registered custom provider ${provider.id}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logFn(`Failed to register custom provider ${provider.id}: ${message}`);
}
}
modelRegistry.refresh();
}
export function reregisterCustomProviders(
modelRegistry: ModelRegistryLike,
previousProviders: CustomProvider[] | undefined,
currentProviders: CustomProvider[] | undefined,
logFn: (message: string) => void,
): void {
const previousById = new Map((previousProviders ?? []).map((provider) => [provider.id, provider]));
for (const provider of currentProviders ?? []) {
const previous = previousById.get(provider.id);
if (previous && !providersDiffer(previous, provider)) {
continue;
}
try {
modelRegistry.registerProvider(provider.id, toProviderConfig(provider));
logFn(`${previous ? "Updated" : "Registered"} custom provider ${provider.id}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logFn(`Failed to register custom provider ${provider.id}: ${message}`);
}
}
modelRegistry.refresh();
}

View File

@@ -49,6 +49,7 @@ import {
} from "./claude-cli-extension.js";
import { getCachedUpdateStatus, isUpdateCheckEnabled } from "../update-cache.js";
import { resolveSelfExtension } from "./self-extension.js";
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree, type FileEntry, type FileReadResult, type TaskStep as TUITaskStep, type TaskLogEntry as TUITaskLogEntry, type TaskDetailData, type TaskEvent } from "./dashboard-tui/index.js";
// Re-export for backward compatibility with tests
@@ -1244,6 +1245,18 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
extensionsResult.runtime.pendingProviderRegistrations = [];
modelRegistry.refresh();
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
registerCustomProviders(
modelRegistry,
globalSettings.customProviders,
(message) => logSink.log(message, "custom-providers"),
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logSink.warn(`Failed to load custom providers from global settings: ${message}`, "custom-providers");
}
// Eagerly sync OpenRouter models — the pi-openrouter-realtime extension
// only registers providers on session_start (TUI-only event), so kick off
// a fetch here so the dashboard model list is populated. Respects the
@@ -1293,6 +1306,21 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
modelRegistry.refresh();
}
registerHandler(store, "settings:updated", ({ settings, previous }) => {
const currentProviders = settings.customProviders;
const previousProviders = previous.customProviders;
if (JSON.stringify(currentProviders ?? []) === JSON.stringify(previousProviders ?? [])) {
return;
}
reregisterCustomProviders(
modelRegistry,
previousProviders,
currentProviders,
(message) => logSink.log(message, "custom-providers"),
);
});
// ── Skills adapter for skills discovery and execution toggling ─────────────
//
// Create the skills adapter using the same DefaultPackageManager instance

View File

@@ -53,6 +53,7 @@ import {
setCachedClaudeCliResolution,
} from "./claude-cli-extension.js";
import { resolveSelfExtension } from "./self-extension.js";
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
let diagnosticIntervalHandle: ReturnType<typeof setInterval> | null = null;
@@ -522,6 +523,18 @@ export async function runServe(
extensionsResult.runtime.pendingProviderRegistrations = [];
modelRegistry.refresh();
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
registerCustomProviders(
modelRegistry,
globalSettings.customProviders,
(message) => console.log(`[custom-providers] ${message}`),
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[custom-providers] Failed to load custom providers from global settings: ${message}`);
}
(async () => {
try {
const settings = await store.getSettings();
@@ -606,6 +619,21 @@ export async function runServe(
modelRegistry.refresh();
}
store.on("settings:updated", ({ settings, previous }) => {
const currentProviders = settings.customProviders;
const previousProviders = previous.customProviders;
if (JSON.stringify(currentProviders ?? []) === JSON.stringify(previousProviders ?? [])) {
return;
}
reregisterCustomProviders(
modelRegistry,
previousProviders,
currentProviders,
(message) => console.log(`[custom-providers] ${message}`),
);
});
// ── Daemon token resolution ─────────────────────────────────────────────
//
// When --daemon flag is set, resolve the daemon token using the same