diff --git a/studio/frontend/src/app/router.tsx b/studio/frontend/src/app/router.tsx index 13ff8a5cbe..d26d8b9dee 100644 --- a/studio/frontend/src/app/router.tsx +++ b/studio/frontend/src/app/router.tsx @@ -12,6 +12,7 @@ import { Route as indexRoute } from "./routes/index"; import { Route as loginRoute } from "./routes/login"; import { Route as onboardingRoute } from "./routes/onboarding"; import { Route as changePasswordRoute } from "./routes/change-password"; +import { Route as settingsRoute } from "./routes/settings"; import { Route as studioRoute } from "./routes/studio"; const routeTree = rootRoute.addChildren([ @@ -20,6 +21,7 @@ const routeTree = rootRoute.addChildren([ loginRoute, changePasswordRoute, gridTestRoute, + settingsRoute, studioRoute, chatRoute, exportRoute, diff --git a/studio/frontend/src/app/routes/settings.tsx b/studio/frontend/src/app/routes/settings.tsx new file mode 100644 index 0000000000..4e35f0b16d --- /dev/null +++ b/studio/frontend/src/app/routes/settings.tsx @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { createRoute, redirect } from "@tanstack/react-router"; +import { getPostAuthRoute } from "@/features/auth"; +import { useSettingsDialogStore } from "@/features/settings"; +import { requireAuth } from "../auth-guards"; +import { Route as rootRoute } from "./__root"; + +// /settings is a deep link to the modal. Open it, then redirect home. +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: "/settings", + beforeLoad: async () => { + await requireAuth(); + useSettingsDialogStore.getState().openDialog(); + throw redirect({ to: getPostAuthRoute() }); + }, + component: () => null, +}); diff --git a/studio/frontend/src/features/chat/chat-providers-dialog.tsx b/studio/frontend/src/features/chat/chat-providers-dialog.tsx index 96f95d6d7b..722716cbc9 100644 --- a/studio/frontend/src/features/chat/chat-providers-dialog.tsx +++ b/studio/frontend/src/features/chat/chat-providers-dialog.tsx @@ -135,6 +135,9 @@ function parseManualModelIds(text: string): string[] { return out; } +// Remote providers safe for manual model IDs (openrouter drops unused params). +const MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES = new Set(["openrouter"]); + function pruneProviderModelIds(providerType: string, modelIds: string[]): string[] { if (providerType === "anthropic") { return modelIds.filter((id) => !ANTHROPIC_DATED_SNAPSHOT_SUFFIX.test(id)); @@ -330,6 +333,10 @@ export function ChatProvidersSettings({ updatedAt, }; }); + // Don't wipe localStorage providers when the server has no rows. + if (syncedProviders.length === 0 && providersRef.current.length > 0) { + return; + } onProvidersChange(syncedProviders); } catch (error) { const message = @@ -501,19 +508,28 @@ export function ChatProvidersSettings({ return; } const curated = selectedRegistryEntry?.model_list_mode === "curated"; - const manualModels = isCustomProvider || curated; + const manualOnly = isCustomProvider || curated; + const remoteAllowsManual = + MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES.has(providerType); + const manualIds = parseManualModelIds(manualModelIds); + const allowManual = manualOnly || remoteAllowsManual; const modelsToSave = pruneProviderModelIds( providerType, - manualModels + allowManual ? [ ...new Set([ ...selectedModelIds, - ...parseManualModelIds(manualModelIds), + ...manualIds, ]), ] : [...selectedModelIds], ); - if (manualModels) { + if (manualOnly) { + if (modelsToSave.length === 0) { + toast.error("Add at least one model ID."); + return; + } + } else if (remoteAllowsManual && manualIds.length > 0) { if (modelsToSave.length === 0) { toast.error("Add at least one model ID."); return; @@ -553,7 +569,7 @@ export function ChatProvidersSettings({ name: created.display_name, baseUrl: created.base_url ?? "", models: modelsToSave, - availableModels: manualModels + availableModels: manualOnly ? [] : pruneProviderModelIds(providerType, availableModels), isReasoningModel: supportsProviderReasoningToggle(uiProviderType) @@ -597,19 +613,29 @@ export function ChatProvidersSettings({ } const entry = registryByType.get(existing.providerType); const curated = entry?.model_list_mode === "curated"; - const manualModels = isEditingCustomProvider || curated; + const manualOnly = isEditingCustomProvider || curated; + const remoteAllowsManual = MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES.has( + existing.providerType, + ); + const manualIds = parseManualModelIds(manualModelIds); + const allowManual = manualOnly || remoteAllowsManual; const modelsToSave = pruneProviderModelIds( existing.providerType, - manualModels + allowManual ? [ ...new Set([ ...selectedModelIds, - ...parseManualModelIds(manualModelIds), + ...manualIds, ]), ] : [...selectedModelIds], ); - if (manualModels) { + if (manualOnly) { + if (modelsToSave.length === 0) { + toast.error("Add at least one model ID."); + return; + } + } else if (remoteAllowsManual && manualIds.length > 0) { if (modelsToSave.length === 0) { toast.error("Add at least one model ID."); return; @@ -655,7 +681,7 @@ export function ChatProvidersSettings({ name: updated.display_name, baseUrl: updated.base_url ?? "", models: modelsToSave, - availableModels: manualModels + availableModels: manualOnly ? [] : pruneProviderModelIds(existing.providerType, availableModels), isReasoningModel: supportsProviderReasoningToggle( @@ -1184,71 +1210,99 @@ export function ChatProvidersSettings({ /> - ) : availableModels.length === 0 ? null : ( + ) : availableModels.length === 0 && + !MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES.has(providerType) ? null : (
-
- - {availableModelsLabel} - - - setModelSearchQuery(event.target.value) - } - placeholder="Search" - aria-label="Search models" - className={modelSearchInputClassName} - /> -
- - -
-
- + Select all + + +
+ + + + )} + {/* Manual IDs allowed for openrouter only. */} + {MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES.has(providerType) ? ( +
+ +