FN-7263: refresh custom provider model lists
Refresh persisted custom-provider model lists from saved endpoints and expose manual refresh controls. - Add dashboard API helpers and routes to refresh one or all custom-provider model lists while preserving concurrent settings edits. - Start background model refresh from serve, dashboard, and daemon startup paths after the server begins listening. - Add Settings UI refresh actions, row-level status messaging, styles, docs, and regression coverage for refresh behavior. Files changed: .changeset/fn-7263-custom-provider-model-refresh.md | 7 + docs/dashboard-guide.md | 6 +- docs/settings-reference.md | 2 +- packages/cli/src/commands/__tests__/daemon.test.ts | 59 +++++ .../cli/src/commands/__tests__/dashboard.test.ts | 51 ++++ packages/cli/src/commands/__tests__/serve.test.ts | 59 ++++- packages/cli/src/commands/daemon.ts | 39 +++- packages/cli/src/commands/dashboard.ts | 10 + packages/cli/src/commands/serve.ts | 11 +- .../app/__tests__/api-custom-providers.test.ts | 43 ++++ packages/dashboard/app/api/legacy.ts | 11 + .../app/components/CustomProvidersSection.css | 41 +++- .../app/components/CustomProvidersSection.tsx | 68 +++++- .../__tests__/CustomProvidersSection.test.tsx | 227 ++++++++++++++++++ packages/dashboard/src/index.ts | 6 + .../__tests__/custom-provider-routes.test.ts | 255 ++++++++++++++++++++ .../src/routes/__tests__/custom-providers.test.ts | 12 + .../src/routes/register-custom-provider-routes.ts | 259 ++++++++++++++++----- 18 files changed, 1101 insertions(+), 65 deletions(-) Fusion-Task-Id: FN-7263 Fusion-Task-Lineage: 60ee0637-ac85-409f-a376-c01f4bc8e8c5 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7263-custom-provider-model-refresh.md
Normal file
7
.changeset/fn-7263-custom-provider-model-refresh.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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[]` | `[]` | <a id="customproviders"></a>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[]` | `[]` | <a id="customproviders"></a>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. |
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Response> {
|
||||
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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<RefreshProviderModelsResponse> {
|
||||
return api<RefreshProviderModelsResponse>(`/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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const [detectError, setDetectError] = useState<string | null>(null);
|
||||
const [refreshingProviderId, setRefreshingProviderId] = useState<string | null>(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
|
||||
<div className="custom-provider-list">
|
||||
{providers.map((provider) => {
|
||||
const isEditingThisProvider = isFormOpen && editingProvider?.id === provider.id;
|
||||
const isRefreshingThisProvider = refreshingProviderId === provider.id;
|
||||
const providerRefreshStatus = refreshStatus?.providerId === provider.id ? refreshStatus : null;
|
||||
|
||||
return (
|
||||
<div key={provider.id}>
|
||||
@@ -305,6 +350,16 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
</div>
|
||||
</div>
|
||||
<div className="custom-provider-item-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm custom-provider-refresh-btn"
|
||||
onClick={() => void handleRefreshProviderModels(provider)}
|
||||
disabled={isRefreshingThisProvider}
|
||||
aria-label={t("providers.refreshModelsLabel", "Refresh models for {{name}}", { name: provider.name })}
|
||||
>
|
||||
{isRefreshingThisProvider ? <Loader2 aria-hidden="true" className="custom-provider-spin" /> : <RefreshCw aria-hidden="true" />}
|
||||
<span>{isRefreshingThisProvider ? t("providers.refreshingModels", "Refreshing…") : t("providers.refreshModels", "Refresh Models")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm"
|
||||
@@ -324,6 +379,13 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{providerRefreshStatus ? (
|
||||
<div className={`custom-provider-row-message custom-provider-row-message--${providerRefreshStatus.type}`} role={providerRefreshStatus.type === "error" ? "alert" : "status"}>
|
||||
{providerRefreshStatus.type === "error" ? <AlertCircle aria-hidden="true" /> : null}
|
||||
<span>{providerRefreshStatus.message}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isEditingThisProvider ? (
|
||||
<div className="custom-provider-form custom-provider-item-edit-form">
|
||||
<div className="form-group custom-provider-form-row">
|
||||
@@ -392,7 +454,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", alignItems: "center", marginTop: "4px" }}>
|
||||
<div className="custom-provider-detect-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
@@ -509,7 +571,7 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", alignItems: "center", marginTop: "4px" }}>
|
||||
<div className="custom-provider-detect-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
|
||||
@@ -6,12 +6,16 @@ const mockFetchCustomProviders = vi.fn();
|
||||
const mockAddCustomProvider = vi.fn();
|
||||
const mockUpdateCustomProvider = vi.fn();
|
||||
const mockDeleteCustomProvider = vi.fn();
|
||||
const mockProbeProviderModels = vi.fn();
|
||||
const mockRefreshProviderModels = vi.fn();
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args),
|
||||
addCustomProvider: (...args: unknown[]) => mockAddCustomProvider(...args),
|
||||
updateCustomProvider: (...args: unknown[]) => mockUpdateCustomProvider(...args),
|
||||
deleteCustomProvider: (...args: unknown[]) => mockDeleteCustomProvider(...args),
|
||||
probeProviderModels: (...args: unknown[]) => mockProbeProviderModels(...args),
|
||||
refreshProviderModels: (...args: unknown[]) => mockRefreshProviderModels(...args),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
@@ -20,6 +24,7 @@ vi.mock("lucide-react", () => ({
|
||||
Loader2: ({ className }: { className?: string }) => <svg data-testid="icon-loader" className={className} />,
|
||||
Pencil: () => <svg data-testid="icon-pencil" />,
|
||||
Plus: () => <svg data-testid="icon-plus" />,
|
||||
RefreshCw: () => <svg data-testid="icon-refresh" />,
|
||||
Search: () => <svg data-testid="icon-search" />,
|
||||
Trash2: () => <svg data-testid="icon-trash" />,
|
||||
}));
|
||||
@@ -55,6 +60,17 @@ describe("CustomProvidersSection", () => {
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
mockDeleteCustomProvider.mockResolvedValue({ success: true });
|
||||
mockProbeProviderModels.mockResolvedValue({ models: [], count: 0 });
|
||||
mockRefreshProviderModels.mockResolvedValue({
|
||||
provider: {
|
||||
id: "test-id",
|
||||
name: "Test Provider",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://api.example.com",
|
||||
models: [{ id: "fresh-model", name: "Fresh model" }],
|
||||
},
|
||||
modelsRefreshed: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders collapsed disclosure by default", () => {
|
||||
@@ -349,6 +365,217 @@ describe("CustomProvidersSection", () => {
|
||||
expect(payload).not.toHaveProperty("apiKey");
|
||||
});
|
||||
|
||||
it("renders one refresh button per populated provider and none for an empty list", async () => {
|
||||
mockFetchCustomProviders.mockResolvedValueOnce([
|
||||
{
|
||||
id: "test-id",
|
||||
name: "Test Provider",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://api.example.com",
|
||||
},
|
||||
]);
|
||||
|
||||
const { unmount } = render(<CustomProvidersSection embedded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Refresh models for Test Provider" })).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Refresh models for Test Provider" }).closest(".custom-provider-item-actions")).toBeTruthy();
|
||||
|
||||
unmount();
|
||||
mockFetchCustomProviders.mockResolvedValueOnce([]);
|
||||
render(<CustomProvidersSection />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Advanced: Custom Providers/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No custom providers configured.")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: /Refresh models for/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("refreshes one provider's models and shows a success message", async () => {
|
||||
const onProviderChange = vi.fn();
|
||||
mockFetchCustomProviders.mockResolvedValueOnce([
|
||||
{
|
||||
id: "test-id",
|
||||
name: "Test Provider",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://api.example.com",
|
||||
models: [{ id: "stale-model", name: "Stale model" }],
|
||||
},
|
||||
]);
|
||||
|
||||
render(<CustomProvidersSection embedded onProviderChange={onProviderChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Refresh models for Test Provider" })).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh models for Test Provider" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshProviderModels).toHaveBeenCalledWith("test-id");
|
||||
expect(onProviderChange).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText("Refreshed 1 model(s).")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("disables only the refreshing provider while refresh is pending", async () => {
|
||||
let resolveRefresh: (value: unknown) => void = () => undefined;
|
||||
mockRefreshProviderModels.mockReturnValueOnce(new Promise((resolve) => { resolveRefresh = resolve; }));
|
||||
mockFetchCustomProviders.mockResolvedValueOnce([
|
||||
{
|
||||
id: "test-id",
|
||||
name: "Test Provider",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://api.example.com",
|
||||
},
|
||||
{
|
||||
id: "other-id",
|
||||
name: "Other Provider",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://other.example.com",
|
||||
},
|
||||
]);
|
||||
|
||||
render(<CustomProvidersSection embedded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Refresh models for Test Provider" })).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh models for Test Provider" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Refresh models for Test Provider" })).toHaveAttribute("disabled");
|
||||
expect(screen.getByRole("button", { name: "Refresh models for Other Provider" })).not.toHaveAttribute("disabled");
|
||||
|
||||
resolveRefresh({
|
||||
provider: {
|
||||
id: "test-id",
|
||||
name: "Test Provider",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://api.example.com",
|
||||
models: [{ id: "fresh-model", name: "Fresh model" }],
|
||||
},
|
||||
modelsRefreshed: 1,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Refreshed 1 model(s).")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("syncs an open edit form after refreshing models so save preserves the refreshed list", async () => {
|
||||
mockFetchCustomProviders
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: "test-id",
|
||||
name: "Test Provider",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://api.example.com",
|
||||
apiKey: "sk••••test",
|
||||
models: [{ id: "stale-model", name: "Stale model" }],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: "test-id",
|
||||
name: "Renamed Provider",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://api.example.com",
|
||||
apiKey: "sk••••test",
|
||||
models: [{ id: "fresh-model", name: "Fresh model" }],
|
||||
},
|
||||
]);
|
||||
mockRefreshProviderModels.mockResolvedValueOnce({
|
||||
provider: {
|
||||
id: "test-id",
|
||||
name: "Test Provider",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://api.example.com",
|
||||
apiKey: "sk••••test",
|
||||
models: [{ id: "fresh-model", name: "Fresh model" }],
|
||||
},
|
||||
modelsRefreshed: 1,
|
||||
});
|
||||
|
||||
render(<CustomProvidersSection embedded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Refresh models for Test Provider" })).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Edit Test Provider"));
|
||||
expect(screen.getByLabelText("Available models")).toHaveValue("stale-model");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh models for Test Provider" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Available models")).toHaveValue("fresh-model");
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Provider name"), { target: { value: "Renamed Provider" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateCustomProvider).toHaveBeenCalledWith("test-id", expect.objectContaining({
|
||||
name: "Renamed Provider",
|
||||
models: [{ id: "fresh-model", name: "fresh-model" }],
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it("shows refresh failures without closing the edit form or erasing models", async () => {
|
||||
mockRefreshProviderModels.mockRejectedValueOnce(new Error("provider offline"));
|
||||
mockFetchCustomProviders.mockResolvedValueOnce([
|
||||
{
|
||||
id: "test-id",
|
||||
name: "Test Provider",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://api.example.com",
|
||||
models: [{ id: "stale-model", name: "Stale model" }],
|
||||
},
|
||||
]);
|
||||
|
||||
render(<CustomProvidersSection embedded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Edit Test Provider")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Edit Test Provider"));
|
||||
expect(screen.getByLabelText("Available models")).toHaveValue("stale-model");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh models for Test Provider" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("provider offline")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Save Changes" })).toBeTruthy();
|
||||
expect(screen.getByLabelText("Available models")).toHaveValue("stale-model");
|
||||
});
|
||||
|
||||
it("keeps refresh actions in the reusable mobile-safe action container", async () => {
|
||||
mockFetchCustomProviders.mockResolvedValueOnce([
|
||||
{
|
||||
id: "test-id",
|
||||
name: "Test Provider",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://api.example.com",
|
||||
},
|
||||
]);
|
||||
|
||||
render(<CustomProvidersSection embedded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Refresh models for Test Provider" })).toBeTruthy();
|
||||
});
|
||||
|
||||
const actions = screen.getByRole("button", { name: "Refresh models for Test Provider" }).closest(".custom-provider-item-actions");
|
||||
expect(actions).toBeTruthy();
|
||||
expect(actions?.querySelectorAll("button")).toHaveLength(3);
|
||||
expect(screen.getByRole("button", { name: "Refresh models for Test Provider" })).toHaveTextContent("Refresh Models");
|
||||
});
|
||||
|
||||
it("deletes provider after confirmation", async () => {
|
||||
mockFetchCustomProviders
|
||||
.mockResolvedValueOnce([
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
export { createServer, loadTlsCredentialsFromEnv, type ServerOptions } from "./server.js";
|
||||
export {
|
||||
refreshAllCustomProviderModels,
|
||||
refreshCustomProviderModels,
|
||||
type RefreshAllCustomProviderModelsResult,
|
||||
type RefreshCustomProviderModelsResult,
|
||||
} from "./routes/register-custom-provider-routes.js";
|
||||
export { stopAllDevServers, destroyAllDevServerManagers, getActiveProcessManagers } from "./dev-server-routes.js";
|
||||
export {
|
||||
createRuntimeLogger,
|
||||
|
||||
@@ -109,6 +109,7 @@ describe("custom provider routes", () => {
|
||||
beforeEach(() => {
|
||||
settings = {};
|
||||
mockInvalidateAllGlobalSettingsCaches.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("GET /custom-providers returns empty array when none configured", async () => {
|
||||
@@ -369,4 +370,258 @@ describe("custom provider routes", () => {
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("POST /custom-providers/:id/refresh-models uses stored keys and updates only the selected provider", async () => {
|
||||
settings.customProviders = [
|
||||
{
|
||||
id: "cp-1",
|
||||
name: "OpenAI Proxy",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
apiKey: "sk-stored-secret",
|
||||
models: [{ id: "stale-model", name: "Stale model" }],
|
||||
},
|
||||
{
|
||||
id: "cp-2",
|
||||
name: "Sibling",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://sibling.example.com/v1",
|
||||
apiKey: "sk-sibling-secret",
|
||||
models: [{ id: "sibling-model", name: "Sibling model" }],
|
||||
},
|
||||
];
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: "fresh-model", name: "Fresh model" },
|
||||
{ id: "fresh-model", name: "Duplicate model" },
|
||||
{ id: "embedding-model", name: "Embedding model" },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const updates: Array<Partial<GlobalSettings>> = [];
|
||||
const app = createApp(settings, (patch) => updates.push(patch));
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/custom-providers/cp-1/refresh-models");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.example.com/v1/models",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: "Bearer sk-stored-secret" }),
|
||||
}),
|
||||
);
|
||||
expect(res.body).toEqual({
|
||||
provider: expect.objectContaining({
|
||||
id: "cp-1",
|
||||
apiKey: "sk-•••••cret",
|
||||
models: [{ id: "fresh-model", name: "Fresh model" }],
|
||||
}),
|
||||
modelsRefreshed: 1,
|
||||
});
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0].customProviders).toEqual([
|
||||
expect.objectContaining({ id: "cp-1", models: [{ id: "fresh-model", name: "Fresh model" }] }),
|
||||
expect.objectContaining({ id: "cp-2", models: [{ id: "sibling-model", name: "Sibling model" }] }),
|
||||
]);
|
||||
expect(mockInvalidateAllGlobalSettingsCaches).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("POST /custom-providers/:id/refresh-models allows intentional local provider endpoints", async () => {
|
||||
settings.customProviders = [
|
||||
{
|
||||
id: "cp-local",
|
||||
name: "Local LM Studio",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "http://localhost:1234/v1",
|
||||
apiKey: "local-secret",
|
||||
models: [{ id: "stale-local", name: "Stale local" }],
|
||||
},
|
||||
];
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ data: [{ id: "local-model", name: "Local model" }] }),
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const app = createApp(settings);
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/custom-providers/cp-local/refresh-models");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost:1234/v1/models",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: "Bearer local-secret" }),
|
||||
}),
|
||||
);
|
||||
expect(settings.customProviders?.[0]?.models).toEqual([{ id: "local-model", name: "Local model" }]);
|
||||
});
|
||||
|
||||
it("POST /custom-providers/:id/refresh-models preserves concurrent provider changes made during probing", async () => {
|
||||
settings.customProviders = [
|
||||
{
|
||||
id: "cp-1",
|
||||
name: "OpenAI Proxy",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
apiKey: "sk-stored-secret",
|
||||
models: [{ id: "stale-model", name: "Stale model" }],
|
||||
},
|
||||
];
|
||||
vi.stubGlobal("fetch", vi.fn(async () => {
|
||||
settings.customProviders = [
|
||||
{
|
||||
id: "cp-1",
|
||||
name: "Renamed While Refreshing",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
apiKey: "sk-stored-secret",
|
||||
supportsDeveloperRole: true,
|
||||
models: [{ id: "edited-model", name: "Edited model" }],
|
||||
},
|
||||
{
|
||||
id: "cp-2",
|
||||
name: "Added While Refreshing",
|
||||
apiType: "anthropic-compatible",
|
||||
baseUrl: "https://anthropic.example.com/v1",
|
||||
apiKey: "sk-added-secret",
|
||||
models: [{ id: "added-model", name: "Added model" }],
|
||||
},
|
||||
];
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ data: [{ id: "fresh-model", name: "Fresh model" }] }),
|
||||
};
|
||||
}));
|
||||
const updates: Array<Partial<GlobalSettings>> = [];
|
||||
const app = createApp(settings, (patch) => updates.push(patch));
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/custom-providers/cp-1/refresh-models");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.provider).toEqual(expect.objectContaining({
|
||||
id: "cp-1",
|
||||
name: "Renamed While Refreshing",
|
||||
supportsDeveloperRole: true,
|
||||
models: [{ id: "fresh-model", name: "Fresh model" }],
|
||||
}));
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0].customProviders).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "cp-1",
|
||||
name: "Renamed While Refreshing",
|
||||
supportsDeveloperRole: true,
|
||||
models: [{ id: "fresh-model", name: "Fresh model" }],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "cp-2",
|
||||
name: "Added While Refreshing",
|
||||
models: [{ id: "added-model", name: "Added model" }],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("POST /custom-providers/:id/refresh-models aborts when connection fields change during probing", async () => {
|
||||
settings.customProviders = [
|
||||
{
|
||||
id: "cp-1",
|
||||
name: "OpenAI Proxy",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
apiKey: "sk-stored-secret",
|
||||
models: [{ id: "stale-model", name: "Stale model" }],
|
||||
},
|
||||
];
|
||||
vi.stubGlobal("fetch", vi.fn(async () => {
|
||||
settings.customProviders = [
|
||||
{
|
||||
id: "cp-1",
|
||||
name: "OpenAI Proxy",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://new-api.example.com/v1",
|
||||
apiKey: "sk-new-secret",
|
||||
models: [{ id: "edited-model", name: "Edited model" }],
|
||||
},
|
||||
];
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ data: [{ id: "old-endpoint-model", name: "Old endpoint model" }] }),
|
||||
};
|
||||
}));
|
||||
const updates: Array<Partial<GlobalSettings>> = [];
|
||||
const app = createApp(settings, (patch) => updates.push(patch));
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/custom-providers/cp-1/refresh-models");
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toContain("connection changed during model refresh");
|
||||
expect(updates).toHaveLength(0);
|
||||
expect(settings.customProviders?.[0]).toEqual(expect.objectContaining({
|
||||
baseUrl: "https://new-api.example.com/v1",
|
||||
apiKey: "sk-new-secret",
|
||||
models: [{ id: "edited-model", name: "Edited model" }],
|
||||
}));
|
||||
});
|
||||
|
||||
it("POST /custom-providers/:id/refresh-models preserves models when probing fails", async () => {
|
||||
settings.customProviders = [
|
||||
{
|
||||
id: "cp-1",
|
||||
name: "OpenAI Proxy",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
apiKey: "sk-stored-secret",
|
||||
models: [{ id: "stale-model", name: "Stale model" }],
|
||||
},
|
||||
];
|
||||
vi.stubGlobal("fetch", vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
text: async () => "bad key",
|
||||
})));
|
||||
const updates: Array<Partial<GlobalSettings>> = [];
|
||||
const app = createApp(settings, (patch) => updates.push(patch));
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/custom-providers/cp-1/refresh-models");
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(updates).toHaveLength(0);
|
||||
expect(settings.customProviders?.[0]?.models).toEqual([{ id: "stale-model", name: "Stale model" }]);
|
||||
});
|
||||
|
||||
it("POST /custom-providers/:id/refresh-models preserves models when only non-chat models are returned", async () => {
|
||||
settings.customProviders = [
|
||||
{
|
||||
id: "cp-1",
|
||||
name: "OpenAI Proxy",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
models: [{ id: "stale-model", name: "Stale model" }],
|
||||
},
|
||||
];
|
||||
vi.stubGlobal("fetch", vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ data: [{ id: "text-embedding-3-large" }] }),
|
||||
})));
|
||||
const updates: Array<Partial<GlobalSettings>> = [];
|
||||
const app = createApp(settings, (patch) => updates.push(patch));
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/custom-providers/cp-1/refresh-models");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("No chat models found");
|
||||
expect(updates).toHaveLength(0);
|
||||
expect(settings.customProviders?.[0]?.models).toEqual([{ id: "stale-model", name: "Stale model" }]);
|
||||
});
|
||||
|
||||
it("POST /custom-providers/:id/refresh-models returns 404 for unknown providers", async () => {
|
||||
settings.customProviders = [];
|
||||
const app = createApp(settings);
|
||||
const res = await REQUEST(app, "POST", "/api/custom-providers/missing/refresh-models");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -600,6 +600,18 @@ describe("POST /api/custom-providers/probe-models", () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("keeps SSRF protection for untrusted Detect Models probe input", async () => {
|
||||
const app = setupApp(createCustomProviderStore().store);
|
||||
const res = await doRequest(app, "POST", "/api/custom-providers/probe-models", {
|
||||
baseUrl: "http://localhost:1234/v1",
|
||||
apiType: "openai-compatible",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("loopback or private address");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns error when provider returns non-200", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
|
||||
@@ -159,7 +159,7 @@ function parseCreateBody(body: unknown): Omit<CustomProvider, "id"> {
|
||||
return provider;
|
||||
}
|
||||
|
||||
interface ProbeModelResult {
|
||||
export interface ProbeModelResult {
|
||||
id: string;
|
||||
name: string;
|
||||
reasoning?: boolean;
|
||||
@@ -167,6 +167,22 @@ interface ProbeModelResult {
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
export interface RefreshCustomProviderModelsResult {
|
||||
provider: CustomProvider;
|
||||
modelsRefreshed: number;
|
||||
}
|
||||
|
||||
export interface RefreshAllCustomProviderModelsResult {
|
||||
refreshed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
interface CustomProviderSettingsStore {
|
||||
getGlobalSettingsStore: () => { getSettings: () => Promise<{ customProviders?: CustomProvider[] }> };
|
||||
updateGlobalSettings: (patch: { customProviders: CustomProvider[] }) => Promise<unknown>;
|
||||
}
|
||||
|
||||
const MAX_PROBE_MODELS = 100;
|
||||
|
||||
type ProbeApiType = "openai-compatible" | "anthropic-compatible" | "google-generative-ai" | "openai-responses";
|
||||
@@ -217,10 +233,15 @@ function isNonChatModel(m: Record<string, unknown>): boolean {
|
||||
* Probe a custom provider's /models endpoint to discover available models.
|
||||
* Supports OpenAI-compatible, Anthropic-compatible, and Google Generative AI providers.
|
||||
*/
|
||||
async function probeProviderModels(
|
||||
interface ProbeProviderModelsOptions {
|
||||
allowPrivateAddress?: boolean;
|
||||
}
|
||||
|
||||
export async function probeProviderModels(
|
||||
baseUrl: string,
|
||||
apiKey: string | undefined,
|
||||
apiType: ProbeApiType,
|
||||
options: ProbeProviderModelsOptions = {},
|
||||
): Promise<ProbeModelResult[]> {
|
||||
let url: URL;
|
||||
try {
|
||||
@@ -232,63 +253,68 @@ async function probeProviderModels(
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw badRequest("baseUrl must use http or https");
|
||||
}
|
||||
// SSRF protection: reject private/loopback/link-local hosts
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
if (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1" ||
|
||||
hostname === "[::1]" ||
|
||||
hostname.endsWith(".local") ||
|
||||
hostname.endsWith(".internal")
|
||||
) {
|
||||
throw badRequest("baseUrl must not be a loopback or private address");
|
||||
}
|
||||
// Resolve hostname to IP and check against private ranges.
|
||||
// If resolution fails, let the fetch attempt proceed naturally.
|
||||
try {
|
||||
const resolved = await dns.lookup(hostname, { all: true });
|
||||
const addresses = resolved.map((a) => a.address);
|
||||
for (const addr of addresses) {
|
||||
if (net.isIP(addr) === 0) continue;
|
||||
const parts = addr.split(".").map(Number);
|
||||
if (parts.length === 4 && !Number.isNaN(parts[0])) {
|
||||
// 127.0.0.0/8
|
||||
if (parts[0] === 127) throw badRequest("baseUrl must not be a loopback or private address");
|
||||
// 10.0.0.0/8
|
||||
if (parts[0] === 10) throw badRequest("baseUrl must not be a loopback or private address");
|
||||
// 172.16.0.0/12
|
||||
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) throw badRequest("baseUrl must not be a loopback or private address");
|
||||
// 192.168.0.0/16
|
||||
if (parts[0] === 192 && parts[1] === 168) throw badRequest("baseUrl must not be a loopback or private address");
|
||||
// 169.254.0.0/16 (link-local, includes cloud metadata)
|
||||
if (parts[0] === 169 && parts[1] === 254) throw badRequest("baseUrl must not be a loopback or private address");
|
||||
} else if (net.isIPv6(addr)) {
|
||||
const lower = addr.toLowerCase();
|
||||
// ::1 — IPv6 loopback
|
||||
if (lower === "::1" || lower === "0:0:0:0:0:0:0:1") throw badRequest("baseUrl must not be a loopback or private address");
|
||||
// fc00::/7 — Unique Local Addresses (private, RFC 4193)
|
||||
if (lower.startsWith("fc") || lower.startsWith("fd")) throw badRequest("baseUrl must not be a loopback or private address");
|
||||
// fe80::/10 — link-local addresses
|
||||
if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) throw badRequest("baseUrl must not be a loopback or private address");
|
||||
// ::ffff:0:0/96 — IPv4-mapped IPv6 — extract embedded IPv4 and re-check
|
||||
const ipv4Mapped = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)/);
|
||||
if (ipv4Mapped) {
|
||||
const v4Parts = ipv4Mapped[1].split(".").map(Number);
|
||||
if (v4Parts.length === 4) {
|
||||
if (v4Parts[0] === 127 || v4Parts[0] === 10 ||
|
||||
(v4Parts[0] === 172 && v4Parts[1] >= 16 && v4Parts[1] <= 31) ||
|
||||
(v4Parts[0] === 192 && v4Parts[1] === 168) ||
|
||||
(v4Parts[0] === 169 && v4Parts[1] === 254)) {
|
||||
throw badRequest("baseUrl must not be a loopback or private address");
|
||||
/*
|
||||
* FNXC:CustomProviders 2026-06-30-00:00:
|
||||
* Detect Models accepts untrusted form input, so it keeps SSRF rejection for loopback, LAN, link-local, .local, and .internal hosts. Startup and Settings Refresh Models operate on an already-saved custom provider baseUrl that the user intentionally configured for generation, so they may probe local tools such as LM Studio, Ollama, vLLM, or internal proxies without exposing raw keys to the browser.
|
||||
*/
|
||||
if (!options.allowPrivateAddress) {
|
||||
if (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1" ||
|
||||
hostname === "[::1]" ||
|
||||
hostname.endsWith(".local") ||
|
||||
hostname.endsWith(".internal")
|
||||
) {
|
||||
throw badRequest("baseUrl must not be a loopback or private address");
|
||||
}
|
||||
// Resolve hostname to IP and check against private ranges.
|
||||
// If resolution fails, let the fetch attempt proceed naturally.
|
||||
try {
|
||||
const resolved = await dns.lookup(hostname, { all: true });
|
||||
const addresses = resolved.map((a) => a.address);
|
||||
for (const addr of addresses) {
|
||||
if (net.isIP(addr) === 0) continue;
|
||||
const parts = addr.split(".").map(Number);
|
||||
if (parts.length === 4 && !Number.isNaN(parts[0])) {
|
||||
// 127.0.0.0/8
|
||||
if (parts[0] === 127) throw badRequest("baseUrl must not be a loopback or private address");
|
||||
// 10.0.0.0/8
|
||||
if (parts[0] === 10) throw badRequest("baseUrl must not be a loopback or private address");
|
||||
// 172.16.0.0/12
|
||||
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) throw badRequest("baseUrl must not be a loopback or private address");
|
||||
// 192.168.0.0/16
|
||||
if (parts[0] === 192 && parts[1] === 168) throw badRequest("baseUrl must not be a loopback or private address");
|
||||
// 169.254.0.0/16 (link-local, includes cloud metadata)
|
||||
if (parts[0] === 169 && parts[1] === 254) throw badRequest("baseUrl must not be a loopback or private address");
|
||||
} else if (net.isIPv6(addr)) {
|
||||
const lower = addr.toLowerCase();
|
||||
// ::1 — IPv6 loopback
|
||||
if (lower === "::1" || lower === "0:0:0:0:0:0:0:1") throw badRequest("baseUrl must not be a loopback or private address");
|
||||
// fc00::/7 — Unique Local Addresses (private, RFC 4193)
|
||||
if (lower.startsWith("fc") || lower.startsWith("fd")) throw badRequest("baseUrl must not be a loopback or private address");
|
||||
// fe80::/10 — link-local addresses
|
||||
if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) throw badRequest("baseUrl must not be a loopback or private address");
|
||||
// ::ffff:0:0/96 — IPv4-mapped IPv6 — extract embedded IPv4 and re-check
|
||||
const ipv4Mapped = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)/);
|
||||
if (ipv4Mapped) {
|
||||
const v4Parts = ipv4Mapped[1].split(".").map(Number);
|
||||
if (v4Parts.length === 4) {
|
||||
if (v4Parts[0] === 127 || v4Parts[0] === 10 ||
|
||||
(v4Parts[0] === 172 && v4Parts[1] >= 16 && v4Parts[1] <= 31) ||
|
||||
(v4Parts[0] === 192 && v4Parts[1] === 168) ||
|
||||
(v4Parts[0] === 169 && v4Parts[1] === 254)) {
|
||||
throw badRequest("baseUrl must not be a loopback or private address");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
// DNS resolution failed — proceed without SSRF check; the fetch will fail naturally
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
// DNS resolution failed — proceed without SSRF check; the fetch will fail naturally
|
||||
}
|
||||
|
||||
let modelsUrl: string;
|
||||
@@ -410,6 +436,111 @@ async function probeProviderModels(
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeProviderModels(models: ProbeModelResult[]): ProbeModelResult[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: ProbeModelResult[] = [];
|
||||
for (const model of models) {
|
||||
const id = model.id.trim();
|
||||
if (!id || seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(id);
|
||||
deduped.push({ ...model, id, name: model.name.trim() || id });
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
async function discoverUsableProviderModels(provider: Pick<CustomProvider, "baseUrl" | "apiKey" | "apiType">): Promise<ProbeModelResult[]> {
|
||||
const models = dedupeProviderModels(
|
||||
await probeProviderModels(provider.baseUrl, provider.apiKey, provider.apiType, { allowPrivateAddress: true }),
|
||||
);
|
||||
if (models.length === 0) {
|
||||
throw new ApiError(404, "No chat models found in provider response");
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:CustomProviders 2026-06-29-00:00:
|
||||
* Startup and Settings refreshes share this seam so persisted custom-provider model lists can be updated from the stored provider record while the browser only receives sanitized providers. The refresh must reuse probe SSRF checks, use the raw stored API key, and preserve the previous model list when probing fails or yields no chat models.
|
||||
*/
|
||||
export async function refreshCustomProviderModels(
|
||||
store: CustomProviderSettingsStore,
|
||||
providerId: string,
|
||||
): Promise<RefreshCustomProviderModelsResult> {
|
||||
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`);
|
||||
}
|
||||
|
||||
const targetProvider = providers[targetIndex];
|
||||
const models = await discoverUsableProviderModels(targetProvider);
|
||||
const persistedModels = models.map((model) => ({ id: model.id, name: model.name }));
|
||||
|
||||
/*
|
||||
* FNXC:CustomProviders 2026-06-30-00:00:
|
||||
* Model refresh can be slow because it probes a user-configured endpoint. Re-read settings after discovery and merge only the target provider's models so startup/manual refresh cannot overwrite concurrent provider edits, additions, or deletions made while the probe was in flight.
|
||||
*
|
||||
* FNXC:CustomProviders 2026-06-30-10:24:
|
||||
* The probed connection fields are part of the model-list provenance. If the user edits baseUrl, apiType, or apiKey while a refresh is in flight, abort instead of persisting model IDs discovered from the previous endpoint onto the updated provider.
|
||||
*/
|
||||
const latestSettings = await store.getGlobalSettingsStore().getSettings();
|
||||
const latestProviders = latestSettings.customProviders ?? [];
|
||||
const latestTargetIndex = latestProviders.findIndex((provider) => provider.id === providerId);
|
||||
if (latestTargetIndex < 0) {
|
||||
throw notFound(`custom provider '${providerId}' not found`);
|
||||
}
|
||||
|
||||
const latestTargetProvider = latestProviders[latestTargetIndex];
|
||||
if (
|
||||
latestTargetProvider.baseUrl !== targetProvider.baseUrl ||
|
||||
latestTargetProvider.apiType !== targetProvider.apiType ||
|
||||
latestTargetProvider.apiKey !== targetProvider.apiKey
|
||||
) {
|
||||
throw new ApiError(409, "Custom provider connection changed during model refresh; retry refresh to use the latest endpoint");
|
||||
}
|
||||
|
||||
const updatedProvider: CustomProvider = {
|
||||
...latestTargetProvider,
|
||||
models: persistedModels,
|
||||
};
|
||||
const nextProviders = [...latestProviders];
|
||||
nextProviders[latestTargetIndex] = updatedProvider;
|
||||
await store.updateGlobalSettings({ customProviders: nextProviders });
|
||||
invalidateAllGlobalSettingsCaches();
|
||||
|
||||
return { provider: sanitizeProvider(updatedProvider), modelsRefreshed: persistedModels.length };
|
||||
}
|
||||
|
||||
export async function refreshAllCustomProviderModels(
|
||||
store: CustomProviderSettingsStore,
|
||||
logFn: (message: string) => void,
|
||||
): Promise<RefreshAllCustomProviderModelsResult> {
|
||||
const settings = await store.getGlobalSettingsStore().getSettings();
|
||||
const providers = settings.customProviders ?? [];
|
||||
if (providers.length === 0) {
|
||||
return { refreshed: 0, failed: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
let refreshed = 0;
|
||||
let failed = 0;
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
const result = await refreshCustomProviderModels(store, provider.id);
|
||||
refreshed += 1;
|
||||
logFn(`Refreshed ${result.modelsRefreshed} model(s) for custom provider "${provider.name}" (id=${provider.id})`);
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logFn(`Failed to refresh models for custom provider "${provider.name}" (id=${provider.id}): ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { refreshed, failed, skipped: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and validates the body of an update-custom-provider request.
|
||||
* Returns an object with only the fields that were provided for partial updates.
|
||||
@@ -572,6 +703,26 @@ export const registerCustomProviderRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/custom-providers/:id/refresh-models", async (req, res) => {
|
||||
try {
|
||||
if (!store) {
|
||||
throw new ApiError(500, "Settings store unavailable");
|
||||
}
|
||||
|
||||
const providerId = String(req.params.id ?? "").trim();
|
||||
if (!providerId) {
|
||||
throw badRequest("id path parameter is required");
|
||||
}
|
||||
|
||||
res.json(await refreshCustomProviderModels(store, providerId));
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// NOTE: probe-models must be registered AFTER the :id param routes
|
||||
// so Express does not match "probe-models" as an :id value.
|
||||
router.post("/custom-providers/probe-models", async (req, res) => {
|
||||
@@ -603,7 +754,7 @@ export const registerCustomProviderRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
}
|
||||
const apiType = rawApiType as ProbeApiType;
|
||||
|
||||
const models = await probeProviderModels(baseUrl, apiKey, apiType);
|
||||
const models = dedupeProviderModels(await probeProviderModels(baseUrl, apiKey, apiType));
|
||||
res.json({ models, count: models.length });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
|
||||
Reference in New Issue
Block a user