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 6c051b1851
commit 64b5f677ff
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,