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
This commit is contained in:
Lee Jackson 2026-05-20 07:30:01 +01:00 committed by GitHub
commit 95a638eb8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 180 additions and 47 deletions

View file

@ -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

View file

@ -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,

View file

@ -84,6 +84,15 @@ type LoraCandidate = {
exportType?: "lora" | "merged" | "gguf";
};
const EXTERNAL_PROVIDER_DROPDOWN_ORDER: Record<string, number> = {
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<ExternalModelOption[]>(
() =>
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:<short-chosen>` — 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:<short-chosen>` — 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<LoraModelOption[]>([]);

View file

@ -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({
</div>
</header>
<div className="flex w-full max-w-[760px] flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-x-6">
<div className="flex items-center gap-2">
<Label
htmlFor="chat-connections-enabled"
className="cursor-pointer text-xs text-muted-foreground"
>
Enable connections
</Label>
<Switch
id="chat-connections-enabled"
checked={connectionsEnabled}
onCheckedChange={setConnectionsEnabled}
aria-label="Enable connections"
aria-describedby="chat-connections-description"
/>
</div>
<p
id="chat-connections-description"
className="max-w-md text-[11px] leading-snug text-muted-foreground/65 sm:text-right"
>
When off, all provider connections are disabled.
</p>
</div>
<section className="flex max-w-[760px] flex-col gap-2">
<div className="overflow-hidden rounded-[10px] border border-border/70 bg-muted/[0.12]">
<button

View file

@ -188,6 +188,7 @@ export function toExternalBackendProviderType(
const EXTERNAL_PROVIDERS_KEY = "unsloth_chat_external_providers";
const EXTERNAL_PROVIDER_KEYS_KEY = "unsloth_chat_external_provider_keys";
const CONNECTIONS_ENABLED_KEY = "unsloth_chat_connections_enabled";
const EXTERNAL_MODEL_PREFIX = "external::";
function canUseStorage(): boolean {
@ -305,6 +306,26 @@ function fromUnknownProvider(value: unknown): ExternalProviderConfig | null {
};
}
export function loadConnectionsEnabled(): boolean {
if (!canUseStorage()) return true;
try {
const raw = localStorage.getItem(CONNECTIONS_ENABLED_KEY);
if (raw == null) return true;
return raw === "true";
} catch {
return true;
}
}
export function saveConnectionsEnabled(enabled: boolean): void {
if (!canUseStorage()) return;
try {
localStorage.setItem(CONNECTIONS_ENABLED_KEY, enabled ? "true" : "false");
} catch {
// ignore
}
}
export function loadExternalProviders(): ExternalProviderConfig[] {
if (!canUseStorage()) return [];
try {

View file

@ -1124,6 +1124,11 @@ export function useChatModelRuntime() {
return;
}
setModelsError(null);
if (isExternalModelId(params.checkpoint)) {
clearCheckpoint();
await refresh();
return;
}
try {
async function performUnload(): Promise<void> {
await unloadModel({ model_path: params.checkpoint });

View file

@ -37,6 +37,7 @@ import { authFetch } from "@/features/auth";
import { createOpenAIStreamAdapter } from "./api/chat-adapter";
import { db } from "./db";
import {
loadConnectionsEnabled,
loadExternalProviders,
parseExternalModelId,
providerTypeSupportsVision,
@ -101,7 +102,7 @@ class VisionImageAdapter implements AttachmentAdapter {
let externalSupportsVision: boolean | null = null;
let externalModelLabel: string | null = null;
if (externalSelection !== null) {
const providers = loadExternalProviders();
const providers = loadConnectionsEnabled() ? loadExternalProviders() : [];
const provider = providers.find(
(p) => p.id === externalSelection.providerId,
);

View file

@ -298,7 +298,11 @@ export function SharedComposer({
return s.models.find((m) => m.id === checkpoint);
});
const checkpoint = useChatRuntimeStore((s) => s.params.checkpoint);
const externalProviders = useExternalProvidersStore((s) => s.providers);
const connectionsEnabled = useExternalProvidersStore(
(s) => s.connectionsEnabled,
);
const externalProvidersAll = useExternalProvidersStore((s) => s.providers);
const externalProviders = connectionsEnabled ? externalProvidersAll : [];
const modelLoaded = useChatRuntimeStore(
(s) => !!s.params.checkpoint && !s.modelLoading,
);

View file

@ -3,22 +3,31 @@
import { create } from "zustand";
import {
loadConnectionsEnabled,
loadExternalProviders,
saveConnectionsEnabled,
saveExternalProviders,
type ExternalProviderConfig,
} from "../external-providers";
interface ExternalProvidersState {
providers: ExternalProviderConfig[];
connectionsEnabled: boolean;
setProviders: (providers: ExternalProviderConfig[]) => void;
setConnectionsEnabled: (enabled: boolean) => void;
}
export const useExternalProvidersStore = create<ExternalProvidersState>(
(set) => ({
providers: loadExternalProviders(),
connectionsEnabled: loadConnectionsEnabled(),
setProviders: (providers) => {
set({ providers });
saveExternalProviders(providers);
},
setConnectionsEnabled: (enabled) => {
set({ connectionsEnabled: enabled });
saveConnectionsEnabled(enabled);
},
}),
);