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:
parent
6c52697ad0
commit
95a638eb8d
9 changed files with 180 additions and 47 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[]>([]);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue