From a226b7e7e9904bf43c0029b94b18c0a729809135 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 22 May 2026 06:42:39 -0700 Subject: [PATCH 1/3] Studio: reconcile external providers across browsers after delete (#5698) Deleting a connection in one browser left the same connection stuck in every other browser/tab. The user could not delete or edit it from there because the local state never caught up with the server, and clicks either no-op'd or threw on a missing-row backend response. Two pieces caused the bug: 1. `ChatProvidersSettings` ran its backend sync once on mount and then silently kept localStorage providers whenever `listProviderConfigs` returned an empty array, on the assumption that an empty server response had to be a transient glitch. That assumption is wrong when another browser removed the last connection. With the guard gone, trust any successful API response, including an empty list. A focus / visibilitychange listener now triggers a silent re-sync so the dialog does not need to be closed and reopened to pick up remote deletes. 2. `deleteProviderConfig` threw on HTTP 404, so once Browser A deleted a connection, Browser B's "Delete" click failed and the local row stuck around. Treat 404 as success: the server's job is already done and the local cache only needs to be pruned. --- .../src/features/chat/api/providers-api.ts | 7 +++ .../features/chat/chat-providers-dialog.tsx | 48 ++++++++++++++----- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/studio/frontend/src/features/chat/api/providers-api.ts b/studio/frontend/src/features/chat/api/providers-api.ts index 0b86c7ee82..9625c4c217 100644 --- a/studio/frontend/src/features/chat/api/providers-api.ts +++ b/studio/frontend/src/features/chat/api/providers-api.ts @@ -140,6 +140,13 @@ export async function deleteProviderConfig(providerId: string): Promise { const response = await authFetch(`/api/providers/${providerId}`, { method: "DELETE", }); + // Treat 404 as success: another browser (or tab) already deleted this + // provider on the backend, so locally pruning the stale cache is the + // correct follow-up. Without this, the caller would throw and the user + // would be stuck with an entry they cannot remove from the UI. + if (response.status === 404) { + return; + } if (!response.ok) { const body = await response.json().catch(() => null); throw new Error(parseErrorText(response.status, body)); diff --git a/studio/frontend/src/features/chat/chat-providers-dialog.tsx b/studio/frontend/src/features/chat/chat-providers-dialog.tsx index 51530b4133..3ffb3a1441 100644 --- a/studio/frontend/src/features/chat/chat-providers-dialog.tsx +++ b/studio/frontend/src/features/chat/chat-providers-dialog.tsx @@ -345,15 +345,21 @@ export function ChatProvidersSettings({ useEffect(() => { let isMounted = true; - const syncFromBackend = async () => { - setRegistryLoading(true); - setSyncingProviders(true); + const syncFromBackend = async ({ + showSpinner = true, + }: { showSpinner?: boolean } = {}) => { + if (showSpinner) { + setRegistryLoading(true); + setSyncingProviders(true); + } + let syncSucceeded = false; try { const [registryRows, configRows] = await Promise.all([ listProviderRegistry(), listProviderConfigs(), ]); if (!isMounted) return; + syncSucceeded = true; setRegistry(registryRows); setProviderType((current) => { if ( @@ -406,25 +412,45 @@ export function ChatProvidersSettings({ updatedAt, }; }); - // Don't wipe localStorage providers when the server has no rows. - if (syncedProviders.length === 0 && providersRef.current.length > 0) { - return; - } + // Trust the backend response when it succeeds. An empty array means + // every connection was removed (often from another browser/tab) and + // the local cache should mirror that, otherwise the stale entries + // become un-removable in this browser until localStorage is cleared. onProvidersChange(syncedProviders); } catch (error) { - const message = - error instanceof Error ? error.message : "Unknown error"; - toast.error(`Failed to load connections: ${message}`); + // Only surface a toast for real failures, not for the silent + // background re-sync on tab focus. + if (showSpinner) { + const message = + error instanceof Error ? error.message : "Unknown error"; + toast.error(`Failed to load connections: ${message}`); + } } finally { - if (isMounted) { + if (isMounted && showSpinner) { setRegistryLoading(false); setSyncingProviders(false); } } + return syncSucceeded; }; void syncFromBackend(); + // Re-sync silently when the tab regains focus so deletes made in + // another browser propagate without forcing the user to reopen the + // dialog. Skip when the document is hidden to avoid background work. + const handleVisibilityChange = () => { + if (typeof document === "undefined" || document.hidden) return; + void syncFromBackend({ showSpinner: false }); + }; + if (typeof window !== "undefined") { + window.addEventListener("focus", handleVisibilityChange); + document.addEventListener("visibilitychange", handleVisibilityChange); + } return () => { isMounted = false; + if (typeof window !== "undefined") { + window.removeEventListener("focus", handleVisibilityChange); + document.removeEventListener("visibilitychange", handleVisibilityChange); + } }; }, [onProvidersChange]); From 228d1cd40ceb05b2cdae41f913aa22528439bc88 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 22 May 2026 06:45:27 -0700 Subject: [PATCH 2/3] Studio: persist external provider selection across page refresh (#5697) Selecting a connected external provider (Anthropic, OpenAI, Google, etc.) and refreshing the page reverted the picker back to no selection. Root cause is that `PersistedInferenceParams` in `chat-settings-api.ts` excludes `checkpoint` from the server-side settings payload by design. Local model selections survive refresh because the backend re-derives them from `/api/inference/status.active_model`, but external selections have no backend mirror, so they were lost. Fix: persist `external::*` checkpoints to a small dedicated `localStorage` key (`unsloth_chat_last_external_checkpoint`) and hydrate from it on store init. Local checkpoints continue to come from the backend status as before; only external ids are mirrored client-side. `setCheckpoint` writes the key when an external id is selected and clears it when switching back to a local id, and `clearCheckpoint` clears it so the picker does not snap back after an explicit reset. --- .../chat/stores/chat-runtime-store.ts | 80 ++++++++++++++++--- 1 file changed, 69 insertions(+), 11 deletions(-) diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index 07e3244a36..287dd1fa8b 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -14,6 +14,7 @@ import { DEFAULT_INFERENCE_PARAMS, type InferenceParams, } from "../types/runtime"; +import { isExternalModelId } from "../external-providers"; import { loadChatSettingsWithLegacyImport, savePersistedChatSettingsPatch, @@ -23,6 +24,40 @@ const HF_TOKEN_KEY = "unsloth_hf_token"; export const CHAT_REASONING_ENABLED_KEY = "unsloth_chat_reasoning_enabled"; export const CHAT_TOOLS_ENABLED_KEY = "unsloth_chat_tools_enabled"; export const CHAT_CODE_TOOLS_ENABLED_KEY = "unsloth_chat_code_tools_enabled"; +// External provider selection is encoded into `params.checkpoint` as +// `external::::`. PersistedChatSettings deliberately +// Omits `checkpoint` because the local-model side is mirrored by the +// backend's `/api/inference/status.active_model` response. External +// selections have no such backend mirror, so without explicit +// localStorage persistence here the user's external pick is silently +// reset to the default on every page refresh. +const LAST_EXTERNAL_CHECKPOINT_KEY = "unsloth_chat_last_external_checkpoint"; + +function loadLastExternalCheckpoint(): string | null { + if (typeof window === "undefined") return null; + try { + const value = window.localStorage.getItem(LAST_EXTERNAL_CHECKPOINT_KEY); + return isExternalModelId(value) ? value : null; + } catch { + return null; + } +} + +function saveLastExternalCheckpoint(value: string | null): void { + if (typeof window === "undefined") return; + try { + if (value && isExternalModelId(value)) { + window.localStorage.setItem(LAST_EXTERNAL_CHECKPOINT_KEY, value); + } else { + // Clearing on a switch to a local / empty checkpoint means the + // next refresh won't override the now-active local selection. + window.localStorage.removeItem(LAST_EXTERNAL_CHECKPOINT_KEY); + } + } catch { + // Storage quota / private-mode failures are non-fatal -- the + // selection just won't survive the refresh. + } +} export type ReasoningStyle = "enable_thinking" | "reasoning_effort"; export type ReasoningEffort = @@ -479,7 +514,16 @@ function setScalarSettingVersion( export const useChatRuntimeStore = create((set, get) => ({ settingsHydrated: false, - params: DEFAULT_INFERENCE_PARAMS, + // Hydrate the last external checkpoint into params.checkpoint so the + // external picker selection survives a page refresh. Local model + // checkpoints are re-derived from the backend in useChatModelRuntime + // and intentionally NOT persisted here. + params: (() => { + const persistedExternal = loadLastExternalCheckpoint(); + return persistedExternal + ? { ...DEFAULT_INFERENCE_PARAMS, checkpoint: persistedExternal } + : DEFAULT_INFERENCE_PARAMS; + })(), customPresets: [], activePreset: "Default", activePresetSource: getPresetSource("Default"), @@ -640,18 +684,31 @@ export const useChatRuntimeStore = create((set, get) => ({ }), setModelsError: (modelsError) => set({ modelsError }), setCheckpoint: (modelId, ggufVariant) => - set((state) => ({ - params: { - ...state.params, - checkpoint: modelId, - }, - activeGgufVariant: ggufVariant ?? null, - })), + set((state) => { + // Persist external selections so they survive a page refresh. + // Local model ids are NOT persisted here -- they get re-derived + // from the backend's `/api/inference/status.active_model` on + // mount, and a stale persisted local id would race against the + // freshly-loaded model. See LAST_EXTERNAL_CHECKPOINT_KEY notes. + saveLastExternalCheckpoint(isExternalModelId(modelId) ? modelId : null); + return { + params: { + ...state.params, + checkpoint: modelId, + }, + activeGgufVariant: ggufVariant ?? null, + }; + }), setActiveThreadId: (activeThreadId) => set({ activeThreadId, contextUsage: null }), setSettingsPanelOpen: (settingsPanelOpen) => set({ settingsPanelOpen }), - clearCheckpoint: () => - set((state) => ({ + clearCheckpoint: () => { + // Mirror setCheckpoint's persistence behavior: dropping the + // checkpoint must also clear any stored external selection so + // the next refresh doesn't snap back to a model the user + // intentionally cleared. + saveLastExternalCheckpoint(null); + return set((state) => ({ params: { ...state.params, checkpoint: "", @@ -687,7 +744,8 @@ export const useChatRuntimeStore = create((set, get) => ({ defaultChatTemplate: null, chatTemplateOverride: null, loadedChatTemplateOverride: null, - })), + })); + }, setReasoningEnabled: (reasoningEnabled, options) => set(() => { if (options?.persist !== false) { From 51736a776692c7aaff9dff7269677aceb2cc01c2 Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Fri, 22 May 2026 15:03:56 +0100 Subject: [PATCH 3/3] Studio: add Anthropic and OpenAI prompt guards for disabled tools (#5674) * Add Anthropic prompt guards for disabled tools * fix: merge Anthropic tool guard into structured system prompts * fix: scope Anthropic disabled-tool guard wording * chore: adjust claude guard prompt * chore: add openai to list of prompt guarded providers * Studio: include web_fetch in the per-turn disabled-tool guard Add webFetchEnabledForThisTurn alongside webSearchEnabledForThisTurn and codeExecEnabledForThisTurn. Use it in the enabled_tools payload so web_fetch follows the Search pill the same way web_search does, and mention "web fetch" in the disabled-tool guard prose on providers that ship the tool (Anthropic today; other providers stay inert via providerSupportsBuiltinWebFetch). --------- Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Co-authored-by: Daniel Han --- .../src/features/chat/api/chat-adapter.ts | 131 +++++++++++++----- 1 file changed, 94 insertions(+), 37 deletions(-) diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index a5a77b0863..c214c17c0a 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -869,6 +869,38 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { throw new Error("Missing connection API key."); } + const webSearchEnabledForThisTurn = + Boolean( + externalProvider && + toolsEnabled && + providerSupportsBuiltinWebSearch(externalProvider.providerType), + ); + const codeExecEnabledForThisTurn = + Boolean( + externalProvider && + externalSelection && + codeToolsEnabled && + providerSupportsBuiltinCodeExecution( + externalProvider.providerType, + externalSelection.modelId, + externalProvider.baseUrl, + ), + ); + // web_fetch shares the Search pill with web_search (no separate + // UI toggle), so it follows toolsEnabled. Anthropic is the only + // provider that ships it today; on others providerSupportsBuiltinWebFetch + // returns false and this stays inert. + const webFetchEnabledForThisTurn = + Boolean( + externalProvider && + toolsEnabled && + providerSupportsBuiltinWebFetch(externalProvider.providerType), + ); + const providerShipsWebFetch = Boolean( + externalProvider && + providerSupportsBuiltinWebFetch(externalProvider.providerType), + ); + const outboundMessages = messages .map(toOpenAIMessage) .filter((message): message is NonNullable => @@ -883,6 +915,62 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { content: safeSystemPrompt.trim(), }); } + let disabledToolGuard: string | null = null; + const disabledToolGuardProviderType = externalProvider?.providerType; + if ( + disabledToolGuardProviderType === "anthropic" || + disabledToolGuardProviderType === "openai" + ) { + const webLabel = providerShipsWebFetch + ? "web search or web fetch" + : "web search"; + if (!webSearchEnabledForThisTurn && !codeExecEnabledForThisTurn) { + disabledToolGuard = + `You do not have ${webLabel} or code execution tools in this conversation. ` + + "Answer from your own knowledge. " + + "If a request genuinely requires tool use, live data fetch or running code, " + + "inform the user that you do not have access to these capabilities. " + + "Do not return tool-call syntax inside your response."; + } else if (!webSearchEnabledForThisTurn) { + disabledToolGuard = + `You do not have ${webLabel} tools in this conversation. ` + + "You may still use code execution tools when they are available and useful. " + + "If a request genuinely requires live data fetch or web search tool use, " + + "inform the user that you do not have access to these capabilities. " + + "Do not return tool-call syntax inside your response."; + } else if (!codeExecEnabledForThisTurn) { + disabledToolGuard = + "You do not have code execution tools in this conversation. " + + `You may still use ${webLabel} tools when they are available and useful. ` + + "If a request genuinely requires running code or code execution tool use, " + + "inform the user that you do not have access to these capabilities. " + + "Do not return tool-call syntax inside your response."; + } + } + if (disabledToolGuard) { + const firstMessage = outboundMessages[0]; + if (firstMessage?.role === "system") { + if (typeof firstMessage.content === "string") { + outboundMessages[0] = { + ...firstMessage, + content: `${firstMessage.content}\n\n${disabledToolGuard}`, + }; + } else { + outboundMessages[0] = { + ...firstMessage, + content: [ + ...firstMessage.content, + { type: "text", text: `\n\n${disabledToolGuard}` }, + ], + }; + } + } else { + outboundMessages.unshift({ + role: "system", + content: disabledToolGuard, + }); + } + } const imageBase64 = findLatestUserImageBase64(messages); const audioBase64 = findLatestUserAudioBase64(messages); @@ -1137,13 +1225,6 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { // (sent as `container` on /v1/messages). let openaiCodeExecContainerId: string | null = null; let anthropicCodeExecContainerId: string | null = null; - const codeExecEnabledForThisTurn = - codeToolsEnabled && - providerSupportsBuiltinCodeExecution( - externalProvider.providerType, - externalSelection.modelId, - externalProvider.baseUrl, - ); if (codeExecEnabledForThisTurn && resolvedThreadId) { try { const thread = await getStoredChatThread(resolvedThreadId); @@ -1313,25 +1394,13 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { // translates enabled_tools into each provider's tool // schema — for Anthropic that's the entries appended to // body["tools"] inside _stream_anthropic. - ...((toolsEnabled && - providerSupportsBuiltinWebSearch( - externalProvider.providerType, - )) || - (codeToolsEnabled && - providerSupportsBuiltinCodeExecution( - externalProvider.providerType, - externalSelection.modelId, - externalProvider.baseUrl, - )) + ...(webSearchEnabledForThisTurn || + webFetchEnabledForThisTurn || + codeExecEnabledForThisTurn ? { enable_tools: true, enabled_tools: [ - ...(toolsEnabled && - providerSupportsBuiltinWebSearch( - externalProvider.providerType, - ) - ? ["web_search"] - : []), + ...(webSearchEnabledForThisTurn ? ["web_search"] : []), // Pair web_fetch with the Search pill on any // provider that ships it (Anthropic today). The // common workflow is "search returns URLs, fetch @@ -1339,20 +1408,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { // surface a citation but cannot quote from the // page body, which is the whole point of the // tool. There is no separate UI toggle yet. - ...(toolsEnabled && - providerSupportsBuiltinWebFetch( - externalProvider.providerType, - ) - ? ["web_fetch"] - : []), - ...(codeToolsEnabled && - providerSupportsBuiltinCodeExecution( - externalProvider.providerType, - externalSelection.modelId, - externalProvider.baseUrl, - ) - ? ["code_execution"] - : []), + ...(webFetchEnabledForThisTurn ? ["web_fetch"] : []), + ...(codeExecEnabledForThisTurn ? ["code_execution"] : []), ], } : {}),