diff --git a/.changeset/fn-7263-custom-provider-model-refresh.md b/.changeset/fn-7263-custom-provider-model-refresh.md new file mode 100644 index 0000000000..a8ba79e042 --- /dev/null +++ b/.changeset/fn-7263-custom-provider-model-refresh.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Refresh custom provider model lists at startup and from Settings. +category: feature +dev: Adds persisted custom-provider model refresh routes and startup best-effort refresh for dashboard, serve, and daemon. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index caa420fdff..836aa04fbd 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -269,7 +269,7 @@ The custom-provider form uses these fields: - **API key** — optional credential for providers that require authentication. - **Available models** — comma-separated model IDs, for example `gpt-4, gpt-3.5-turbo`. -Use **Detect Models** to auto-fill **Available models** from the provider's `/models` endpoint. Detection requires a **Base URL** and may require an **API key**, depending on the provider. +Use **Detect Models** to auto-fill **Available models** while adding or editing a provider from the provider's `/models` endpoint. Detection requires a **Base URL** and may require an **API key**, depending on the provider. Saved providers also have a row-level **Refresh Models** action that uses the stored endpoint and credential to replace the persisted model list without exposing the raw key in the browser. ### Add a custom provider @@ -292,10 +292,10 @@ Expected outcome: the provider appears in the Custom Providers list with its API 1. Open **Settings → Authentication → Custom Providers** and expand **Advanced: Custom Providers**. 2. Find the provider in the list and select its pencil **Edit** action. 3. Update **Provider name**, **API type**, **Base URL**, **API key**, or **Available models** as needed. -4. Select **Detect Models** again if you want to refresh or add model IDs from the provider's `/models` endpoint. +4. Select **Detect Models** again if you want to refresh or add model IDs from the provider's `/models` endpoint before saving. 5. Select **Save Changes**. -Expected outcome: the provider list refreshes, and model dropdowns use the updated model list. If you rename the provider or change model IDs, update any **Project Models** or workflow model lane selections that should use the new `{provider}/{modelId}` value. +Expected outcome: the provider list refreshes, and model dropdowns use the updated model list. If you only need to refresh a saved provider's models after credentials, endpoints, or upstream availability changed, select the row-level **Refresh Models** action instead; failures keep the previous model list intact. If you rename the provider or change model IDs, update any **Project Models** or workflow model lane selections that should use the new `{provider}/{modelId}` value. ### Delete a custom provider diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 9fc007ad54..fb85b27de7 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -78,7 +78,7 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio | `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, OpenAI Responses API (`apiType: "openai-responses"`), Anthropic-compatible, or Google Generative AI (`apiType: "google-generative-ai"`) providers used by the custom-provider API (`/api/custom-providers`). Each entry uses `{ id, name, apiType, baseUrl, apiKey?, supportsDeveloperRole?, models? }`; `supportsDeveloperRole` is an OpenAI-compatible opt-in that enables `developer` role emission (default/omitted is `false`, forcing safe `system` role). API keys are stored raw but masked in API responses. Fusion resolves these providers from the active global settings directory (`~/.fusion`, with legacy `~/.pi/fusion` and `~/.pi/kb` migration support) so custom-provider models remain available after restart. | +| `customProviders` | `CustomProvider[]` | `[]` | User-defined OpenAI-compatible, OpenAI Responses API (`apiType: "openai-responses"`), Anthropic-compatible, or Google Generative AI (`apiType: "google-generative-ai"`) providers used by the custom-provider API (`/api/custom-providers`). Each entry uses `{ id, name, apiType, baseUrl, apiKey?, supportsDeveloperRole?, models? }`; `supportsDeveloperRole` is an OpenAI-compatible opt-in that enables `developer` role emission (default/omitted is `false`, forcing safe `system` role). API keys are stored raw but masked in API responses. Fusion resolves these providers from the active global settings directory (`~/.fusion`, with legacy `~/.pi/fusion` and `~/.pi/kb` migration support) so custom-provider models remain available after restart. Dashboard, serve, and daemon startup refresh each configured provider's persisted `models` list from its `/models` endpoint on a best-effort basis; failures leave the previous list intact and do not block startup. In Settings → Authentication → Advanced: Custom Providers, use **Refresh Models** on a provider row to manually refresh that provider after changing credentials, endpoints, or upstream model availability. Saved local/LAN/internal provider URLs are eligible for this stored-provider refresh path, while the add/edit **Detect Models** form keeps stricter SSRF protections for untrusted one-off input. | | `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. | diff --git a/packages/cli/src/commands/__tests__/daemon.test.ts b/packages/cli/src/commands/__tests__/daemon.test.ts index ea17ee0ab1..7313b39174 100644 --- a/packages/cli/src/commands/__tests__/daemon.test.ts +++ b/packages/cli/src/commands/__tests__/daemon.test.ts @@ -76,6 +76,11 @@ const mocks = vi.hoisted(() => { getRootDir: vi.fn().mockReturnValue("/repo"), getMissionStore: vi.fn().mockReturnValue(missionStore), getPluginStore: vi.fn().mockReturnValue(pluginStore), + getGlobalSettingsStore: vi.fn(() => globalSettingsStoreInstance), + updateGlobalSettings: vi.fn().mockImplementation((settings: Record) => { + globalSettingsData = { ...globalSettingsData, ...settings }; + return Promise.resolve(globalSettingsData); + }), getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, recycleWorktrees: false, @@ -343,6 +348,8 @@ const mocks = vi.hoisted(() => { refresh: vi.fn(), }; + const refreshAllCustomProviderModels = vi.fn().mockResolvedValue({ refreshed: 0, failed: 0, skipped: 0 }); + const agentSemaphoreCtor = vi.fn().mockImplementation(function () { return { _active: 0, @@ -492,6 +499,7 @@ const mocks = vi.hoisted(() => { processAndAuditInsightExtractionMock, authStorage, modelRegistry, + refreshAllCustomProviderModels, reset() { taskStores.length = 0; automationStores.length = 0; @@ -515,6 +523,10 @@ const mocks = vi.hoisted(() => { syncInsightExtractionAutomationMock.mockResolvedValue(undefined); processAndAuditInsightExtractionMock.mockClear(); createAiPromptExecutorMock.mockClear(); + refreshAllCustomProviderModels.mockReset(); + refreshAllCustomProviderModels.mockResolvedValue({ refreshed: 0, failed: 0, skipped: 0 }); + globalSettingsStoreInstance.getSettings.mockReset(); + globalSettingsStoreInstance.getSettings.mockImplementation(() => Promise.resolve({ ...globalSettingsData })); }, }; }); @@ -558,6 +570,7 @@ vi.mock("@fusion/dashboard", () => ({ createSkillsAdapter: vi.fn().mockReturnValue(undefined), getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"), loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined), + refreshAllCustomProviderModels: mocks.refreshAllCustomProviderModels, })); vi.mock("@fusion/engine", async (importOriginal) => { @@ -694,6 +707,52 @@ describe("runDaemon", () => { await triggerSignal("SIGINT"); }); + + it("starts daemon before background custom provider refresh settles", async () => { + mocks.refreshAllCustomProviderModels.mockImplementationOnce(() => new Promise(() => undefined)); + mocks.globalSettingsStoreInstance.getSettings.mockResolvedValue({ + customProviders: [{ + id: "cp-1", + name: "Custom Proxy", + apiType: "openai-compatible", + baseUrl: "https://proxy.example.com/v1", + models: [{ id: "configured-model", name: "Configured model" }], + }], + }); + + await runDaemon({}); + + expect(mocks.refreshAllCustomProviderModels).toHaveBeenCalledTimes(1); + expect(mocks.modelRegistry.registerProvider).toHaveBeenCalledWith( + expect.stringContaining("custom-proxy"), + expect.objectContaining({ models: [expect.objectContaining({ id: "configured-model" })] }), + ); + + await triggerSignal("SIGINT"); + }); + + it("continues startup provider registration when custom provider refresh fails", async () => { + mocks.refreshAllCustomProviderModels.mockRejectedValueOnce(new Error("provider offline")); + mocks.globalSettingsStoreInstance.getSettings.mockResolvedValue({ + customProviders: [{ + id: "cp-1", + name: "Custom Proxy", + apiType: "openai-compatible", + baseUrl: "https://proxy.example.com/v1", + models: [{ id: "configured-model", name: "Configured model" }], + }], + }); + + await runDaemon({}); + + expect(mocks.refreshAllCustomProviderModels).toHaveBeenCalledTimes(1); + expect(mocks.modelRegistry.registerProvider).toHaveBeenCalledWith( + expect.stringContaining("custom-proxy"), + expect.objectContaining({ models: [expect.objectContaining({ id: "configured-model" })] }), + ); + + await triggerSignal("SIGINT"); + }); const originalCwd = process.cwd; const originalExit = process.exit; diff --git a/packages/cli/src/commands/__tests__/dashboard.test.ts b/packages/cli/src/commands/__tests__/dashboard.test.ts index 3065333344..0fa61a3439 100644 --- a/packages/cli/src/commands/__tests__/dashboard.test.ts +++ b/packages/cli/src/commands/__tests__/dashboard.test.ts @@ -58,6 +58,7 @@ const { mockGlobalSettingsUpdateSettings, mockDaemonTokenGetOrCreate, mockGetCliPackageVersion, + mockRefreshAllCustomProviderModels, } = vi.hoisted(() => { delete process.env.FUSION_DASHBOARD_TOKEN; delete process.env.FUSION_DAEMON_TOKEN; @@ -83,6 +84,7 @@ const { mockGlobalSettingsUpdateSettings: vi.fn().mockResolvedValue({}), mockDaemonTokenGetOrCreate: vi.fn().mockResolvedValue("fn_test_dashboard_token"), mockGetCliPackageVersion: vi.fn(), + mockRefreshAllCustomProviderModels: vi.fn().mockResolvedValue({ refreshed: 0, failed: 0, skipped: 0 }), }; }); @@ -369,6 +371,7 @@ vi.mock("@fusion/dashboard", () => ({ getCliPackageVersion: mockGetCliPackageVersion, getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"), loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined), + refreshAllCustomProviderModels: mockRefreshAllCustomProviderModels, stopAllDevServers: vi.fn().mockResolvedValue(undefined), })); @@ -866,6 +869,52 @@ describe("runDashboard — startup model sync", () => { })); expect(mockModelRegistry.refresh).toHaveBeenCalled(); }); + + it("starts dashboard before background custom provider refresh settles", async () => { + mockRefreshAllCustomProviderModels.mockImplementationOnce(() => new Promise(() => undefined)); + mockGlobalSettingsGetSettings.mockResolvedValue({ + customProviders: [{ + id: "cp-1", + name: "Custom Proxy", + apiType: "openai-compatible", + baseUrl: "https://proxy.example.com/v1", + models: [{ id: "configured-model", name: "Configured model" }], + }], + }); + + await runDashboard(0, { open: false }); + + expect(mockRefreshAllCustomProviderModels).toHaveBeenCalledTimes(1); + expect(mockRefreshAllCustomProviderModels).toHaveBeenCalledWith(expect.any(Object), expect.any(Function)); + expect(mockModelRegistry.registerProvider).toHaveBeenCalledWith( + expect.stringContaining("custom-proxy"), + expect.objectContaining({ + baseUrl: "https://proxy.example.com/v1", + models: [expect.objectContaining({ id: "configured-model", name: "Configured model" })], + }), + ); + }); + + it("continues registering custom providers when startup refresh fails", async () => { + mockRefreshAllCustomProviderModels.mockRejectedValueOnce(new Error("provider offline")); + mockGlobalSettingsGetSettings.mockResolvedValue({ + customProviders: [{ + id: "cp-1", + name: "Custom Proxy", + apiType: "openai-compatible", + baseUrl: "https://proxy.example.com/v1", + models: [{ id: "configured-model", name: "Configured model" }], + }], + }); + + await runDashboard(0, { open: false }); + + expect(mockRefreshAllCustomProviderModels).toHaveBeenCalledTimes(1); + expect(mockModelRegistry.registerProvider).toHaveBeenCalledWith( + expect.stringContaining("custom-proxy"), + expect.objectContaining({ models: [expect.objectContaining({ id: "configured-model" })] }), + ); + }); }); function resetGitHubMocks() { @@ -960,6 +1009,8 @@ beforeEach(() => { mockDaemonTokenGetOrCreate.mockResolvedValue("fn_test_dashboard_token"); mockGetCliPackageVersion.mockReset(); mockGetCliPackageVersion.mockReturnValue(CLI_PACKAGE_VERSION); + mockRefreshAllCustomProviderModels.mockReset(); + mockRefreshAllCustomProviderModels.mockResolvedValue({ refreshed: 0, failed: 0, skipped: 0 }); }); afterEach(() => { diff --git a/packages/cli/src/commands/__tests__/serve.test.ts b/packages/cli/src/commands/__tests__/serve.test.ts index 7de0d678eb..a27e89bbca 100644 --- a/packages/cli/src/commands/__tests__/serve.test.ts +++ b/packages/cli/src/commands/__tests__/serve.test.ts @@ -95,6 +95,7 @@ const mocks = vi.hoisted(() => { const pluginLoaderInstances: any[] = []; const projectEngineInstances: any[] = []; const listenCalls: ListenCall[] = []; + const globalSettingsGetSettings = vi.fn().mockResolvedValue({}); function createTaskStoreMock(projectId = "") { const emitter = new EventEmitter(); @@ -110,8 +111,9 @@ const mocks = vi.hoisted(() => { getRootDir: vi.fn().mockReturnValue(`/repo${projectId ? `/${projectId}` : ""}`), getFusionDir: vi.fn().mockReturnValue(`/repo${projectId ? `/${projectId}` : ""}/.fusion`), getGlobalSettingsStore: vi.fn(() => ({ - getSettings: vi.fn().mockResolvedValue({}), + getSettings: globalSettingsGetSettings, })), + updateGlobalSettings: vi.fn().mockResolvedValue({}), getMissionStore: vi.fn().mockReturnValue(missionStore), getPluginStore: vi.fn().mockReturnValue(pluginStore), getSettings: vi.fn().mockResolvedValue({ @@ -378,6 +380,8 @@ const mocks = vi.hoisted(() => { refresh: vi.fn(), }; + const refreshAllCustomProviderModels = vi.fn().mockResolvedValue({ refreshed: 0, failed: 0, skipped: 0 }); + const agentSemaphoreCtor = vi.fn().mockImplementation(function () { return { _active: 0, @@ -555,6 +559,8 @@ const mocks = vi.hoisted(() => { processAndAuditInsightExtractionMock, authStorage, modelRegistry, + refreshAllCustomProviderModels, + globalSettingsGetSettings, reset() { taskStores.length = 0; automationStores.length = 0; @@ -577,6 +583,10 @@ const mocks = vi.hoisted(() => { syncInsightExtractionAutomationMock.mockResolvedValue(undefined); processAndAuditInsightExtractionMock.mockClear(); createAiPromptExecutorMock.mockClear(); + refreshAllCustomProviderModels.mockReset(); + refreshAllCustomProviderModels.mockResolvedValue({ refreshed: 0, failed: 0, skipped: 0 }); + globalSettingsGetSettings.mockReset(); + globalSettingsGetSettings.mockResolvedValue({}); // Reset multi-project state engineUsageLog.length = 0; getProjectByPathResolver = null; @@ -620,6 +630,7 @@ vi.mock("@fusion/dashboard", () => ({ createSkillsAdapter: vi.fn().mockReturnValue(undefined), getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"), loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined), + refreshAllCustomProviderModels: mocks.refreshAllCustomProviderModels, })); vi.mock("@fusion/engine", async (importOriginal) => { @@ -764,6 +775,52 @@ describe("runServe", () => { await triggerSignal("SIGINT"); }); + + it("starts serving before background custom provider refresh settles", async () => { + mocks.refreshAllCustomProviderModels.mockImplementationOnce(() => new Promise(() => undefined)); + mocks.globalSettingsGetSettings.mockResolvedValue({ + customProviders: [{ + id: "cp-1", + name: "Custom Proxy", + apiType: "openai-compatible", + baseUrl: "https://proxy.example.com/v1", + models: [{ id: "configured-model", name: "Configured model" }], + }], + }); + + await runServe(0, {}); + + expect(mocks.refreshAllCustomProviderModels).toHaveBeenCalledTimes(1); + expect(mocks.modelRegistry.registerProvider).toHaveBeenCalledWith( + expect.stringContaining("custom-proxy"), + expect.objectContaining({ models: [expect.objectContaining({ id: "configured-model" })] }), + ); + + await triggerSignal("SIGINT"); + }); + + it("continues startup provider registration when custom provider refresh fails", async () => { + mocks.refreshAllCustomProviderModels.mockRejectedValueOnce(new Error("provider offline")); + mocks.globalSettingsGetSettings.mockResolvedValue({ + customProviders: [{ + id: "cp-1", + name: "Custom Proxy", + apiType: "openai-compatible", + baseUrl: "https://proxy.example.com/v1", + models: [{ id: "configured-model", name: "Configured model" }], + }], + }); + + await runServe(0, {}); + + expect(mocks.refreshAllCustomProviderModels).toHaveBeenCalledTimes(1); + expect(mocks.modelRegistry.registerProvider).toHaveBeenCalledWith( + expect.stringContaining("custom-proxy"), + expect.objectContaining({ models: [expect.objectContaining({ id: "configured-model" })] }), + ); + + await triggerSignal("SIGINT"); + }); const originalCwd = process.cwd; const originalOn = process.on; const originalExit = process.exit; diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index a3d59f7273..f0c72701c4 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -25,7 +25,7 @@ import { registerBuiltInZaiProvider, } from "@fusion/core"; import type { AutomationRunResult, ScheduledTask } from "@fusion/core"; -import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv, registerGithubTrackingHook } from "@fusion/dashboard"; +import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv, refreshAllCustomProviderModels, registerGithubTrackingHook } from "@fusion/dashboard"; import { ProjectEngineManager, PeerExchangeService, @@ -76,6 +76,7 @@ import { getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-pa import { resolveProject } from "../project-context.js"; import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js"; import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js"; +import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js"; import { ensureCwdProjectRegistered } from "./ensure-project-registered.js"; const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes @@ -665,6 +666,18 @@ export async function runDaemon(opts: DaemonOptions = {}) { extensionsResult.runtime.pendingProviderRegistrations = []; mergeBuiltInZaiProviderModels(modelRegistry, (message) => console.log(`[extensions] ${message}`)); modelRegistry.refresh(); + + try { + const globalSettings = await store.getGlobalSettingsStore().getSettings(); + registerCustomProviders( + modelRegistry, + globalSettings.customProviders, + (message) => console.log(`[custom-providers] ${message}`), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[custom-providers] Failed to load custom providers from global settings: ${message}`); + } } catch (error) { const message = error instanceof Error ? error.message : String(error); console.log(`[extensions] Failed to discover extensions: ${message}`); @@ -679,6 +692,21 @@ export async function runDaemon(opts: DaemonOptions = {}) { log: (scope, message) => console.log(`[${scope}] ${message}`), }); + store.on("settings:updated", ({ settings, previous }) => { + const currentProviders = settings.customProviders; + const previousProviders = previous.customProviders; + if (JSON.stringify(currentProviders ?? []) === JSON.stringify(previousProviders ?? [])) { + return; + } + + reregisterCustomProviders( + modelRegistry, + previousProviders, + currentProviders, + (message) => console.log(`[custom-providers] ${message}`), + ); + }); + // ── Skills adapter for skills discovery and execution toggling ───────────── const skillsAdapter = packageManager ? createSkillsAdapter({ @@ -801,6 +829,15 @@ export async function runDaemon(opts: DaemonOptions = {}) { const actualPort = (server.address() as AddressInfo).port; + /* + FNXC:CustomProviders 2026-06-30-00:00: + Daemon startup must not wait on custom-provider model probes because offline provider endpoints can take one timeout each. Start the refresh after listen and let settings:updated reconcile the model registry when persisted models change. + */ + void refreshAllCustomProviderModels(store, (message) => console.log(`[custom-providers] ${message}`)).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[custom-providers] Failed to refresh custom provider models from global settings: ${message}`); + }); + // ── CentralCore: node registration ──────────────────────────────────── let centralCore: CentralCore | null = sharedCentralCore; if (!centralCore) { diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 926880b145..8e81be4700 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -28,6 +28,7 @@ import { } from "@fusion/core"; import { createServer, + refreshAllCustomProviderModels, AttachTicketStore, CliInputAttributionLog, CliConfirmAdvanceRegistry, @@ -2411,6 +2412,15 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: server.on("listening", async () => { const actualPort = (server.address() as AddressInfo).port; + /* + FNXC:CustomProviders 2026-06-30-00:00: + Custom provider startup refresh probes user-configured endpoints and can wait on unreachable networks. Kick it off only after the HTTP server is listening so dashboard availability is not gated by per-provider /models timeouts; settings updates re-register refreshed models when the background write lands. + */ + void refreshAllCustomProviderModels(store, (message) => logSink.log(message, "custom-providers")).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + logSink.warn(`Failed to refresh custom provider models from global settings: ${message}`, "custom-providers"); + }); + if (actualPort !== selectedPort) { logSink.warn(`Port ${selectedPort} in use, using ${actualPort} instead`, "dashboard"); } diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index ce74335300..441f4d8a28 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -25,7 +25,7 @@ import { registerBuiltInZaiProvider, } from "@fusion/core"; import type { AutomationRunResult, ScheduledTask } from "@fusion/core"; -import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv, registerGithubTrackingHook } from "@fusion/dashboard"; +import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv, refreshAllCustomProviderModels, registerGithubTrackingHook } from "@fusion/dashboard"; import { ProjectEngineManager, PeerExchangeService, @@ -908,6 +908,15 @@ export async function runServe( const actualPort = (server.address() as AddressInfo).port; + /* + FNXC:CustomProviders 2026-06-30-00:00: + Headless serve must become reachable before custom-provider /models probes run. Refresh in the background after listen, then rely on the settings:updated listener to re-register refreshed model lists without delaying startup on slow or unreachable endpoints. + */ + void refreshAllCustomProviderModels(store, (message) => console.log(`[custom-providers] ${message}`)).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[custom-providers] Failed to refresh custom provider models from global settings: ${message}`); + }); + // ── mDNS discovery: broadcast presence and listen for other nodes ─────── // // Advertises this node on the local network and discovers other Fusion nodes diff --git a/packages/dashboard/app/__tests__/api-custom-providers.test.ts b/packages/dashboard/app/__tests__/api-custom-providers.test.ts new file mode 100644 index 0000000000..53fa865260 --- /dev/null +++ b/packages/dashboard/app/__tests__/api-custom-providers.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { refreshProviderModels } from "../api"; +import { clearAuthToken } from "../auth"; + +function mockFetchResponse(body: unknown): Promise { + return Promise.resolve({ + ok: true, + status: 200, + statusText: "OK", + headers: { get: () => "application/json" }, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + } as unknown as Response); +} + +describe("custom provider API wrappers", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + clearAuthToken(); + }); + + it("posts to the per-provider model refresh endpoint", async () => { + const response = { + provider: { + id: "provider/id", + name: "Provider", + apiType: "openai-compatible", + baseUrl: "https://api.example.com/v1", + models: [{ id: "fresh-model", name: "Fresh model" }], + }, + modelsRefreshed: 1, + }; + globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(response)); + + await expect(refreshProviderModels("provider/id")).resolves.toEqual(response); + expect(globalThis.fetch).toHaveBeenCalledWith( + "/api/custom-providers/provider%2Fid/refresh-models", + expect.objectContaining({ method: "POST" }), + ); + }); +}); diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index ec51ebdd1a..a14d16bec2 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -2239,6 +2239,17 @@ export function deleteCustomProvider(id: string): Promise<{ success: boolean }> }); } +export interface RefreshProviderModelsResponse { + provider: CustomProvider; + modelsRefreshed: number; +} + +export function refreshProviderModels(id: string): Promise { + return api(`/custom-providers/${encodeURIComponent(id)}/refresh-models`, { + method: "POST", + }); +} + // Backward-compatibility exports for existing UI callers; will be removed when // custom-provider UI migrates to the new core CustomProvider contract. export interface CustomProviderModelInput { diff --git a/packages/dashboard/app/components/CustomProvidersSection.css b/packages/dashboard/app/components/CustomProvidersSection.css index 08040f06db..b8de2310dd 100644 --- a/packages/dashboard/app/components/CustomProvidersSection.css +++ b/packages/dashboard/app/components/CustomProvidersSection.css @@ -57,8 +57,41 @@ .custom-provider-item-actions { display: flex; + align-items: center; gap: var(--space-xs); flex-shrink: 0; + flex-wrap: wrap; + justify-content: flex-end; +} + +.custom-provider-refresh-btn { + white-space: nowrap; +} + +.custom-provider-detect-actions { + display: flex; + align-items: center; + gap: var(--space-sm); + margin-top: var(--space-xs); +} + +.custom-provider-row-message { + display: flex; + align-items: center; + gap: var(--space-xs); + margin-top: var(--space-xs); + padding: var(--space-xs) var(--space-sm); + border-radius: var(--radius-sm); +} + +.custom-provider-row-message--success { + color: var(--color-success); + background: color-mix(in srgb, var(--color-success) 10%, transparent); +} + +.custom-provider-row-message--error { + color: var(--color-error); + background: color-mix(in srgb, var(--color-error) 10%, transparent); } .custom-provider-badge { @@ -132,7 +165,13 @@ } .custom-provider-item-actions { - align-self: flex-end; + align-self: stretch; + justify-content: flex-start; margin-top: var(--space-xs); } + + .custom-provider-refresh-btn { + flex: 1 1 auto; + justify-content: center; + } } diff --git a/packages/dashboard/app/components/CustomProvidersSection.tsx b/packages/dashboard/app/components/CustomProvidersSection.tsx index 09b1995c1a..d86da7baa6 100644 --- a/packages/dashboard/app/components/CustomProvidersSection.tsx +++ b/packages/dashboard/app/components/CustomProvidersSection.tsx @@ -5,10 +5,11 @@ import { deleteCustomProvider, fetchCustomProviders, probeProviderModels, + refreshProviderModels, updateCustomProvider, type CustomProvider, } from "../api"; -import { AlertCircle, Loader2, Pencil, Plus, Search, Trash2 } from "lucide-react"; +import { AlertCircle, Loader2, Pencil, Plus, RefreshCw, Search, Trash2 } from "lucide-react"; import { OnboardingDisclosure } from "./OnboardingDisclosure"; import "./CustomProvidersSection.css"; @@ -82,6 +83,8 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C const [formError, setFormError] = useState(null); const [detecting, setDetecting] = useState(false); const [detectError, setDetectError] = useState(null); + const [refreshingProviderId, setRefreshingProviderId] = useState(null); + const [refreshStatus, setRefreshStatus] = useState<{ providerId: string; type: "success" | "error"; message: string } | null>(null); const loadProviders = useCallback(async () => { setLoading(true); @@ -274,6 +277,46 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C [loadProviders, onProviderChange, t], ); + const handleRefreshProviderModels = useCallback( + async (provider: CustomProvider) => { + setRefreshingProviderId(provider.id); + setRefreshStatus(null); + setError(null); + try { + const result = await refreshProviderModels(provider.id); + setProviders((current) => current.map((candidate) => ( + candidate.id === provider.id ? result.provider : candidate + ))); + if (editingProvider?.id === provider.id) { + /* + FNXC:CustomProviders 2026-06-30-00:00: + Manual refresh can run while a provider edit form is open. Keep that form's model input synchronized with the persisted refresh result so saving unrelated edits cannot overwrite newly discovered models with the pre-refresh list. + */ + const refreshedModels = (result.provider.models ?? []).map((model) => model.id).join(", "); + setModels(refreshedModels); + setEditingProvider((current) => current?.id === provider.id + ? { ...current, models: result.provider.models ?? [] } + : current); + } + onProviderChange?.(); + setRefreshStatus({ + providerId: provider.id, + type: "success", + message: t("providers.refreshModelsSuccess", "Refreshed {{count}} model(s).", { count: result.modelsRefreshed }), + }); + } catch (refreshError) { + setRefreshStatus({ + providerId: provider.id, + type: "error", + message: refreshError instanceof Error ? refreshError.message : t("providers.refreshModelsFailed", "Failed to refresh models."), + }); + } finally { + setRefreshingProviderId(null); + } + }, + [editingProvider?.id, onProviderChange, t], + ); + const sectionContent = ( <> {embedded ? null : loading ? ( @@ -294,6 +337,8 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
{providers.map((provider) => { const isEditingThisProvider = isFormOpen && editingProvider?.id === provider.id; + const isRefreshingThisProvider = refreshingProviderId === provider.id; + const providerRefreshStatus = refreshStatus?.providerId === provider.id ? refreshStatus : null; return (
@@ -305,6 +350,16 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
+
+ {providerRefreshStatus ? ( +
+ {providerRefreshStatus.type === "error" ?
+ ) : null} + {isEditingThisProvider ? (
@@ -392,7 +454,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C />
-
+
-
+