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,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fixed custom provider registration so provider keys are derived from the configured provider name (with deterministic collision suffixing) instead of internal UUID ids, ensuring model selector and logs show stable human-readable keys. Also fixed the OpenAI-compatible custom-provider registration path by validating end-to-end openai-completions round-trip behavior with a regression test.
|
||||
7
.changeset/fn-5601-openai-responses-apitype.md
Normal file
7
.changeset/fn-5601-openai-responses-apitype.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add `openai-responses` as a supported custom provider `apiType` across CLI, engine, dashboard API validation, and dashboard forms.
|
||||
|
||||
Custom providers configured with this apiType now route through pi-ai's built-in `openai-responses` transport while probe-model discovery continues to use the OpenAI-compatible `/v1/models` path.
|
||||
@@ -10,6 +10,7 @@ describe("custom-provider-registry", () => {
|
||||
it.each([
|
||||
["openai-compatible", "openai-completions"],
|
||||
["anthropic-compatible", "anthropic"],
|
||||
["openai-responses", "openai-responses"],
|
||||
])("resolveApiType maps %s -> %s", (apiType, expectedApi) => {
|
||||
expect(resolveApiType(apiType)).toBe(expectedApi);
|
||||
});
|
||||
@@ -20,7 +21,7 @@ describe("custom-provider-registry", () => {
|
||||
const logFn = vi.fn();
|
||||
const providers: CustomProvider[] = [
|
||||
{
|
||||
id: "openai-custom",
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
name: "OpenAI Custom",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://example.test/v1",
|
||||
@@ -28,7 +29,7 @@ describe("custom-provider-registry", () => {
|
||||
models: [{ id: "m1", name: "Model 1" }],
|
||||
},
|
||||
{
|
||||
id: "anthropic-custom",
|
||||
id: "660e8400-e29b-41d4-a716-446655440001",
|
||||
name: "Anthropic Custom",
|
||||
apiType: "anthropic-compatible",
|
||||
baseUrl: "https://anthropic.test",
|
||||
@@ -54,6 +55,34 @@ describe("custom-provider-registry", () => {
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses slugified provider names and collision suffixes for registry keys", () => {
|
||||
const registerProvider = vi.fn();
|
||||
const refresh = vi.fn();
|
||||
|
||||
registerCustomProviders(
|
||||
{ registerProvider, refresh },
|
||||
[
|
||||
{
|
||||
id: "dd0e8400-e29b-41d4-a716-446655440008",
|
||||
name: "My AI Provider",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://one.test",
|
||||
},
|
||||
{
|
||||
id: "ee0e8400-e29b-41d4-a716-446655440009",
|
||||
name: "My AI Provider",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://two.test",
|
||||
},
|
||||
],
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(registerProvider).toHaveBeenNthCalledWith(1, "my-ai-provider", expect.any(Object));
|
||||
expect(registerProvider).toHaveBeenNthCalledWith(2, "my-ai-provider-2", expect.any(Object));
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handles empty provider list and still refreshes", () => {
|
||||
const registerProvider = vi.fn();
|
||||
const refresh = vi.fn();
|
||||
@@ -71,7 +100,7 @@ describe("custom-provider-registry", () => {
|
||||
registerCustomProviders(
|
||||
{ registerProvider, refresh },
|
||||
[{
|
||||
id: "no-models",
|
||||
id: "770e8400-e29b-41d4-a716-446655440002",
|
||||
name: "No Models",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://nomodels.test",
|
||||
@@ -97,13 +126,13 @@ describe("custom-provider-registry", () => {
|
||||
{ registerProvider, refresh },
|
||||
[
|
||||
{
|
||||
id: "bad",
|
||||
id: "880e8400-e29b-41d4-a716-446655440003",
|
||||
name: "Bad",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://bad.test",
|
||||
},
|
||||
{
|
||||
id: "good",
|
||||
id: "990e8400-e29b-41d4-a716-446655440004",
|
||||
name: "Good",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://good.test",
|
||||
@@ -113,7 +142,7 @@ describe("custom-provider-registry", () => {
|
||||
);
|
||||
|
||||
expect(registerProvider).toHaveBeenCalledTimes(2);
|
||||
expect(logFn).toHaveBeenCalledWith(expect.stringContaining("Failed to register custom provider bad"));
|
||||
expect(logFn).toHaveBeenCalledWith(expect.stringContaining("id=880e8400-e29b-41d4-a716-446655440003"));
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -123,10 +152,10 @@ describe("custom-provider-registry", () => {
|
||||
|
||||
reregisterCustomProviders(
|
||||
{ registerProvider, refresh },
|
||||
[{ id: "old", name: "Old", apiType: "openai-compatible", baseUrl: "https://old.test" }],
|
||||
[{ id: "aa0e8400-e29b-41d4-a716-446655440005", 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" },
|
||||
{ id: "aa0e8400-e29b-41d4-a716-446655440005", name: "Old", apiType: "openai-compatible", baseUrl: "https://old.test" },
|
||||
{ id: "bb0e8400-e29b-41d4-a716-446655440006", name: "New", apiType: "anthropic-compatible", baseUrl: "https://new.test" },
|
||||
],
|
||||
vi.fn(),
|
||||
);
|
||||
@@ -142,13 +171,13 @@ describe("custom-provider-registry", () => {
|
||||
|
||||
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" }],
|
||||
[{ id: "cc0e8400-e29b-41d4-a716-446655440007", name: "Provider", apiType: "openai-compatible", baseUrl: "https://one.test", apiKey: "A" }],
|
||||
[{ id: "cc0e8400-e29b-41d4-a716-446655440007", name: "Provider", apiType: "openai-compatible", baseUrl: "https://two.test", apiKey: "B" }],
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(registerProvider).toHaveBeenCalledTimes(1);
|
||||
expect(registerProvider).toHaveBeenCalledWith("same-id", expect.objectContaining({
|
||||
expect(registerProvider).toHaveBeenCalledWith("provider", expect.objectContaining({
|
||||
baseUrl: "https://two.test",
|
||||
apiKey: "B",
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CustomProvider } from "@fusion/core";
|
||||
import { customProviderRegistryKey, type CustomProvider } from "@fusion/core";
|
||||
|
||||
interface ModelRegistryLike {
|
||||
registerProvider: (name: string, config: {
|
||||
@@ -22,6 +22,9 @@ export function resolveApiType(apiType: string): string {
|
||||
if (apiType === "anthropic-compatible") {
|
||||
return "anthropic";
|
||||
}
|
||||
if (apiType === "openai-responses") {
|
||||
return "openai-responses";
|
||||
}
|
||||
return "openai-completions";
|
||||
}
|
||||
|
||||
@@ -56,13 +59,15 @@ export function registerCustomProviders(
|
||||
customProviders: CustomProvider[] | undefined,
|
||||
logFn: (message: string) => void,
|
||||
): void {
|
||||
for (const provider of customProviders ?? []) {
|
||||
const providers = customProviders ?? [];
|
||||
for (const provider of providers) {
|
||||
const registryKey = customProviderRegistryKey(provider, providers);
|
||||
try {
|
||||
modelRegistry.registerProvider(provider.id, toProviderConfig(provider));
|
||||
logFn(`Registered custom provider ${provider.id}`);
|
||||
modelRegistry.registerProvider(registryKey, toProviderConfig(provider));
|
||||
logFn(`Registered custom provider "${provider.name}" (key=${registryKey}, id=${provider.id})`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logFn(`Failed to register custom provider ${provider.id}: ${message}`);
|
||||
logFn(`Failed to register custom provider "${provider.name}" (key=${registryKey}, id=${provider.id}): ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,19 +81,21 @@ export function reregisterCustomProviders(
|
||||
logFn: (message: string) => void,
|
||||
): void {
|
||||
const previousById = new Map((previousProviders ?? []).map((provider) => [provider.id, provider]));
|
||||
const providers = currentProviders ?? [];
|
||||
|
||||
for (const provider of currentProviders ?? []) {
|
||||
for (const provider of providers) {
|
||||
const previous = previousById.get(provider.id);
|
||||
if (previous && !providersDiffer(previous, provider)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const registryKey = customProviderRegistryKey(provider, providers);
|
||||
try {
|
||||
modelRegistry.registerProvider(provider.id, toProviderConfig(provider));
|
||||
logFn(`${previous ? "Updated" : "Registered"} custom provider ${provider.id}`);
|
||||
modelRegistry.registerProvider(registryKey, toProviderConfig(provider));
|
||||
logFn(`${previous ? "Updated" : "Registered"} custom provider "${provider.name}" (key=${registryKey}, id=${provider.id})`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logFn(`Failed to register custom provider ${provider.id}: ${message}`);
|
||||
logFn(`Failed to register custom provider "${provider.name}" (key=${registryKey}, id=${provider.id}): ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
50
packages/core/src/__tests__/custom-provider-key.test.ts
Normal file
50
packages/core/src/__tests__/custom-provider-key.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CustomProvider } from "../types.js";
|
||||
import { customProviderRegistryKey } from "../custom-provider-key.js";
|
||||
|
||||
function provider(id: string, name: string): CustomProvider {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://example.test",
|
||||
};
|
||||
}
|
||||
|
||||
describe("customProviderRegistryKey", () => {
|
||||
it("slugifies provider names", () => {
|
||||
const providers = [provider("1", "My AI Provider")];
|
||||
expect(customProviderRegistryKey(providers[0]!, providers)).toBe("my-ai-provider");
|
||||
});
|
||||
|
||||
it("handles punctuation and non-ascii", () => {
|
||||
const providers = [provider("1", " Héllø!!! Provider ### ")];
|
||||
expect(customProviderRegistryKey(providers[0]!, providers)).toBe("h-ll-provider");
|
||||
});
|
||||
|
||||
it("falls back to id when name slug is empty", () => {
|
||||
const providers = [provider("550e8400-e29b-41d4-a716-446655440000", "!!!")];
|
||||
expect(customProviderRegistryKey(providers[0]!, providers)).toBe("550e8400-e29b-41d4-a716-446655440000");
|
||||
});
|
||||
|
||||
it("adds deterministic suffixes for two-way collisions", () => {
|
||||
const providers = [provider("1", "A"), provider("2", "A")];
|
||||
expect(customProviderRegistryKey(providers[0]!, providers)).toBe("a");
|
||||
expect(customProviderRegistryKey(providers[1]!, providers)).toBe("a-2");
|
||||
});
|
||||
|
||||
it("adds deterministic suffixes for three-way collisions", () => {
|
||||
const providers = [provider("1", "A"), provider("2", "A"), provider("3", "A")];
|
||||
expect(customProviderRegistryKey(providers[0]!, providers)).toBe("a");
|
||||
expect(customProviderRegistryKey(providers[1]!, providers)).toBe("a-2");
|
||||
expect(customProviderRegistryKey(providers[2]!, providers)).toBe("a-3");
|
||||
});
|
||||
|
||||
it("is stable for the same ordered list", () => {
|
||||
const providers = [provider("1", "A"), provider("2", "A")];
|
||||
const first = customProviderRegistryKey(providers[1]!, providers);
|
||||
const second = customProviderRegistryKey(providers[1]!, providers);
|
||||
expect(first).toBe("a-2");
|
||||
expect(second).toBe("a-2");
|
||||
});
|
||||
});
|
||||
26
packages/core/src/custom-provider-key.ts
Normal file
26
packages/core/src/custom-provider-key.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { CustomProvider } from "./types.js";
|
||||
|
||||
function slugifyProviderName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export function customProviderRegistryKey(provider: CustomProvider, allProviders: CustomProvider[]): string {
|
||||
const base = slugifyProviderName(provider.name) || provider.id;
|
||||
let count = 0;
|
||||
|
||||
for (const current of allProviders) {
|
||||
const currentBase = slugifyProviderName(current.name) || current.id;
|
||||
if (currentBase === base) {
|
||||
count += 1;
|
||||
}
|
||||
if (current.id === provider.id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return count <= 1 ? base : `${base}-${count}`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure } from "./types.js";
|
||||
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js";
|
||||
export { customProviderRegistryKey } from "./custom-provider-key.js";
|
||||
export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js";
|
||||
export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js";
|
||||
export {
|
||||
|
||||
@@ -495,7 +495,7 @@ export interface NotificationProviderConfig {
|
||||
export interface CustomProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
apiType: "openai-compatible" | "anthropic-compatible" | "google-generative-ai";
|
||||
apiType: "openai-compatible" | "anthropic-compatible" | "google-generative-ai" | "openai-responses";
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
models?: { id: string; name: string }[];
|
||||
|
||||
@@ -1880,7 +1880,7 @@ export function setLlamaCppEnabled(
|
||||
export interface CustomProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
apiType: "openai-compatible" | "anthropic-compatible" | "google-generative-ai";
|
||||
apiType: "openai-compatible" | "anthropic-compatible" | "google-generative-ai" | "openai-responses";
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
models?: { id: string; name: string }[];
|
||||
@@ -1894,6 +1894,7 @@ export async function fetchCustomProviders(): Promise<CustomProviderConfig[] & {
|
||||
baseUrl: provider.baseUrl,
|
||||
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages"
|
||||
: provider.apiType === "google-generative-ai" ? "google-generative-ai"
|
||||
: provider.apiType === "openai-responses" ? "openai-responses"
|
||||
: "openai-completions",
|
||||
apiKey: provider.apiKey,
|
||||
models: (provider.models ?? []).map((model) => ({ id: model.id, name: model.name })),
|
||||
@@ -1927,7 +1928,10 @@ export function updateCustomProvider(
|
||||
: {}),
|
||||
...(legacy.api
|
||||
? {
|
||||
apiType: legacy.api === "anthropic-messages" ? "anthropic-compatible" : "openai-compatible",
|
||||
apiType: legacy.api === "anthropic-messages" ? "anthropic-compatible"
|
||||
: legacy.api === "google-generative-ai" ? "google-generative-ai"
|
||||
: legacy.api === "openai-responses" ? "openai-responses"
|
||||
: "openai-compatible",
|
||||
}
|
||||
: {}),
|
||||
...("apiType" in (updates as Record<string, unknown>)
|
||||
@@ -1969,6 +1973,7 @@ export interface CustomProviderConfig {
|
||||
export function createCustomProvider(config: CustomProviderConfig): Promise<CustomProvider> {
|
||||
const apiType = config.api === "anthropic-messages" ? "anthropic-compatible"
|
||||
: config.api === "google-generative-ai" ? "google-generative-ai"
|
||||
: config.api === "openai-responses" ? "openai-responses"
|
||||
: "openai-compatible";
|
||||
return addCustomProvider({
|
||||
name: config.name?.trim() || config.id,
|
||||
@@ -2002,7 +2007,7 @@ export interface ProbeModelsResponse {
|
||||
export interface ProbeModelsParams {
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
apiType: "openai-compatible" | "anthropic-compatible" | "google-generative-ai";
|
||||
apiType: "openai-compatible" | "anthropic-compatible" | "google-generative-ai" | "openai-responses";
|
||||
}
|
||||
|
||||
export async function probeProviderModels(params: ProbeModelsParams): Promise<ProbeModelsResponse> {
|
||||
|
||||
@@ -13,7 +13,7 @@ import "./CustomProvidersSection.css";
|
||||
|
||||
type ProviderApiType = CustomProvider["apiType"];
|
||||
|
||||
const API_TYPES: ProviderApiType[] = ["openai-compatible", "anthropic-compatible", "google-generative-ai"];
|
||||
const API_TYPES: ProviderApiType[] = ["openai-compatible", "openai-responses", "anthropic-compatible", "google-generative-ai"];
|
||||
|
||||
type LegacyProvider = {
|
||||
id: string;
|
||||
@@ -37,7 +37,10 @@ function normalizeProviders(result: Awaited<ReturnType<typeof fetchCustomProvide
|
||||
return {
|
||||
id: provider.id,
|
||||
name: provider.name?.trim() || provider.id,
|
||||
apiType: provider.api === "anthropic-messages" ? "anthropic-compatible" : "openai-compatible",
|
||||
apiType: provider.api === "anthropic-messages" ? "anthropic-compatible"
|
||||
: provider.api === "openai-responses" ? "openai-responses"
|
||||
: provider.api === "google-generative-ai" ? "google-generative-ai"
|
||||
: "openai-compatible",
|
||||
baseUrl: provider.baseUrl,
|
||||
...(provider.apiKey ? { apiKey: provider.apiKey } : {}),
|
||||
models: (provider.models ?? []).map((model) => ({
|
||||
@@ -338,6 +341,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
disabled={saving}
|
||||
>
|
||||
<option value="openai-compatible">OpenAI-compatible</option>
|
||||
<option value="openai-responses">OpenAI Responses</option>
|
||||
<option value="anthropic-compatible">Anthropic-compatible</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -453,6 +457,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
disabled={saving}
|
||||
>
|
||||
<option value="openai-compatible">OpenAI-compatible</option>
|
||||
<option value="openai-responses">OpenAI Responses</option>
|
||||
<option value="anthropic-compatible">Anthropic-compatible</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -116,6 +116,35 @@ describe("CustomProvidersSection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows OpenAI Responses option and posts openai-responses apiType", async () => {
|
||||
mockFetchCustomProviders.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
||||
|
||||
render(<CustomProvidersSection embedded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /Add Custom Provider/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Add Custom Provider/i }));
|
||||
|
||||
const apiTypeSelect = screen.getByLabelText("API type") as HTMLSelectElement;
|
||||
expect(screen.getByRole("option", { name: "OpenAI Responses" })).toBeTruthy();
|
||||
fireEvent.change(apiTypeSelect, { target: { value: "openai-responses" } });
|
||||
expect(apiTypeSelect.value).toBe("openai-responses");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Provider name"), { target: { value: "Responses Provider" } });
|
||||
fireEvent.change(screen.getByLabelText("Base URL"), { target: { value: "https://api.example.com/v1" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save Provider" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddCustomProvider).toHaveBeenCalledWith({
|
||||
name: "Responses Provider",
|
||||
apiType: "openai-responses",
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation errors for empty name and invalid baseUrl", async () => {
|
||||
render(<CustomProvidersSection embedded />);
|
||||
|
||||
@@ -139,6 +168,42 @@ describe("CustomProvidersSection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes legacy openai-responses api to apiType on edit", async () => {
|
||||
mockFetchCustomProviders
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: "legacy-id",
|
||||
name: "Legacy Responses",
|
||||
baseUrl: "https://legacy.example.com/v1",
|
||||
api: "openai-responses",
|
||||
models: [{ id: "r1", name: "Responses 1" }],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: "legacy-id",
|
||||
name: "Legacy Responses",
|
||||
apiType: "openai-responses",
|
||||
baseUrl: "https://legacy.example.com/v1",
|
||||
},
|
||||
]);
|
||||
|
||||
render(<CustomProvidersSection embedded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Edit Legacy Responses")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Edit Legacy Responses"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateCustomProvider).toHaveBeenCalledWith("legacy-id", expect.objectContaining({
|
||||
apiType: "openai-responses",
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it("edits an existing provider", async () => {
|
||||
mockFetchCustomProviders
|
||||
.mockResolvedValueOnce([
|
||||
|
||||
@@ -193,6 +193,27 @@ describe("custom provider routes", () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /custom-providers accepts openai-responses apiType", async () => {
|
||||
const app = createApp(settings);
|
||||
const createRes = await REQUEST(app, "POST", "/api/custom-providers", {
|
||||
name: "Responses Provider",
|
||||
apiType: "openai-responses",
|
||||
baseUrl: "https://responses.example.com/v1",
|
||||
});
|
||||
|
||||
expect(createRes.status).toBe(201);
|
||||
expect(createRes.body.apiType).toBe("openai-responses");
|
||||
|
||||
const listRes = await REQUEST(app, "GET", "/api/custom-providers");
|
||||
expect(listRes.status).toBe(200);
|
||||
expect(listRes.body).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "Responses Provider",
|
||||
apiType: "openai-responses",
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("POST /custom-providers rejects invalid apiType", async () => {
|
||||
const app = createApp(settings);
|
||||
const res = await REQUEST(app, "POST", "/api/custom-providers", {
|
||||
@@ -239,6 +260,7 @@ describe("custom provider routes", () => {
|
||||
const app = createApp(settings);
|
||||
const res = await REQUEST(app, "PUT", "/api/custom-providers/cp-1", {
|
||||
name: "Updated",
|
||||
apiType: "openai-responses",
|
||||
apiKey: "sk-updated-9999",
|
||||
});
|
||||
|
||||
@@ -246,7 +268,7 @@ describe("custom provider routes", () => {
|
||||
expect(res.body).toMatchObject({
|
||||
id: "cp-1",
|
||||
name: "Updated",
|
||||
apiType: "openai-compatible",
|
||||
apiType: "openai-responses",
|
||||
baseUrl: "https://original.example.com",
|
||||
apiKey: "sk-•••••9999",
|
||||
});
|
||||
|
||||
@@ -394,6 +394,38 @@ describe("POST /api/custom-providers/probe-models", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("routes openai-responses through the OpenAI-compatible probe branch", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: "gpt-5", object: "model", owned_by: "system" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const app = setupApp(createCustomProviderStore().store);
|
||||
const res = await doRequest(app, "POST", "/api/custom-providers/probe-models", {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiType: "openai-responses",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
count: 1,
|
||||
models: [
|
||||
{ id: "gpt-5", name: "gpt-5", reasoning: false },
|
||||
],
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://api.openai.com/v1/models",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: "Bearer sk-test" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns Anthropic-compatible models", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
||||
@@ -46,8 +46,13 @@ function assertNonEmptyString(value: unknown, fieldName: string): string {
|
||||
* @throws {ApiError} with status 400 if the type is not recognized.
|
||||
*/
|
||||
function assertApiType(value: unknown): CustomProvider["apiType"] {
|
||||
if (value !== "openai-compatible" && value !== "anthropic-compatible" && value !== "google-generative-ai") {
|
||||
throw badRequest("apiType must be 'openai-compatible', 'anthropic-compatible', or 'google-generative-ai'");
|
||||
if (
|
||||
value !== "openai-compatible" &&
|
||||
value !== "anthropic-compatible" &&
|
||||
value !== "google-generative-ai" &&
|
||||
value !== "openai-responses"
|
||||
) {
|
||||
throw badRequest("apiType must be 'openai-compatible', 'anthropic-compatible', 'google-generative-ai', or 'openai-responses'");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -144,7 +149,7 @@ interface ProbeModelResult {
|
||||
|
||||
const MAX_PROBE_MODELS = 100;
|
||||
|
||||
type ProbeApiType = "openai-compatible" | "anthropic-compatible" | "google-generative-ai";
|
||||
type ProbeApiType = "openai-compatible" | "anthropic-compatible" | "google-generative-ai" | "openai-responses";
|
||||
|
||||
/**
|
||||
* Check if a model should be excluded (embedding / reranking / audio-only / no-text-input models).
|
||||
@@ -272,7 +277,7 @@ async function probeProviderModels(
|
||||
"User-Agent": "Fusion/1.0",
|
||||
};
|
||||
|
||||
if (apiType === "openai-compatible") {
|
||||
if (apiType === "openai-compatible" || apiType === "openai-responses") {
|
||||
// OpenAI-compatible: /v1/models relative to baseUrl
|
||||
const pathname = url.pathname.replace(/\/+$/, "");
|
||||
const modelsPath = pathname ? pathname + "/models" : "/models";
|
||||
@@ -560,10 +565,11 @@ export const registerCustomProviderRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
if (
|
||||
rawApiType !== "openai-compatible" &&
|
||||
rawApiType !== "anthropic-compatible" &&
|
||||
rawApiType !== "google-generative-ai"
|
||||
rawApiType !== "google-generative-ai" &&
|
||||
rawApiType !== "openai-responses"
|
||||
) {
|
||||
throw badRequest(
|
||||
"apiType must be 'openai-compatible', 'anthropic-compatible', or 'google-generative-ai'",
|
||||
"apiType must be 'openai-compatible', 'anthropic-compatible', 'google-generative-ai', or 'openai-responses'",
|
||||
);
|
||||
}
|
||||
const apiType = rawApiType as ProbeApiType;
|
||||
|
||||
@@ -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