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