feat(FN-5601): add OpenAI Responses API type support to custom providers

Added OpenAI Responses API as a new custom provider type, wiring `apiType: "responses"` through the core registry, engine routes, and dashboard UI with a dropdown selector; includes test coverage across the registry, routes, and component layers.

Fusion-Task-Id: FN-5601

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5601
This commit is contained in:
gsxdsm
2026-05-27 01:42:16 -07:00
parent da34bd06e3
commit d767e2ecbd
17 changed files with 389 additions and 41 deletions

View File

@@ -0,0 +1,69 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
import { completeSimple } from "@mariozechner/pi-ai";
import { customProviderRegistryKey, type CustomProvider } from "@fusion/core";
describe("custom providers openai-completions regression", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("registers under slug key and completes a chat round-trip", async () => {
const authStorage = AuthStorage.inMemory();
const modelRegistry = ModelRegistry.inMemory(authStorage);
const providers: CustomProvider[] = [
{
id: "550e8400-e29b-41d4-a716-446655440000",
name: "My AI Provider",
apiType: "openai-compatible",
baseUrl: "https://example.test/v1",
apiKey: "CUSTOM_KEY",
models: [{ id: "my-model", name: "My Model" }],
},
];
const provider = providers[0]!;
modelRegistry.registerProvider(customProviderRegistryKey(provider, providers), {
baseUrl: provider.baseUrl,
api: "openai-completions",
apiKey: provider.apiKey,
models: [{
id: "my-model",
name: "My Model",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 16384,
}],
});
modelRegistry.refresh();
const registered = modelRegistry.getAll().find((model) => model.id === "my-model");
expect(registered?.provider).toBe("my-ai-provider");
vi.stubGlobal("fetch", vi.fn(async () => {
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("data: {\"id\":\"chatcmpl-test\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hello from mock transport\"},\"finish_reason\":null}]}\n\n"));
controller.enqueue(new TextEncoder().encode("data: {\"id\":\"chatcmpl-test\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n\n"));
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
controller.close();
},
});
return new Response(stream, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}));
const model = modelRegistry.find("my-ai-provider", "my-model");
expect(model).toBeDefined();
const response = await completeSimple(model!, {
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
});
expect(response.role).toBe("assistant");
});
});

View File

@@ -1124,13 +1124,21 @@ describe("createFnAgent", () => {
it("registers custom providers from global settings", async () => {
readCustomProvidersMock.mockReturnValue([
{
id: "custom-openai",
id: "550e8400-e29b-41d4-a716-446655440000",
name: "Custom OpenAI",
apiType: "openai-compatible",
baseUrl: "https://custom.example/v1",
apiKey: "CUSTOM_API_KEY",
models: [{ id: "custom-model", name: "Custom Model" }],
},
{
id: "660e8400-e29b-41d4-a716-446655440001",
name: "Custom Responses",
apiType: "openai-responses",
baseUrl: "https://responses.example/v1",
apiKey: "RESPONSES_API_KEY",
models: [{ id: "responses-model", name: "Responses Model" }],
},
] as any);
const { createFnAgent } = await import("../pi.js");
@@ -1149,6 +1157,12 @@ describe("createFnAgent", () => {
apiKey: "CUSTOM_API_KEY",
models: [expect.objectContaining({ id: "custom-model", name: "Custom Model" })],
}));
expect(registerProviderMock).toHaveBeenCalledWith("custom-responses", expect.objectContaining({
baseUrl: "https://responses.example/v1",
api: "openai-responses",
apiKey: "RESPONSES_API_KEY",
models: [expect.objectContaining({ id: "responses-model", name: "Responses Model" })],
}));
});
it("avoids lock-based SettingsManager.create when loading extension providers", async () => {

View File

@@ -34,7 +34,7 @@ import {
type AgentSession,
type ToolDefinition,
} from "@mariozechner/pi-coding-agent";
import { getEnabledPiExtensionPaths, getFusionAgentDir, getLegacyPiAgentDir, getProjectRootFromWorktree, reconcileClaudeCliPaths, reconcileDroidCliPaths, resolvePiExtensionProjectRoot } from "@fusion/core";
import { customProviderRegistryKey, getEnabledPiExtensionPaths, getFusionAgentDir, getLegacyPiAgentDir, getProjectRootFromWorktree, reconcileClaudeCliPaths, reconcileDroidCliPaths, resolvePiExtensionProjectRoot } from "@fusion/core";
import type {
AgentPermissionPolicyActionCategory,
PermanentAgentActionCategory,
@@ -1675,11 +1675,15 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
const resolvedProjectRoot = getProjectRootFromWorktree(options.cwd) ?? resolvePiExtensionProjectRoot(options.cwd);
await registerExtensionProviders(resolvedProjectRoot, modelRegistry);
for (const provider of readCustomProviders()) {
const customProviders = readCustomProviders();
for (const provider of customProviders) {
try {
modelRegistry.registerProvider(provider.id, {
const registryKey = customProviderRegistryKey(provider, customProviders);
modelRegistry.registerProvider(registryKey, {
baseUrl: provider.baseUrl,
api: provider.apiType === "anthropic-compatible" ? "anthropic" : "openai-completions",
api: provider.apiType === "anthropic-compatible" ? "anthropic"
: provider.apiType === "openai-responses" ? "openai-responses"
: "openai-completions",
apiKey: provider.apiKey,
models: (provider.models ?? []).map((model) => ({
id: model.id,
@@ -1696,10 +1700,11 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
maxTokens: 16384,
})),
});
piLog.log(`Registered custom provider ${provider.id}`);
piLog.log(`Registered custom provider "${provider.name}" (key=${registryKey}, id=${provider.id})`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
piLog.warn(`Failed to register custom provider ${provider.id}: ${message}`);
const registryKey = customProviderRegistryKey(provider, customProviders);
piLog.warn(`Failed to register custom provider "${provider.name}" (key=${registryKey}, id=${provider.id}): ${message}`);
}
}
modelRegistry.refresh();