From 95a638eb8db496350a13d50edce02b0c21ce3a70 Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Wed, 20 May 2026 07:30:01 +0100 Subject: [PATCH] Studio: add connections toggle and order hosted providers (#5588) * fix: add connections toggle and order hosted providers * fix: clear hosted checkpoint when connections disable * fix: skip backend unload when disabling connections --- .../src/components/assistant-ui/thread.tsx | 12 +- .../src/features/chat/api/chat-adapter.ts | 11 ++ .../frontend/src/features/chat/chat-page.tsx | 128 ++++++++++++------ .../features/chat/chat-providers-dialog.tsx | 32 +++++ .../src/features/chat/external-providers.ts | 21 +++ .../chat/hooks/use-chat-model-runtime.ts | 5 + .../src/features/chat/runtime-provider.tsx | 3 +- .../src/features/chat/shared-composer.tsx | 6 +- .../chat/stores/external-providers-store.ts | 9 ++ 9 files changed, 180 insertions(+), 47 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 0326b90a97..e4fcff6b0f 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -549,7 +549,11 @@ const ReasoningToggle: FC = () => { const lastOpenRouterChosenModel = useChatRuntimeStore( (s) => s.lastOpenRouterChosenModel, ); - const externalProviders = useExternalProvidersStore((s) => s.providers); + const connectionsEnabled = useExternalProvidersStore( + (s) => s.connectionsEnabled, + ); + const externalProvidersAll = useExternalProvidersStore((s) => s.providers); + const externalProviders = connectionsEnabled ? externalProvidersAll : []; const externalSelection = parseExternalModelId(checkpoint); const selectedExternalProvider = externalSelection != null @@ -768,7 +772,11 @@ const WebSearchToggle: FC = () => { const toolsEnabled = useChatRuntimeStore((s) => s.toolsEnabled); const setToolsEnabled = useChatRuntimeStore((s) => s.setToolsEnabled); const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled); - const externalProviders = useExternalProvidersStore((s) => s.providers); + const connectionsEnabled = useExternalProvidersStore( + (s) => s.connectionsEnabled, + ); + const externalProvidersAll = useExternalProvidersStore((s) => s.providers); + const externalProviders = connectionsEnabled ? externalProvidersAll : []; const externalSelection = parseExternalModelId(checkpoint); const selectedExternalProvider = externalSelection != null diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 359099e8b3..370d73306c 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -48,6 +48,7 @@ import { providerSupportsBuiltinWebSearch, } from "../provider-capabilities"; import { useChatRuntimeStore } from "../stores/chat-runtime-store"; +import { useExternalProvidersStore } from "../stores/external-providers-store"; import { isMultimodalResponse } from "../types/api"; import type { ChatModelSummary } from "../types/runtime"; import { getImageInputUnavailableReason } from "../utils/image-input-support"; @@ -768,6 +769,16 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { } = runtime; const externalSelection = parseExternalModelId(params.checkpoint); const isExternalRequest = externalSelection !== null; + if ( + isExternalRequest && + !useExternalProvidersStore.getState().connectionsEnabled + ) { + toast.error("Connections are disabled.", { + description: + "Turn on Enable connections in Settings > Connections to use hosted models.", + }); + throw new Error("Connections disabled."); + } const externalProvider = isExternalRequest ? loadExternalProviders().find( (provider) => provider.id === externalSelection.providerId, diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 81a8c8818e..a62270ed50 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -84,6 +84,15 @@ type LoraCandidate = { exportType?: "lora" | "merged" | "gguf"; }; +const EXTERNAL_PROVIDER_DROPDOWN_ORDER: Record = { + openai: 0, + anthropic: 1, +}; + +function getExternalProviderDropdownRank(providerType: string): number { + return EXTERNAL_PROVIDER_DROPDOWN_ORDER[providerType] ?? 2; +} + function normalizeModelRef(value: string | null | undefined): string { return value?.trim().toLowerCase() ?? ""; } @@ -557,7 +566,11 @@ export function ChatPage(): ReactElement { const settingsOpen = useChatRuntimeStore((s) => s.settingsPanelOpen); const setSettingsOpen = useChatRuntimeStore((s) => s.setSettingsPanelOpen); const externalProviders = useExternalProvidersStore((s) => s.providers); + const connectionsEnabled = useExternalProvidersStore( + (s) => s.connectionsEnabled, + ); const setExternalProviders = useExternalProvidersStore((s) => s.setProviders); + const externalProvidersForChat = connectionsEnabled ? externalProviders : []; useEffect(() => { const threadId = search.thread; @@ -605,6 +618,7 @@ export function ChatPage(): ReactElement { const lorasFromStore = useChatRuntimeStore((state) => state.loras); const modelsError = useChatRuntimeStore((state) => state.modelsError); const modelLoading = useChatRuntimeStore((state) => state.modelLoading); + const clearCheckpoint = useChatRuntimeStore((state) => state.clearCheckpoint); const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId); const modelOperationInProgress = useChatRuntimeStore( (state) => state.modelLoading, @@ -618,6 +632,24 @@ export function ChatPage(): ReactElement { loadProgress, loadToastDismissed, } = useChatModelRuntime(); + const prevConnectionsEnabledRef = useRef(connectionsEnabled); + useEffect(() => { + const turnedOff = + prevConnectionsEnabledRef.current && !connectionsEnabled; + if (!connectionsEnabled && isExternalModelId(inferenceParams.checkpoint)) { + clearCheckpoint(); + if (turnedOff) { + toast.info("Connections disabled", { + description: "Switched away from the hosted model.", + }); + } + } + prevConnectionsEnabledRef.current = connectionsEnabled; + }, [ + clearCheckpoint, + connectionsEnabled, + inferenceParams.checkpoint, + ]); const pendingNativeModelIntent = useNativeIntentStore( (state) => state.pendingModelIntent, ); @@ -641,16 +673,16 @@ export function ChatPage(): ReactElement { const selection = parseExternalModelId(inferenceParams.checkpoint); if (!selection) return null; return ( - externalProviders.find( + externalProvidersForChat.find( (p) => p.id === selection.providerId, ) ?? null ); - }, [externalProviders, inferenceParams.checkpoint]); + }, [externalProvidersForChat, inferenceParams.checkpoint]); const activeExternalProviderType = activeExternalProvider?.providerType ?? null; const activeProviderCapabilities = useMemo(() => { const selection = parseExternalModelId(inferenceParams.checkpoint); if (!selection) return null; - const provider = externalProviders.find( + const provider = externalProvidersForChat.find( (p) => p.id === selection.providerId, ); const baseCapabilities = getProviderCapabilities(provider?.providerType); @@ -667,7 +699,7 @@ export function ChatPage(): ReactElement { topK: false, }; }, [ - externalProviders, + externalProvidersForChat, inferenceParams.checkpoint, reasoningEnabled, reasoningStyle, @@ -677,7 +709,9 @@ export function ChatPage(): ReactElement { useEffect(() => { const selection = parseExternalModelId(inferenceParams.checkpoint); if (!selection) return; - const provider = externalProviders.find((p) => p.id === selection.providerId); + const provider = externalProvidersForChat.find( + (p) => p.id === selection.providerId, + ); const reasoningCaps = getExternalReasoningCapabilities( provider?.providerType, selection.modelId, @@ -782,7 +816,7 @@ export function ChatPage(): ReactElement { ? (storedCodeToolsEnabled ?? false) : false, }); - }, [externalProviders, inferenceParams.checkpoint]); + }, [externalProvidersForChat, inferenceParams.checkpoint]); const canCompare = useMemo(() => { return Boolean(inferenceParams.checkpoint) && !isExternalModel; }, [inferenceParams.checkpoint, isExternalModel]); @@ -885,7 +919,9 @@ export function ChatPage(): ReactElement { if (meta?.source === "external" || isExternalModelId(value)) { const selectedExternal = parseExternalModelId(value); const selectedProvider = selectedExternal - ? externalProviders.find((p) => p.id === selectedExternal.providerId) + ? externalProvidersForChat.find( + (p) => p.id === selectedExternal.providerId, + ) : null; const reasoningCaps = getExternalReasoningCapabilities( selectedProvider?.providerType, @@ -1040,7 +1076,7 @@ export function ChatPage(): ReactElement { }, [ activeThreadId, - externalProviders, + externalProvidersForChat, modelsFromStore, selectModel, setInferenceParams, @@ -1125,41 +1161,47 @@ export function ChatPage(): ReactElement { ); const externalModels = useMemo( () => - externalProviders.flatMap((provider) => - provider.models.map((model) => { - // For OpenRouter's free router we know which underlying free - // model the gateway actually picked once a stream completes - // (chat-adapter latches `chunk.model` into the runtime store). - // Render the chip as `openrouter:` — drop the - // redundant `/free` from the router id and the org prefix - // from the chosen id (e.g. - // openrouter/free + inclusionai/ring-2.6-1t-20260508:free - // -> openrouter:ring-2.6-1t-20260508:free - // ). The `:free` suffix on the chosen id already conveys - // 'free model', so the leading `/free` is noise. - let displayName = model; - if ( - provider.providerType === "openrouter" && - model === "openrouter/free" && - lastOpenRouterChosenModel - ) { - const lastSlash = lastOpenRouterChosenModel.lastIndexOf("/"); - const shortChosen = - lastSlash >= 0 - ? lastOpenRouterChosenModel.slice(lastSlash + 1) - : lastOpenRouterChosenModel; - displayName = `openrouter:${shortChosen}`; - } - return { - id: buildExternalModelId(provider.id, model), - name: displayName, - providerId: provider.id, - providerName: provider.name, - providerType: provider.providerType, - }; - }), - ), - [externalProviders, lastOpenRouterChosenModel], + [...externalProvidersForChat] + .sort( + (a, b) => + getExternalProviderDropdownRank(a.providerType) - + getExternalProviderDropdownRank(b.providerType), + ) + .flatMap((provider) => + provider.models.map((model) => { + // For OpenRouter's free router we know which underlying free + // model the gateway actually picked once a stream completes + // (chat-adapter latches `chunk.model` into the runtime store). + // Render the chip as `openrouter:` — drop the + // redundant `/free` from the router id and the org prefix + // from the chosen id (e.g. + // openrouter/free + inclusionai/ring-2.6-1t-20260508:free + // -> openrouter:ring-2.6-1t-20260508:free + // ). The `:free` suffix on the chosen id already conveys + // 'free model', so the leading `/free` is noise. + let displayName = model; + if ( + provider.providerType === "openrouter" && + model === "openrouter/free" && + lastOpenRouterChosenModel + ) { + const lastSlash = lastOpenRouterChosenModel.lastIndexOf("/"); + const shortChosen = + lastSlash >= 0 + ? lastOpenRouterChosenModel.slice(lastSlash + 1) + : lastOpenRouterChosenModel; + displayName = `openrouter:${shortChosen}`; + } + return { + id: buildExternalModelId(provider.id, model), + name: displayName, + providerId: provider.id, + providerName: provider.name, + providerType: provider.providerType, + }; + }), + ), + [externalProvidersForChat, lastOpenRouterChosenModel], ); const [localModels, setLocalModels] = useState([]); diff --git a/studio/frontend/src/features/chat/chat-providers-dialog.tsx b/studio/frontend/src/features/chat/chat-providers-dialog.tsx index 722716cbc9..dcd51da044 100644 --- a/studio/frontend/src/features/chat/chat-providers-dialog.tsx +++ b/studio/frontend/src/features/chat/chat-providers-dialog.tsx @@ -21,6 +21,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; import { Spinner } from "@/components/ui/spinner"; import { Textarea } from "@/components/ui/textarea"; import { @@ -62,6 +63,7 @@ import { supportsProviderReasoningToggle, toExternalBackendProviderType, } from "./external-providers"; +import { useExternalProvidersStore } from "./stores/external-providers-store"; /** Matches navbar / thread layout easing (see index.css --ease-out-quart) */ const PROVIDER_FORM_EASE: [number, number, number, number] = [ @@ -190,6 +192,12 @@ export function ChatProvidersSettings({ const [customProviderName, setCustomProviderName] = useState("Custom"); const [isReasoningModel, setIsReasoningModel] = useState(false); const reduceMotion = useReducedMotion(); + const connectionsEnabled = useExternalProvidersStore( + (s) => s.connectionsEnabled, + ); + const setConnectionsEnabled = useExternalProvidersStore( + (s) => s.setConnectionsEnabled, + ); const isCustomProvider = isCustomProviderType(providerType); // Ollama runs locally and does not require an API key. Hide the input // entirely rather than just marking it optional so users aren't prompted @@ -1356,6 +1364,30 @@ export function ChatProvidersSettings({ +
+
+ + +
+

+ When off, all provider connections are disabled. +

+
+