feat(FN-2961): merge fusion/fn-2961
- Add documentation for custom provider settings in the settings reference (`docs/settings-reference.md`) Commits merged: - feat(FN-2961): complete Step 6 — document custom provider settings Files changed: docs/settings-reference.md | 1 + 1 file changed, 1 insertion(+) Fusion-Task-Id: FN-2961
This commit is contained in:
@@ -48,6 +48,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|
||||
| `webhookFormat` | `"slack" \| "discord" \| "generic"` | `"generic"` | Webhook payload format. Part of legacy flat settings. |
|
||||
| `webhookEvents` | `string[]` | `[]` | Event filter for webhook notifications. Empty/omitted means all events. Part of legacy flat settings. |
|
||||
| `notificationProviders` | `NotificationProviderConfig[]` | `[]` | Array of pluggable notification provider configurations. Each entry uses `{ id, name, enabled, config }` and is dispatched by provider ID (for example `ntfy` or `webhook`). |
|
||||
| `customProviders` | `CustomProvider[]` | `[]` | User-defined OpenAI-compatible or Anthropic-compatible providers used by the custom-provider API (`/api/custom-providers`). Each entry uses `{ id, name, apiType, baseUrl, apiKey?, models? }`; API keys are stored raw but masked in API responses. |
|
||||
| `defaultProjectId` | `string` | `undefined` | Default project for multi-project CLI operations when `--project` is omitted. |
|
||||
| `setupComplete` | `boolean` | `undefined` | Tracks completion of first-run setup. |
|
||||
| `favoriteProviders` | `string[]` | `undefined` | Pinned providers shown first in model selectors. |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, normalizeMergeConflictStrategy } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, 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, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, 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, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||
export {
|
||||
BUILTIN_AGENT_PROMPTS,
|
||||
|
||||
@@ -29,6 +29,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
||||
webhookFormat: "generic",
|
||||
webhookEvents: [],
|
||||
notificationProviders: [],
|
||||
customProviders: [],
|
||||
defaultProjectId: undefined,
|
||||
setupComplete: undefined,
|
||||
favoriteProviders: undefined,
|
||||
|
||||
@@ -238,6 +238,15 @@ export interface NotificationProviderConfig {
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CustomProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
apiType: "openai-compatible" | "anthropic-compatible";
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
models?: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export interface WorkflowStepInput {
|
||||
/** Built-in template source ID when creating a concrete step from a template. */
|
||||
templateId?: string;
|
||||
@@ -1235,6 +1244,8 @@ export interface GlobalSettings {
|
||||
/** Pluggable notification providers configuration. Additive to legacy ntfy
|
||||
* settings so existing ntfy configuration continues working unchanged. */
|
||||
notificationProviders?: NotificationProviderConfig[];
|
||||
/** User-defined OpenAI/Anthropic-compatible API providers. */
|
||||
customProviders?: CustomProvider[];
|
||||
/** The default project ID for CLI operations when --project flag is not provided.
|
||||
* Used to determine which project to operate on when not in a project directory.
|
||||
* Set via `fn project set-default <name>`. */
|
||||
|
||||
@@ -1499,6 +1499,68 @@ export function setClaudeCliEnabled(
|
||||
});
|
||||
}
|
||||
|
||||
export interface CustomProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
apiType: "openai-compatible" | "anthropic-compatible";
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
models?: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export async function fetchCustomProviders(): Promise<CustomProvider[] & { providers: CustomProvider[] }> {
|
||||
const providers = await api<CustomProvider[]>("/custom-providers");
|
||||
return Object.assign(providers, { providers });
|
||||
}
|
||||
|
||||
export function addCustomProvider(provider: Omit<CustomProvider, "id">): Promise<CustomProvider> {
|
||||
return api<CustomProvider>("/custom-providers", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(provider),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateCustomProvider(
|
||||
id: string,
|
||||
updates: Partial<Omit<CustomProvider, "id">> | CustomProviderConfig,
|
||||
): Promise<CustomProvider> {
|
||||
const legacy = updates as Partial<CustomProviderConfig>;
|
||||
const normalized: Partial<Omit<CustomProvider, "id">> = {
|
||||
...(typeof legacy.name === "string" ? { name: legacy.name } : {}),
|
||||
...(typeof legacy.baseUrl === "string" ? { baseUrl: legacy.baseUrl } : {}),
|
||||
...(typeof legacy.apiKey === "string" ? { apiKey: legacy.apiKey } : {}),
|
||||
...(Array.isArray(legacy.models)
|
||||
? {
|
||||
models: legacy.models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name ?? model.id,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
...(legacy.api
|
||||
? {
|
||||
apiType: legacy.api === "anthropic-messages" ? "anthropic-compatible" : "openai-compatible",
|
||||
}
|
||||
: {}),
|
||||
...("apiType" in (updates as Record<string, unknown>)
|
||||
? { apiType: (updates as Partial<Omit<CustomProvider, "id">>).apiType }
|
||||
: {}),
|
||||
};
|
||||
|
||||
return api<CustomProvider>(`/custom-providers/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(normalized),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteCustomProvider(id: string): Promise<{ success: boolean }> {
|
||||
return api<{ success: boolean }>(`/custom-providers/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
// Backward-compatibility exports for existing UI callers; will be removed when
|
||||
// custom-provider UI migrates to the new core CustomProvider contract.
|
||||
export interface CustomProviderModelInput {
|
||||
id: string;
|
||||
name?: string;
|
||||
@@ -1516,27 +1578,17 @@ export interface CustomProviderConfig {
|
||||
models: CustomProviderModelInput[];
|
||||
}
|
||||
|
||||
export function fetchCustomProviders(): Promise<{ providers: CustomProviderConfig[] }> {
|
||||
return api<{ providers: CustomProviderConfig[] }>("/custom-providers");
|
||||
}
|
||||
|
||||
export function createCustomProvider(config: CustomProviderConfig): Promise<{ provider: CustomProviderConfig }> {
|
||||
return api<{ provider: CustomProviderConfig }>("/custom-providers", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateCustomProvider(id: string, config: CustomProviderConfig): Promise<{ provider: CustomProviderConfig }> {
|
||||
return api<{ provider: CustomProviderConfig }>(`/custom-providers/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteCustomProvider(id: string): Promise<void> {
|
||||
return api<void>(`/custom-providers/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
export function createCustomProvider(config: CustomProviderConfig): Promise<CustomProvider> {
|
||||
const apiType = config.api === "anthropic-messages" ? "anthropic-compatible" : "openai-compatible";
|
||||
return addCustomProvider({
|
||||
name: config.name?.trim() || config.id,
|
||||
apiType,
|
||||
baseUrl: config.baseUrl,
|
||||
apiKey: config.apiKey,
|
||||
models: config.models?.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name ?? model.id,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,118 +1,275 @@
|
||||
import { mkdtemp, readFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
// @vitest-environment node
|
||||
|
||||
import express from "express";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { request } from "../../test-request.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TaskStore, GlobalSettings, CustomProvider } from "@fusion/core";
|
||||
import { createApiRoutes } from "../../routes.js";
|
||||
import { request as performRequest } from "../../test-request.js";
|
||||
|
||||
function createMockGlobalSettingsStore(settings: GlobalSettings) {
|
||||
return {
|
||||
getSettings: vi.fn(async () => settings),
|
||||
updateSettings: vi.fn(),
|
||||
getSettingsPath: vi.fn(),
|
||||
init: vi.fn(),
|
||||
invalidateCache: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockStore(settings: GlobalSettings, onUpdate: (patch: Partial<GlobalSettings>) => void): TaskStore {
|
||||
const globalSettingsStore = createMockGlobalSettingsStore(settings);
|
||||
return {
|
||||
getTask: vi.fn(),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
searchTasks: vi.fn().mockResolvedValue([]),
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
getSettingsFast: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn(),
|
||||
updateGlobalSettings: vi.fn(async (patch: Partial<GlobalSettings>) => {
|
||||
onUpdate(patch);
|
||||
Object.assign(settings, patch);
|
||||
return settings;
|
||||
}),
|
||||
getSettingsByScope: vi.fn().mockResolvedValue({ global: settings, project: {} }),
|
||||
getSettingsByScopeFast: vi.fn().mockResolvedValue({ global: settings, project: {} }),
|
||||
getGlobalSettingsStore: vi.fn(() => globalSettingsStore),
|
||||
logEntry: vi.fn(),
|
||||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
getAgentLogCount: vi.fn().mockResolvedValue(0),
|
||||
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]),
|
||||
addSteeringComment: vi.fn(),
|
||||
addTaskComment: vi.fn(),
|
||||
updateTaskComment: vi.fn(),
|
||||
deleteTaskComment: vi.fn(),
|
||||
getTaskDocuments: vi.fn().mockResolvedValue([]),
|
||||
getTaskDocument: vi.fn().mockResolvedValue(null),
|
||||
getTaskDocumentRevisions: vi.fn().mockResolvedValue([]),
|
||||
getAllDocuments: vi.fn().mockResolvedValue([]),
|
||||
upsertTaskDocument: vi.fn(),
|
||||
deleteTaskDocument: vi.fn(),
|
||||
updatePrInfo: vi.fn(),
|
||||
updateIssueInfo: vi.fn(),
|
||||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||||
getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"),
|
||||
getDatabase: vi.fn(),
|
||||
listWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
createWorkflowStep: vi.fn(),
|
||||
getWorkflowStep: vi.fn(),
|
||||
updateWorkflowStep: vi.fn(),
|
||||
deleteWorkflowStep: vi.fn(),
|
||||
getMissionStore: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
async function REQUEST(
|
||||
app: express.Express,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<{ status: number; body: any }> {
|
||||
const payload = body === undefined ? undefined : JSON.stringify(body);
|
||||
const res = await performRequest(
|
||||
app,
|
||||
method,
|
||||
path,
|
||||
payload,
|
||||
body === undefined ? undefined : { "Content-Type": "application/json" },
|
||||
);
|
||||
return { status: res.status, body: res.body };
|
||||
}
|
||||
|
||||
function createApp(settings: GlobalSettings, onUpdate: (patch: Partial<GlobalSettings>) => void = () => undefined) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(createMockStore(settings, onUpdate)));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("custom provider routes", () => {
|
||||
let homeDir: string;
|
||||
const refresh = vi.fn();
|
||||
let settings: GlobalSettings;
|
||||
|
||||
beforeEach(async () => {
|
||||
homeDir = await mkdtemp(path.join(os.tmpdir(), "fn-custom-provider-"));
|
||||
vi.stubEnv("HOME", homeDir);
|
||||
vi.stubEnv("USERPROFILE", homeDir);
|
||||
refresh.mockReset();
|
||||
beforeEach(() => {
|
||||
settings = {};
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes({
|
||||
getRootDir: () => "/tmp/project",
|
||||
getFusionDir: () => "/tmp/project/.fusion",
|
||||
getDatabase: () => ({ exec: vi.fn(), prepare: vi.fn().mockReturnValue({ run: vi.fn(), get: vi.fn(), all: vi.fn() }) }),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
getGlobalSettingsStore: vi.fn().mockReturnValue({ getSettings: vi.fn().mockResolvedValue({}) }),
|
||||
} as unknown as TaskStore, { modelRegistry: { refresh, getAvailable: () => [] } }));
|
||||
return app;
|
||||
}
|
||||
it("GET /custom-providers returns empty array when none configured", async () => {
|
||||
const app = createApp(settings);
|
||||
const res = await REQUEST(app, "GET", "/api/custom-providers");
|
||||
|
||||
it("supports create/read/update/delete and refreshes model registry", async () => {
|
||||
const app = buildApp();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
const createRes = await request(app, "POST", "/api/custom-providers", JSON.stringify({
|
||||
id: "my-openai-proxy",
|
||||
name: "My OpenAI Proxy",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
api: "openai-completions",
|
||||
apiKey: "MY_API_KEY",
|
||||
models: [{ id: "gpt-4o-mini", name: "GPT 4o Mini" }],
|
||||
}), { "Content-Type": "application/json" });
|
||||
it("GET /custom-providers masks API keys", async () => {
|
||||
settings.customProviders = [
|
||||
{
|
||||
id: "cp-1",
|
||||
name: "OpenAI Proxy",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
apiKey: "sk-test-secret-key-1234",
|
||||
},
|
||||
{
|
||||
id: "cp-2",
|
||||
name: "Anthropic Proxy",
|
||||
apiType: "anthropic-compatible",
|
||||
baseUrl: "https://anthropic.example.com",
|
||||
apiKey: "short",
|
||||
},
|
||||
];
|
||||
|
||||
expect(createRes.status).toBe(201);
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
const app = createApp(settings);
|
||||
const res = await REQUEST(app, "GET", "/api/custom-providers");
|
||||
|
||||
const getRes = await request(app, "GET", "/api/custom-providers");
|
||||
expect(getRes.status).toBe(200);
|
||||
expect((getRes.body as { providers: Array<{ id: string }> }).providers.map((p) => p.id)).toContain("my-openai-proxy");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
id: "cp-1",
|
||||
name: "OpenAI Proxy",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
apiKey: "sk-•••••1234",
|
||||
},
|
||||
{
|
||||
id: "cp-2",
|
||||
name: "Anthropic Proxy",
|
||||
apiType: "anthropic-compatible",
|
||||
baseUrl: "https://anthropic.example.com",
|
||||
apiKey: "••••••••",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const updateRes = await request(app, "PUT", "/api/custom-providers/my-openai-proxy", JSON.stringify({
|
||||
id: "ignored-id",
|
||||
baseUrl: "https://proxy2.example.com/v1",
|
||||
api: "openai-responses",
|
||||
models: [{ id: "gpt-4.1" }],
|
||||
}), { "Content-Type": "application/json" });
|
||||
it("POST /custom-providers creates provider with auto-generated id", async () => {
|
||||
const updates: Array<Partial<GlobalSettings>> = [];
|
||||
const app = createApp(settings, (patch) => updates.push(patch));
|
||||
|
||||
expect(updateRes.status).toBe(200);
|
||||
expect((updateRes.body as { provider: { id: string; baseUrl: string; api: string } }).provider).toMatchObject({
|
||||
id: "my-openai-proxy",
|
||||
baseUrl: "https://proxy2.example.com/v1",
|
||||
api: "openai-responses",
|
||||
const res = await REQUEST(app, "POST", "/api/custom-providers", {
|
||||
name: "My Provider",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://example.com/v1",
|
||||
apiKey: "sk-my-secret-5678",
|
||||
models: [{ id: "gpt-4.1", name: "GPT 4.1" }],
|
||||
});
|
||||
|
||||
const deleteRes = await request(app, "DELETE", "/api/custom-providers/my-openai-proxy");
|
||||
expect(deleteRes.status).toBe(204);
|
||||
expect(refresh).toHaveBeenCalledTimes(3);
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
expect(res.body.apiKey).toBe("sk-•••••5678");
|
||||
expect(updates).toHaveLength(1);
|
||||
|
||||
const persisted = updates[0].customProviders as CustomProvider[];
|
||||
expect(persisted[0]?.apiKey).toBe("sk-my-secret-5678");
|
||||
});
|
||||
|
||||
it("validates bad id, built-in id, invalid URL, and missing fields", async () => {
|
||||
const app = buildApp();
|
||||
it("POST /custom-providers rejects missing name", async () => {
|
||||
const app = createApp(settings);
|
||||
const res = await REQUEST(app, "POST", "/api/custom-providers", {
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://example.com",
|
||||
});
|
||||
|
||||
const badId = await request(app, "POST", "/api/custom-providers", JSON.stringify({
|
||||
id: "Bad_ID",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "m1" }],
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(badId.status).toBe(400);
|
||||
|
||||
const builtIn = await request(app, "POST", "/api/custom-providers", JSON.stringify({
|
||||
id: "openai",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "m1" }],
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(builtIn.status).toBe(400);
|
||||
|
||||
const invalidUrl = await request(app, "POST", "/api/custom-providers", JSON.stringify({
|
||||
id: "custom-openai",
|
||||
baseUrl: "ftp://proxy.example.com/v1",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "m1" }],
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(invalidUrl.status).toBe(400);
|
||||
|
||||
const missingModels = await request(app, "POST", "/api/custom-providers", JSON.stringify({
|
||||
id: "custom-openai",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
api: "openai-completions",
|
||||
models: [],
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(missingModels.status).toBe(400);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("creates models.json automatically when missing", async () => {
|
||||
const app = buildApp();
|
||||
it("POST /custom-providers rejects invalid apiType", async () => {
|
||||
const app = createApp(settings);
|
||||
const res = await REQUEST(app, "POST", "/api/custom-providers", {
|
||||
name: "Invalid",
|
||||
apiType: "bad-type",
|
||||
baseUrl: "https://example.com",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /custom-providers rejects invalid baseUrl", async () => {
|
||||
const app = createApp(settings);
|
||||
const res = await REQUEST(app, "POST", "/api/custom-providers", {
|
||||
name: "Invalid URL",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "not-a-url",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /custom-providers rejects non-http/https baseUrl", async () => {
|
||||
const app = createApp(settings);
|
||||
const res = await REQUEST(app, "POST", "/api/custom-providers", {
|
||||
name: "FTP URL",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "ftp://example.com",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("PUT /custom-providers/:id updates an existing provider", async () => {
|
||||
settings.customProviders = [
|
||||
{
|
||||
id: "cp-1",
|
||||
name: "Original",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://original.example.com",
|
||||
},
|
||||
];
|
||||
|
||||
const app = createApp(settings);
|
||||
const res = await REQUEST(app, "PUT", "/api/custom-providers/cp-1", {
|
||||
name: "Updated",
|
||||
apiKey: "sk-updated-9999",
|
||||
});
|
||||
|
||||
const res = await request(app, "GET", "/api/custom-providers");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
id: "cp-1",
|
||||
name: "Updated",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://original.example.com",
|
||||
apiKey: "sk-•••••9999",
|
||||
});
|
||||
});
|
||||
|
||||
const modelsPath = path.join(homeDir, ".fusion", "agent", "models.json");
|
||||
const content = await readFile(modelsPath, "utf8");
|
||||
expect(JSON.parse(content)).toEqual({ providers: {} });
|
||||
it("PUT /custom-providers/:id returns 404 for non-existent id", async () => {
|
||||
const app = createApp(settings);
|
||||
const res = await REQUEST(app, "PUT", "/api/custom-providers/missing", {
|
||||
name: "Updated",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("DELETE /custom-providers/:id removes a provider", async () => {
|
||||
settings.customProviders = [
|
||||
{
|
||||
id: "cp-1",
|
||||
name: "Delete Me",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://example.com",
|
||||
},
|
||||
];
|
||||
|
||||
const app = createApp(settings);
|
||||
const res = await REQUEST(app, "DELETE", "/api/custom-providers/cp-1");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
expect(settings.customProviders).toEqual([]);
|
||||
});
|
||||
|
||||
it("DELETE /custom-providers/:id returns 404 for non-existent id", async () => {
|
||||
const app = createApp(settings);
|
||||
const res = await REQUEST(app, "DELETE", "/api/custom-providers/missing");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,253 +1,247 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import type { CustomProvider } from "@fusion/core";
|
||||
import { ApiError, badRequest, notFound } from "../api-error.js";
|
||||
import { getFusionModelsPath } from "../auth-paths.js";
|
||||
import type { ApiRouteRegistrar } from "./types.js";
|
||||
|
||||
const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
|
||||
const ALLOWED_APIS = new Set([
|
||||
"openai-completions",
|
||||
"openai-responses",
|
||||
"anthropic-messages",
|
||||
"google-generative-ai",
|
||||
]);
|
||||
|
||||
// Keep in sync with BUILT_IN_PROVIDER_IDS in CustomProviderForm.tsx
|
||||
const BUILT_IN_PROVIDER_IDS = new Set<string>([
|
||||
"anthropic", "claude-cli", "pi-claude-cli", "openai", "openai-codex", "google", "gemini", "google-antigravity",
|
||||
"antigravity", "google-vertex", "vertex", "google-cloud-code", "cloud-code", "google-gemini-cli", "google-generative-ai",
|
||||
"ollama", "github", "github-copilot", "openrouter", "minimax", "minimax-cn", "zai", "kimi", "moonshot", "kimi-coding",
|
||||
"bedrock", "amazon-bedrock", "xai", "grok", "opencode", "opencode-go", "qwen", "qwen-ai", "qwen-coder", "alibaba", "tongyi",
|
||||
"lmstudio", "lm-studio", "huggingface", "hugging-face", "hf", "mistral", "mistral-ai", "azure", "azure-openai",
|
||||
"azure-openai-responses", "fireworks", "fireworks-ai", "fireworksai", "cerebras", "groq", "vercel", "vercel-ai-gateway",
|
||||
"hermes", "hermes-agent", "hermesagent", "openclaw", "open-claw", "paperclip", "paperclipai", "paperclip-ai",
|
||||
]);
|
||||
|
||||
type CustomModelConfig = {
|
||||
id: string;
|
||||
name?: string;
|
||||
reasoning?: boolean;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
|
||||
type CustomProviderConfig = {
|
||||
id: string;
|
||||
name?: string;
|
||||
baseUrl: string;
|
||||
api: "openai-completions" | "openai-responses" | "anthropic-messages" | "google-generative-ai";
|
||||
apiKey?: string;
|
||||
models: CustomModelConfig[];
|
||||
};
|
||||
|
||||
type ModelsFile = {
|
||||
providers: Record<string, Omit<CustomProviderConfig, "id">>;
|
||||
};
|
||||
|
||||
function validateBaseUrl(baseUrl: unknown): string {
|
||||
if (typeof baseUrl !== "string" || baseUrl.trim().length === 0) {
|
||||
throw badRequest("baseUrl is required");
|
||||
function maskApiKey(key: string): string {
|
||||
if (key.length <= 8) {
|
||||
return "••••••••";
|
||||
}
|
||||
const normalized = baseUrl.trim();
|
||||
return key.slice(0, 3) + "•••••" + key.slice(-4);
|
||||
}
|
||||
|
||||
function sanitizeProvider(provider: CustomProvider): CustomProvider {
|
||||
if (!provider.apiKey) {
|
||||
return provider;
|
||||
}
|
||||
|
||||
return {
|
||||
...provider,
|
||||
apiKey: maskApiKey(provider.apiKey),
|
||||
};
|
||||
}
|
||||
|
||||
function assertNonEmptyString(value: unknown, fieldName: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw badRequest(`${fieldName} is required and must be a non-empty string`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function assertApiType(value: unknown): CustomProvider["apiType"] {
|
||||
if (value !== "openai-compatible" && value !== "anthropic-compatible") {
|
||||
throw badRequest("apiType must be either 'openai-compatible' or 'anthropic-compatible'");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertBaseUrl(value: unknown): string {
|
||||
const baseUrl = assertNonEmptyString(value, "baseUrl");
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(normalized);
|
||||
parsed = new URL(baseUrl);
|
||||
} catch {
|
||||
throw badRequest("baseUrl must be a valid URL");
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw badRequest("baseUrl must use http or https");
|
||||
}
|
||||
return normalized;
|
||||
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
function validateModels(models: unknown): CustomModelConfig[] {
|
||||
if (!Array.isArray(models) || models.length === 0) {
|
||||
throw badRequest("models must contain at least one model");
|
||||
function validateModels(value: unknown): Array<{ id: string; name: string }> | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return models.map((model, index) => {
|
||||
if (!model || typeof model !== "object") {
|
||||
if (!Array.isArray(value)) {
|
||||
throw badRequest("models must be an array");
|
||||
}
|
||||
|
||||
return value.map((entry, index) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
throw badRequest(`models[${index}] must be an object`);
|
||||
}
|
||||
const row = model as Record<string, unknown>;
|
||||
if (typeof row.id !== "string" || row.id.trim().length === 0) {
|
||||
throw badRequest(`models[${index}].id is required`);
|
||||
}
|
||||
const parsed: CustomModelConfig = { id: row.id.trim() };
|
||||
if (typeof row.name === "string" && row.name.trim().length > 0) parsed.name = row.name.trim();
|
||||
if (typeof row.reasoning === "boolean") parsed.reasoning = row.reasoning;
|
||||
if (row.contextWindow !== undefined) {
|
||||
if (typeof row.contextWindow !== "number" || !Number.isFinite(row.contextWindow) || row.contextWindow <= 0) {
|
||||
throw badRequest(`models[${index}].contextWindow must be a positive number`);
|
||||
}
|
||||
parsed.contextWindow = row.contextWindow;
|
||||
}
|
||||
if (row.maxTokens !== undefined) {
|
||||
if (typeof row.maxTokens !== "number" || !Number.isFinite(row.maxTokens) || row.maxTokens <= 0) {
|
||||
throw badRequest(`models[${index}].maxTokens must be a positive number`);
|
||||
}
|
||||
parsed.maxTokens = row.maxTokens;
|
||||
}
|
||||
return parsed;
|
||||
|
||||
const row = entry as Record<string, unknown>;
|
||||
return {
|
||||
id: assertNonEmptyString(row.id, `models[${index}].id`),
|
||||
name: assertNonEmptyString(row.name, `models[${index}].name`),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function validateApi(api: unknown): CustomProviderConfig["api"] {
|
||||
if (typeof api !== "string" || !ALLOWED_APIS.has(api)) {
|
||||
throw badRequest("api must be one of: openai-completions, openai-responses, anthropic-messages, google-generative-ai");
|
||||
function parseCreateBody(body: unknown): Omit<CustomProvider, "id"> {
|
||||
if (!body || typeof body !== "object") {
|
||||
throw badRequest("request body must be an object");
|
||||
}
|
||||
return api as CustomProviderConfig["api"];
|
||||
}
|
||||
|
||||
function parseProviderFromBody(body: unknown): CustomProviderConfig {
|
||||
if (!body || typeof body !== "object") throw badRequest("request body must be an object");
|
||||
const row = body as Record<string, unknown>;
|
||||
|
||||
if (typeof row.id !== "string" || row.id.trim().length === 0) {
|
||||
throw badRequest("id is required");
|
||||
}
|
||||
const id = row.id.trim();
|
||||
if (!PROVIDER_ID_PATTERN.test(id)) {
|
||||
throw badRequest("id must be kebab-case (^[a-z][a-z0-9-]*$)");
|
||||
}
|
||||
|
||||
const baseUrl = validateBaseUrl(row.baseUrl);
|
||||
const api = validateApi(row.api);
|
||||
const models = validateModels(row.models);
|
||||
|
||||
const config: CustomProviderConfig = {
|
||||
id,
|
||||
baseUrl,
|
||||
api,
|
||||
models,
|
||||
const provider: Omit<CustomProvider, "id"> = {
|
||||
name: assertNonEmptyString(row.name, "name"),
|
||||
apiType: assertApiType(row.apiType),
|
||||
baseUrl: assertBaseUrl(row.baseUrl),
|
||||
};
|
||||
|
||||
if (typeof row.name === "string" && row.name.trim().length > 0) config.name = row.name.trim();
|
||||
if (typeof row.apiKey === "string" && row.apiKey.trim().length > 0) config.apiKey = row.apiKey.trim();
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async function readModelsFile(modelsPath: string): Promise<ModelsFile> {
|
||||
try {
|
||||
const content = await readFile(modelsPath, "utf8");
|
||||
const parsed = JSON.parse(content) as Partial<ModelsFile>;
|
||||
if (!parsed || typeof parsed !== "object" || !parsed.providers || typeof parsed.providers !== "object") {
|
||||
return { providers: {} };
|
||||
if (row.apiKey !== undefined) {
|
||||
if (typeof row.apiKey !== "string") {
|
||||
throw badRequest("apiKey must be a string");
|
||||
}
|
||||
return { providers: parsed.providers as Record<string, Omit<CustomProviderConfig, "id">> };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
await mkdir(path.dirname(modelsPath), { recursive: true });
|
||||
const initial = { providers: {} } satisfies ModelsFile;
|
||||
await writeFile(modelsPath, `${JSON.stringify(initial, null, 2)}\n`, "utf8");
|
||||
return initial;
|
||||
if (row.apiKey.trim().length > 0) {
|
||||
provider.apiKey = row.apiKey;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const models = validateModels(row.models);
|
||||
if (models) {
|
||||
provider.models = models;
|
||||
}
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
async function writeModelsFile(modelsPath: string, file: ModelsFile): Promise<void> {
|
||||
await mkdir(path.dirname(modelsPath), { recursive: true });
|
||||
await writeFile(modelsPath, `${JSON.stringify(file, null, 2)}\n`, "utf8");
|
||||
}
|
||||
function parseUpdateBody(body: unknown): Partial<Omit<CustomProvider, "id">> {
|
||||
if (!body || typeof body !== "object") {
|
||||
throw badRequest("request body must be an object");
|
||||
}
|
||||
|
||||
function normalizeResponse(file: ModelsFile): CustomProviderConfig[] {
|
||||
return Object.entries(file.providers).map(([id, provider]) => ({ id, ...provider }));
|
||||
const row = body as Record<string, unknown>;
|
||||
const updates: Partial<Omit<CustomProvider, "id">> = {};
|
||||
|
||||
if (row.name !== undefined) {
|
||||
updates.name = assertNonEmptyString(row.name, "name");
|
||||
}
|
||||
if (row.apiType !== undefined) {
|
||||
updates.apiType = assertApiType(row.apiType);
|
||||
}
|
||||
if (row.baseUrl !== undefined) {
|
||||
updates.baseUrl = assertBaseUrl(row.baseUrl);
|
||||
}
|
||||
if (row.apiKey !== undefined) {
|
||||
if (typeof row.apiKey !== "string") {
|
||||
throw badRequest("apiKey must be a string");
|
||||
}
|
||||
updates.apiKey = row.apiKey.trim().length > 0 ? row.apiKey : undefined;
|
||||
}
|
||||
if (row.models !== undefined) {
|
||||
updates.models = validateModels(row.models);
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
export const registerCustomProviderRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const { router, options, rethrowAsApiError } = ctx;
|
||||
const { router, store, rethrowAsApiError } = ctx;
|
||||
|
||||
router.get("/custom-providers", async (_req, res) => {
|
||||
try {
|
||||
const modelsPath = getFusionModelsPath();
|
||||
const file = await readModelsFile(modelsPath);
|
||||
res.json({ providers: normalizeResponse(file) });
|
||||
if (!store) {
|
||||
throw new ApiError(500, "Settings store unavailable");
|
||||
}
|
||||
|
||||
const settings = await store.getGlobalSettingsStore().getSettings();
|
||||
const providers = (settings.customProviders ?? []).map(sanitizeProvider);
|
||||
res.json(providers);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/custom-providers", async (req, res) => {
|
||||
try {
|
||||
const provider = parseProviderFromBody(req.body);
|
||||
if (BUILT_IN_PROVIDER_IDS.has(provider.id)) {
|
||||
throw badRequest(`id '${provider.id}' is reserved for a built-in provider`);
|
||||
if (!store) {
|
||||
throw new ApiError(500, "Settings store unavailable");
|
||||
}
|
||||
|
||||
const modelsPath = getFusionModelsPath();
|
||||
const file = await readModelsFile(modelsPath);
|
||||
if (file.providers[provider.id]) {
|
||||
throw badRequest(`custom provider '${provider.id}' already exists`);
|
||||
}
|
||||
|
||||
file.providers[provider.id] = {
|
||||
name: provider.name,
|
||||
baseUrl: provider.baseUrl,
|
||||
api: provider.api,
|
||||
apiKey: provider.apiKey,
|
||||
models: provider.models,
|
||||
const providerInput = parseCreateBody(req.body);
|
||||
const provider: CustomProvider = {
|
||||
id: crypto.randomUUID(),
|
||||
...providerInput,
|
||||
};
|
||||
await writeModelsFile(modelsPath, file);
|
||||
options?.modelRegistry?.refresh();
|
||||
|
||||
res.status(201).json({ provider });
|
||||
const settings = await store.getGlobalSettingsStore().getSettings();
|
||||
const providers = settings.customProviders ?? [];
|
||||
await store.updateGlobalSettings({ customProviders: [...providers, provider] });
|
||||
|
||||
res.status(201).json(sanitizeProvider(provider));
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/custom-providers/:id", async (req, res) => {
|
||||
try {
|
||||
const providerId = String(req.params.id ?? "").trim();
|
||||
if (!providerId) throw badRequest("id path parameter is required");
|
||||
if (!store) {
|
||||
throw new ApiError(500, "Settings store unavailable");
|
||||
}
|
||||
|
||||
const parsed = parseProviderFromBody({ ...req.body, id: providerId });
|
||||
const modelsPath = getFusionModelsPath();
|
||||
const file = await readModelsFile(modelsPath);
|
||||
if (!file.providers[providerId]) {
|
||||
const providerId = String(req.params.id ?? "").trim();
|
||||
if (!providerId) {
|
||||
throw badRequest("id path parameter is required");
|
||||
}
|
||||
|
||||
const updates = parseUpdateBody(req.body);
|
||||
const settings = await store.getGlobalSettingsStore().getSettings();
|
||||
const providers = settings.customProviders ?? [];
|
||||
const targetIndex = providers.findIndex((provider) => provider.id === providerId);
|
||||
|
||||
if (targetIndex < 0) {
|
||||
throw notFound(`custom provider '${providerId}' not found`);
|
||||
}
|
||||
|
||||
file.providers[providerId] = {
|
||||
name: parsed.name,
|
||||
baseUrl: parsed.baseUrl,
|
||||
api: parsed.api,
|
||||
apiKey: parsed.apiKey,
|
||||
models: parsed.models,
|
||||
const updatedProvider: CustomProvider = {
|
||||
...providers[targetIndex],
|
||||
...updates,
|
||||
};
|
||||
await writeModelsFile(modelsPath, file);
|
||||
options?.modelRegistry?.refresh();
|
||||
|
||||
res.json({ provider: { id: providerId, ...file.providers[providerId] } });
|
||||
const nextProviders = [...providers];
|
||||
nextProviders[targetIndex] = updatedProvider;
|
||||
await store.updateGlobalSettings({ customProviders: nextProviders });
|
||||
|
||||
res.json(sanitizeProvider(updatedProvider));
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/custom-providers/:id", async (req, res) => {
|
||||
try {
|
||||
const providerId = String(req.params.id ?? "").trim();
|
||||
if (!providerId) throw badRequest("id path parameter is required");
|
||||
if (!store) {
|
||||
throw new ApiError(500, "Settings store unavailable");
|
||||
}
|
||||
|
||||
const modelsPath = getFusionModelsPath();
|
||||
const file = await readModelsFile(modelsPath);
|
||||
if (!file.providers[providerId]) {
|
||||
const providerId = String(req.params.id ?? "").trim();
|
||||
if (!providerId) {
|
||||
throw badRequest("id path parameter is required");
|
||||
}
|
||||
|
||||
const settings = await store.getGlobalSettingsStore().getSettings();
|
||||
const providers = settings.customProviders ?? [];
|
||||
const exists = providers.some((provider) => provider.id === providerId);
|
||||
|
||||
if (!exists) {
|
||||
throw notFound(`custom provider '${providerId}' not found`);
|
||||
}
|
||||
|
||||
delete file.providers[providerId];
|
||||
await writeModelsFile(modelsPath, file);
|
||||
options?.modelRegistry?.refresh();
|
||||
|
||||
res.status(204).end();
|
||||
const nextProviders = providers.filter((provider) => provider.id !== providerId);
|
||||
await store.updateGlobalSettings({ customProviders: nextProviders });
|
||||
res.json({ success: true });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user