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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
96
packages/cli/src/commands/custom-provider-registry.ts
Normal file
96
packages/cli/src/commands/custom-provider-registry.ts
Normal 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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user