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,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.

View 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.

View File

@@ -10,6 +10,7 @@ describe("custom-provider-registry", () => {
it.each([ it.each([
["openai-compatible", "openai-completions"], ["openai-compatible", "openai-completions"],
["anthropic-compatible", "anthropic"], ["anthropic-compatible", "anthropic"],
["openai-responses", "openai-responses"],
])("resolveApiType maps %s -> %s", (apiType, expectedApi) => { ])("resolveApiType maps %s -> %s", (apiType, expectedApi) => {
expect(resolveApiType(apiType)).toBe(expectedApi); expect(resolveApiType(apiType)).toBe(expectedApi);
}); });
@@ -20,7 +21,7 @@ describe("custom-provider-registry", () => {
const logFn = vi.fn(); const logFn = vi.fn();
const providers: CustomProvider[] = [ const providers: CustomProvider[] = [
{ {
id: "openai-custom", id: "550e8400-e29b-41d4-a716-446655440000",
name: "OpenAI Custom", name: "OpenAI Custom",
apiType: "openai-compatible", apiType: "openai-compatible",
baseUrl: "https://example.test/v1", baseUrl: "https://example.test/v1",
@@ -28,7 +29,7 @@ describe("custom-provider-registry", () => {
models: [{ id: "m1", name: "Model 1" }], models: [{ id: "m1", name: "Model 1" }],
}, },
{ {
id: "anthropic-custom", id: "660e8400-e29b-41d4-a716-446655440001",
name: "Anthropic Custom", name: "Anthropic Custom",
apiType: "anthropic-compatible", apiType: "anthropic-compatible",
baseUrl: "https://anthropic.test", baseUrl: "https://anthropic.test",
@@ -54,6 +55,34 @@ describe("custom-provider-registry", () => {
expect(refresh).toHaveBeenCalledTimes(1); 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", () => { it("handles empty provider list and still refreshes", () => {
const registerProvider = vi.fn(); const registerProvider = vi.fn();
const refresh = vi.fn(); const refresh = vi.fn();
@@ -71,7 +100,7 @@ describe("custom-provider-registry", () => {
registerCustomProviders( registerCustomProviders(
{ registerProvider, refresh }, { registerProvider, refresh },
[{ [{
id: "no-models", id: "770e8400-e29b-41d4-a716-446655440002",
name: "No Models", name: "No Models",
apiType: "openai-compatible", apiType: "openai-compatible",
baseUrl: "https://nomodels.test", baseUrl: "https://nomodels.test",
@@ -97,13 +126,13 @@ describe("custom-provider-registry", () => {
{ registerProvider, refresh }, { registerProvider, refresh },
[ [
{ {
id: "bad", id: "880e8400-e29b-41d4-a716-446655440003",
name: "Bad", name: "Bad",
apiType: "openai-compatible", apiType: "openai-compatible",
baseUrl: "https://bad.test", baseUrl: "https://bad.test",
}, },
{ {
id: "good", id: "990e8400-e29b-41d4-a716-446655440004",
name: "Good", name: "Good",
apiType: "openai-compatible", apiType: "openai-compatible",
baseUrl: "https://good.test", baseUrl: "https://good.test",
@@ -113,7 +142,7 @@ describe("custom-provider-registry", () => {
); );
expect(registerProvider).toHaveBeenCalledTimes(2); 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); expect(refresh).toHaveBeenCalledTimes(1);
}); });
@@ -123,10 +152,10 @@ describe("custom-provider-registry", () => {
reregisterCustomProviders( reregisterCustomProviders(
{ registerProvider, refresh }, { 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: "aa0e8400-e29b-41d4-a716-446655440005", name: "Old", apiType: "openai-compatible", baseUrl: "https://old.test" },
{ id: "new", name: "New", apiType: "anthropic-compatible", baseUrl: "https://new.test" }, { id: "bb0e8400-e29b-41d4-a716-446655440006", name: "New", apiType: "anthropic-compatible", baseUrl: "https://new.test" },
], ],
vi.fn(), vi.fn(),
); );
@@ -142,13 +171,13 @@ describe("custom-provider-registry", () => {
reregisterCustomProviders( reregisterCustomProviders(
{ registerProvider, refresh }, { registerProvider, refresh },
[{ id: "same-id", name: "Provider", apiType: "openai-compatible", baseUrl: "https://one.test", apiKey: "A" }], [{ id: "cc0e8400-e29b-41d4-a716-446655440007", 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://two.test", apiKey: "B" }],
vi.fn(), vi.fn(),
); );
expect(registerProvider).toHaveBeenCalledTimes(1); expect(registerProvider).toHaveBeenCalledTimes(1);
expect(registerProvider).toHaveBeenCalledWith("same-id", expect.objectContaining({ expect(registerProvider).toHaveBeenCalledWith("provider", expect.objectContaining({
baseUrl: "https://two.test", baseUrl: "https://two.test",
apiKey: "B", apiKey: "B",
})); }));

View File

@@ -1,4 +1,4 @@
import type { CustomProvider } from "@fusion/core"; import { customProviderRegistryKey, type CustomProvider } from "@fusion/core";
interface ModelRegistryLike { interface ModelRegistryLike {
registerProvider: (name: string, config: { registerProvider: (name: string, config: {
@@ -22,6 +22,9 @@ export function resolveApiType(apiType: string): string {
if (apiType === "anthropic-compatible") { if (apiType === "anthropic-compatible") {
return "anthropic"; return "anthropic";
} }
if (apiType === "openai-responses") {
return "openai-responses";
}
return "openai-completions"; return "openai-completions";
} }
@@ -56,13 +59,15 @@ export function registerCustomProviders(
customProviders: CustomProvider[] | undefined, customProviders: CustomProvider[] | undefined,
logFn: (message: string) => void, logFn: (message: string) => void,
): void { ): void {
for (const provider of customProviders ?? []) { const providers = customProviders ?? [];
for (const provider of providers) {
const registryKey = customProviderRegistryKey(provider, providers);
try { try {
modelRegistry.registerProvider(provider.id, toProviderConfig(provider)); modelRegistry.registerProvider(registryKey, toProviderConfig(provider));
logFn(`Registered custom provider ${provider.id}`); logFn(`Registered custom provider "${provider.name}" (key=${registryKey}, id=${provider.id})`);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(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, logFn: (message: string) => void,
): void { ): void {
const previousById = new Map((previousProviders ?? []).map((provider) => [provider.id, provider])); 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); const previous = previousById.get(provider.id);
if (previous && !providersDiffer(previous, provider)) { if (previous && !providersDiffer(previous, provider)) {
continue; continue;
} }
const registryKey = customProviderRegistryKey(provider, providers);
try { try {
modelRegistry.registerProvider(provider.id, toProviderConfig(provider)); modelRegistry.registerProvider(registryKey, toProviderConfig(provider));
logFn(`${previous ? "Updated" : "Registered"} custom provider ${provider.id}`); logFn(`${previous ? "Updated" : "Registered"} custom provider "${provider.name}" (key=${registryKey}, id=${provider.id})`);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(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}`);
} }
} }

View 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");
});
});

View 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}`;
}

View File

@@ -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 { 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 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 { 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 { MOCK_PROVIDER_ID } from "./mock-provider-constants.js";
export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js"; export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js";
export { export {

View File

@@ -495,7 +495,7 @@ export interface NotificationProviderConfig {
export interface CustomProvider { export interface CustomProvider {
id: string; id: string;
name: string; name: string;
apiType: "openai-compatible" | "anthropic-compatible" | "google-generative-ai"; apiType: "openai-compatible" | "anthropic-compatible" | "google-generative-ai" | "openai-responses";
baseUrl: string; baseUrl: string;
apiKey?: string; apiKey?: string;
models?: { id: string; name: string }[]; models?: { id: string; name: string }[];

View File

@@ -1880,7 +1880,7 @@ export function setLlamaCppEnabled(
export interface CustomProvider { export interface CustomProvider {
id: string; id: string;
name: string; name: string;
apiType: "openai-compatible" | "anthropic-compatible" | "google-generative-ai"; apiType: "openai-compatible" | "anthropic-compatible" | "google-generative-ai" | "openai-responses";
baseUrl: string; baseUrl: string;
apiKey?: string; apiKey?: string;
models?: { id: string; name: string }[]; models?: { id: string; name: string }[];
@@ -1894,6 +1894,7 @@ export async function fetchCustomProviders(): Promise<CustomProviderConfig[] & {
baseUrl: provider.baseUrl, baseUrl: provider.baseUrl,
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" api: provider.apiType === "anthropic-compatible" ? "anthropic-messages"
: provider.apiType === "google-generative-ai" ? "google-generative-ai" : provider.apiType === "google-generative-ai" ? "google-generative-ai"
: provider.apiType === "openai-responses" ? "openai-responses"
: "openai-completions", : "openai-completions",
apiKey: provider.apiKey, apiKey: provider.apiKey,
models: (provider.models ?? []).map((model) => ({ id: model.id, name: model.name })), models: (provider.models ?? []).map((model) => ({ id: model.id, name: model.name })),
@@ -1927,7 +1928,10 @@ export function updateCustomProvider(
: {}), : {}),
...(legacy.api ...(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>) ...("apiType" in (updates as Record<string, unknown>)
@@ -1969,6 +1973,7 @@ export interface CustomProviderConfig {
export function createCustomProvider(config: CustomProviderConfig): Promise<CustomProvider> { export function createCustomProvider(config: CustomProviderConfig): Promise<CustomProvider> {
const apiType = config.api === "anthropic-messages" ? "anthropic-compatible" const apiType = config.api === "anthropic-messages" ? "anthropic-compatible"
: config.api === "google-generative-ai" ? "google-generative-ai" : config.api === "google-generative-ai" ? "google-generative-ai"
: config.api === "openai-responses" ? "openai-responses"
: "openai-compatible"; : "openai-compatible";
return addCustomProvider({ return addCustomProvider({
name: config.name?.trim() || config.id, name: config.name?.trim() || config.id,
@@ -2002,7 +2007,7 @@ export interface ProbeModelsResponse {
export interface ProbeModelsParams { export interface ProbeModelsParams {
baseUrl: string; baseUrl: string;
apiKey?: 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> { export async function probeProviderModels(params: ProbeModelsParams): Promise<ProbeModelsResponse> {

View File

@@ -13,7 +13,7 @@ import "./CustomProvidersSection.css";
type ProviderApiType = CustomProvider["apiType"]; 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 = { type LegacyProvider = {
id: string; id: string;
@@ -37,7 +37,10 @@ function normalizeProviders(result: Awaited<ReturnType<typeof fetchCustomProvide
return { return {
id: provider.id, id: provider.id,
name: provider.name?.trim() || 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, baseUrl: provider.baseUrl,
...(provider.apiKey ? { apiKey: provider.apiKey } : {}), ...(provider.apiKey ? { apiKey: provider.apiKey } : {}),
models: (provider.models ?? []).map((model) => ({ models: (provider.models ?? []).map((model) => ({
@@ -338,6 +341,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
disabled={saving} disabled={saving}
> >
<option value="openai-compatible">OpenAI-compatible</option> <option value="openai-compatible">OpenAI-compatible</option>
<option value="openai-responses">OpenAI Responses</option>
<option value="anthropic-compatible">Anthropic-compatible</option> <option value="anthropic-compatible">Anthropic-compatible</option>
</select> </select>
</div> </div>
@@ -453,6 +457,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
disabled={saving} disabled={saving}
> >
<option value="openai-compatible">OpenAI-compatible</option> <option value="openai-compatible">OpenAI-compatible</option>
<option value="openai-responses">OpenAI Responses</option>
<option value="anthropic-compatible">Anthropic-compatible</option> <option value="anthropic-compatible">Anthropic-compatible</option>
</select> </select>
</div> </div>

View File

@@ -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 () => { it("shows validation errors for empty name and invalid baseUrl", async () => {
render(<CustomProvidersSection embedded />); 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 () => { it("edits an existing provider", async () => {
mockFetchCustomProviders mockFetchCustomProviders
.mockResolvedValueOnce([ .mockResolvedValueOnce([

View File

@@ -193,6 +193,27 @@ describe("custom provider routes", () => {
expect(res.status).toBe(400); 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 () => { it("POST /custom-providers rejects invalid apiType", async () => {
const app = createApp(settings); const app = createApp(settings);
const res = await REQUEST(app, "POST", "/api/custom-providers", { const res = await REQUEST(app, "POST", "/api/custom-providers", {
@@ -239,6 +260,7 @@ describe("custom provider routes", () => {
const app = createApp(settings); const app = createApp(settings);
const res = await REQUEST(app, "PUT", "/api/custom-providers/cp-1", { const res = await REQUEST(app, "PUT", "/api/custom-providers/cp-1", {
name: "Updated", name: "Updated",
apiType: "openai-responses",
apiKey: "sk-updated-9999", apiKey: "sk-updated-9999",
}); });
@@ -246,7 +268,7 @@ describe("custom provider routes", () => {
expect(res.body).toMatchObject({ expect(res.body).toMatchObject({
id: "cp-1", id: "cp-1",
name: "Updated", name: "Updated",
apiType: "openai-compatible", apiType: "openai-responses",
baseUrl: "https://original.example.com", baseUrl: "https://original.example.com",
apiKey: "sk-•••••9999", apiKey: "sk-•••••9999",
}); });

View File

@@ -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 () => { it("returns Anthropic-compatible models", async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
ok: true, ok: true,

View File

@@ -46,8 +46,13 @@ function assertNonEmptyString(value: unknown, fieldName: string): string {
* @throws {ApiError} with status 400 if the type is not recognized. * @throws {ApiError} with status 400 if the type is not recognized.
*/ */
function assertApiType(value: unknown): CustomProvider["apiType"] { function assertApiType(value: unknown): CustomProvider["apiType"] {
if (value !== "openai-compatible" && value !== "anthropic-compatible" && value !== "google-generative-ai") { if (
throw badRequest("apiType must be 'openai-compatible', 'anthropic-compatible', or 'google-generative-ai'"); 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; return value;
} }
@@ -144,7 +149,7 @@ interface ProbeModelResult {
const MAX_PROBE_MODELS = 100; 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). * 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", "User-Agent": "Fusion/1.0",
}; };
if (apiType === "openai-compatible") { if (apiType === "openai-compatible" || apiType === "openai-responses") {
// OpenAI-compatible: /v1/models relative to baseUrl // OpenAI-compatible: /v1/models relative to baseUrl
const pathname = url.pathname.replace(/\/+$/, ""); const pathname = url.pathname.replace(/\/+$/, "");
const modelsPath = pathname ? pathname + "/models" : "/models"; const modelsPath = pathname ? pathname + "/models" : "/models";
@@ -560,10 +565,11 @@ export const registerCustomProviderRoutes: ApiRouteRegistrar = (ctx) => {
if ( if (
rawApiType !== "openai-compatible" && rawApiType !== "openai-compatible" &&
rawApiType !== "anthropic-compatible" && rawApiType !== "anthropic-compatible" &&
rawApiType !== "google-generative-ai" rawApiType !== "google-generative-ai" &&
rawApiType !== "openai-responses"
) { ) {
throw badRequest( 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; const apiType = rawApiType as ProbeApiType;

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

View File

@@ -34,7 +34,7 @@ import {
type AgentSession, type AgentSession,
type ToolDefinition, type ToolDefinition,
} from "@mariozechner/pi-coding-agent"; } 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 { import type {
AgentPermissionPolicyActionCategory, AgentPermissionPolicyActionCategory,
PermanentAgentActionCategory, PermanentAgentActionCategory,
@@ -1675,11 +1675,15 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
const resolvedProjectRoot = getProjectRootFromWorktree(options.cwd) ?? resolvePiExtensionProjectRoot(options.cwd); const resolvedProjectRoot = getProjectRootFromWorktree(options.cwd) ?? resolvePiExtensionProjectRoot(options.cwd);
await registerExtensionProviders(resolvedProjectRoot, modelRegistry); await registerExtensionProviders(resolvedProjectRoot, modelRegistry);
for (const provider of readCustomProviders()) { const customProviders = readCustomProviders();
for (const provider of customProviders) {
try { try {
modelRegistry.registerProvider(provider.id, { const registryKey = customProviderRegistryKey(provider, customProviders);
modelRegistry.registerProvider(registryKey, {
baseUrl: provider.baseUrl, 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, apiKey: provider.apiKey,
models: (provider.models ?? []).map((model) => ({ models: (provider.models ?? []).map((model) => ({
id: model.id, id: model.id,
@@ -1696,10 +1700,11 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
maxTokens: 16384, maxTokens: 16384,
})), })),
}); });
piLog.log(`Registered custom provider ${provider.id}`); piLog.log(`Registered custom provider "${provider.name}" (key=${registryKey}, id=${provider.id})`);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(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(); modelRegistry.refresh();