From 63d69ee7e90dbaf1b56ed7390d36dc28af9deb5a Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 19 May 2026 07:40:53 -0700 Subject: [PATCH 01/42] install: bump unsloth floor to >=2026.5.5 (#5621) --- install.ps1 | 10 +++++----- install.sh | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/install.ps1 b/install.ps1 index 5e3d4b6a50..012d22608a 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1300,7 +1300,7 @@ shell.Run cmd, 0, False if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.4" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.5" unsloth-zoo } if ($baseInstallExit -eq 0) { $NoTorchReq = Find-NoTorchRuntimeFile if ($NoTorchReq) { @@ -1308,7 +1308,7 @@ shell.Run cmd, 0, False } } } else { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.4" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.5" unsloth-zoo } } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red @@ -1346,7 +1346,7 @@ shell.Run cmd, 0, False if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.5.4" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.5.5" unsloth-zoo } if ($baseInstallExit -eq 0) { $NoTorchReq = Find-NoTorchRuntimeFile if ($NoTorchReq) { @@ -1354,7 +1354,7 @@ shell.Run cmd, 0, False } } } elseif ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.4" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.5" unsloth-zoo } } else { $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" } } @@ -1382,7 +1382,7 @@ shell.Run cmd, 0, False Write-TauriLog "STEP" "Installing unsloth" substep "installing unsloth (this may take a few minutes)..." if ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.5.4" --torch-backend=auto } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.5.5" --torch-backend=auto } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit) diff --git a/install.sh b/install.sh index cfd76fa945..29acb5e190 100755 --- a/install.sh +++ b/install.sh @@ -1865,7 +1865,7 @@ if [ "$_MIGRATED" = true ]; then # to prevent transitive torch resolution. run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.5.4" unsloth-zoo + "unsloth>=2026.5.5" unsloth-zoo _NO_TORCH_RT="$(_find_no_torch_runtime)" if [ -n "$_NO_TORCH_RT" ]; then run_install_cmd "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT" @@ -1873,7 +1873,7 @@ if [ "$_MIGRATED" = true ]; then else run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.5.4" unsloth-zoo + "unsloth>=2026.5.5" unsloth-zoo fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then substep "overlaying local repo (editable)..." @@ -2041,7 +2041,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --upgrade-package unsloth --upgrade-package unsloth-zoo \ - "unsloth>=2026.5.4" unsloth-zoo + "unsloth>=2026.5.5" unsloth-zoo _NO_TORCH_RT="$(_find_no_torch_runtime)" if [ -n "$_NO_TORCH_RT" ]; then run_install_cmd "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT" @@ -2056,7 +2056,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then fi elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \ - --upgrade-package unsloth "unsloth>=2026.5.4" unsloth-zoo + --upgrade-package unsloth "unsloth>=2026.5.5" unsloth-zoo substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps substep "overlaying unsloth-zoo from git main..." @@ -2088,7 +2088,7 @@ else tauri_log "STEP" "Installing Unsloth" substep "installing unsloth (this may take a few minutes)..." if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.5.4" --torch-backend=auto + run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.5.5" --torch-backend=auto substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps substep "overlaying unsloth-zoo from git main..." From 6c52697ad09682e1f342aa546eeb40ceeedd72df Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Tue, 19 May 2026 19:03:00 +0100 Subject: [PATCH 02/42] Studio: persist chat toggles and preserve custom sampling (#5587) * fix: persist chat toggles and preserve custom sampling * fix: keep Qwen reload sampling aligned with Think state * fix: avoid persisting Qwen reasoning defaults * studio: keep Kimi search off when thinking defaults on * fix: avoid persisting Kimi-enforced chat toggles --- .../frontend/src/features/chat/chat-page.tsx | 35 +++++++++++-- .../chat/hooks/use-chat-model-runtime.ts | 45 +++++++++++++--- .../src/features/chat/shared-composer.tsx | 6 +-- .../chat/stores/chat-runtime-store.ts | 52 +++++++++++++++---- .../src/features/chat/utils/qwen-params.ts | 5 +- 5 files changed, 115 insertions(+), 28 deletions(-) diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 9744b0b017..81a8c8818e 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -67,7 +67,12 @@ import { RegisterCompareHandle, SharedComposer, } from "./shared-composer"; -import { useChatRuntimeStore } from "./stores/chat-runtime-store"; +import { + CHAT_CODE_TOOLS_ENABLED_KEY, + CHAT_TOOLS_ENABLED_KEY, + loadOptionalBool, + useChatRuntimeStore, +} from "./stores/chat-runtime-store"; import { useExternalProvidersStore } from "./stores/external-providers-store"; import { buildChatTourSteps } from "./tour"; import type { ChatView, MessageRecord } from "./types"; @@ -740,6 +745,13 @@ export function ChatPage(): ReactElement { supportsBuiltinWebSearch && (provider?.providerType === "anthropic" || provider?.providerType === "openai"); + const storedToolsEnabled = loadOptionalBool(CHAT_TOOLS_ENABLED_KEY); + const storedCodeToolsEnabled = loadOptionalBool(CHAT_CODE_TOOLS_ENABLED_KEY); + const nextToolsEnabled = supportsBuiltinWebSearch + ? isKimi + ? false + : (storedToolsEnabled ?? searchOnByDefault) + : false; useChatRuntimeStore.setState({ supportsReasoning: reasoningCaps.supportsReasoning, reasoningAlwaysOn: reasoningCaps.reasoningAlwaysOn, @@ -765,8 +777,10 @@ export function ChatPage(): ReactElement { supportsTools: false, supportsBuiltinWebSearch, supportsBuiltinCodeExecution, - toolsEnabled: searchOnByDefault, - codeToolsEnabled: false, + toolsEnabled: nextToolsEnabled, + codeToolsEnabled: supportsBuiltinCodeExecution + ? (storedCodeToolsEnabled ?? false) + : false, }); }, [externalProviders, inferenceParams.checkpoint]); const canCompare = useMemo(() => { @@ -940,6 +954,15 @@ export function ChatPage(): ReactElement { supportsBuiltinWebSearch && (selectedProvider?.providerType === "anthropic" || selectedProvider?.providerType === "openai"); + const storedToolsEnabled = loadOptionalBool(CHAT_TOOLS_ENABLED_KEY); + const storedCodeToolsEnabled = loadOptionalBool( + CHAT_CODE_TOOLS_ENABLED_KEY, + ); + const nextToolsEnabled = supportsBuiltinWebSearch + ? isKimi + ? false + : (storedToolsEnabled ?? searchOnByDefault) + : false; useChatRuntimeStore.setState({ activeGgufVariant: null, ggufContextLength: null, @@ -969,8 +992,10 @@ export function ChatPage(): ReactElement { supportsTools: false, supportsBuiltinWebSearch, supportsBuiltinCodeExecution, - toolsEnabled: searchOnByDefault, - codeToolsEnabled: false, + toolsEnabled: nextToolsEnabled, + codeToolsEnabled: supportsBuiltinCodeExecution + ? (storedCodeToolsEnabled ?? false) + : false, ...(stillOnOpenRouterFree ? {} : { lastOpenRouterChosenModel: null }), }); return; diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index 3f1060edf7..a02c83dbba 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -24,6 +24,8 @@ import { } from "../api/chat-api"; import { formatEta, formatRate } from "../utils/format-transfer"; import { + CHAT_REASONING_ENABLED_KEY, + loadOptionalBool, type ReasoningEffort, useChatRuntimeStore, } from "../stores/chat-runtime-store"; @@ -273,6 +275,10 @@ export function useChatModelRuntime() { } // Restore reasoning/tools support flags and context length + const hydratingExistingModel = + selectedCheckpoint !== statusRes.active_model || + useChatRuntimeStore.getState().activeGgufVariant !== + (statusRes.gguf_variant ?? null); const supportsReasoning = statusRes.supports_reasoning ?? false; const reasoningAlwaysOn = statusRes.reasoning_always_on ?? false; const reasoningStyle = statusRes.reasoning_style ?? "enable_thinking"; @@ -282,6 +288,9 @@ export function useChatModelRuntime() { : (["low", "medium", "high"] as const); const supportsPreserveThinking = statusRes.supports_preserve_thinking ?? false; const supportsTools = statusRes.supports_tools ?? false; + const storedReasoningEnabled = loadOptionalBool( + CHAT_REASONING_ENABLED_KEY, + ); const currentGgufContextLength = statusRes.is_gguf ? (statusRes.context_length ?? null) : null; @@ -363,7 +372,11 @@ export function useChatModelRuntime() { }); // Set reasoning default for Qwen3.5/3.6 small models - if (supportsReasoning) { + if ( + supportsReasoning && + hydratingExistingModel && + storedReasoningEnabled === null + ) { let reasoningDefault = true; const mid = statusRes.active_model.toLowerCase(); if (mid.includes("qwen3.5") || mid.includes("qwen3.6")) { @@ -372,7 +385,7 @@ export function useChatModelRuntime() { reasoningDefault = false; } } - useChatRuntimeStore.getState().setReasoningEnabled(reasoningDefault); + useChatRuntimeStore.setState({ reasoningEnabled: reasoningDefault }); } } else if (!statusRes.active_model && !isExternalSelectionActive) { useChatRuntimeStore.setState({ @@ -466,6 +479,9 @@ export function useChatModelRuntime() { const previousCheckpoint = currentCheckpoint; const previousVariant = useChatRuntimeStore.getState().activeGgufVariant ?? null; + const reloadingSameModel = + previousCheckpoint === modelId && + (ggufVariant ?? null) === (previousVariant ?? null); const previousModel = previousCheckpoint ? models.find((entry) => entry.id === previousCheckpoint) : undefined; @@ -651,6 +667,8 @@ export function useChatModelRuntime() { const keepCustomCtx = null; const reasoningAlwaysOn = loadResponse.reasoning_always_on ?? false; const reasoningStyle = loadResponse.reasoning_style ?? "enable_thinking"; + const supportsReasoning = loadResponse.supports_reasoning ?? false; + const supportsTools = loadResponse.supports_tools ?? false; const reasoningEffortLevels = reasoningStyle === "reasoning_effort" ? (["low", "medium", "high"] as const) @@ -660,23 +678,34 @@ export function useChatModelRuntime() { existingReasoningEffort, ); const ggufMaxContextLength = reportedMaxCtx; + const nextReasoningEnabled = reasoningAlwaysOn + ? true + : reloadingSameModel && supportsReasoning + ? stateBeforeUnload.reasoningEnabled + : reasoningDefault; useChatRuntimeStore.setState({ ggufContextLength: nativeCtx, ggufMaxContextLength, ggufNativeContextLength: reportedNativeCtx, modelRequiresTrustRemoteCode: loadResponse.requires_trust_remote_code ?? false, - supportsReasoning: loadResponse.supports_reasoning ?? false, + supportsReasoning, reasoningAlwaysOn, - reasoningEnabled: reasoningAlwaysOn ? true : reasoningDefault, + reasoningEnabled: nextReasoningEnabled, reasoningStyle, supportsReasoningOff: reasoningStyle !== "reasoning_effort", reasoningEffortLevels, reasoningEffort: clampedReasoningEffort, supportsPreserveThinking: loadResponse.supports_preserve_thinking ?? false, - supportsTools: loadResponse.supports_tools ?? false, - toolsEnabled: loadResponse.supports_tools ?? false, - codeToolsEnabled: loadResponse.supports_tools ?? false, + supportsTools, + toolsEnabled: + reloadingSameModel && supportsTools + ? stateBeforeUnload.toolsEnabled + : supportsTools, + codeToolsEnabled: + reloadingSameModel && supportsTools + ? stateBeforeUnload.codeToolsEnabled + : supportsTools, kvCacheDtype: loadedKv, loadedKvCacheDtype: loadedKv, speculativeType: loadedSpec, @@ -700,7 +729,7 @@ export function useChatModelRuntime() { const mid = modelId.toLowerCase(); const needsPresencePenalty = mid.includes("qwen3.5") || mid.includes("qwen3.6"); - const p = reasoningDefault + const p = nextReasoningEnabled ? { temperature: 0.6, topP: 0.95, diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index 11a73ee486..016260238c 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -944,7 +944,7 @@ export function SharedComposer({ // Mutual exclusion: turning thinking on for a // Kimi model forces the web_search builtin off. if (isKimiExternal && toolsEnabled) { - setToolsEnabled(false); + setToolsEnabled(false, { persist: false }); } }} > @@ -973,7 +973,7 @@ export function SharedComposer({ // requires thinking off, so turning thinking on flips // the Search pill off (and vice versa). if (isKimiExternal && next && toolsEnabled) { - setToolsEnabled(false); + setToolsEnabled(false, { persist: false }); } }} className={cn( @@ -1041,7 +1041,7 @@ export function SharedComposer({ // back on when Search goes off — mutual exclusion that // mirrors what the backend enforces. if (isKimiExternal) { - setReasoningEnabled(!next); + setReasoningEnabled(!next, { persist: false }); applyQwenThinkingParams(!next); } }} 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 e55a25d08d..457f39ac9e 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -22,8 +22,11 @@ const HF_TOKEN_KEY = "unsloth_hf_token"; const INFERENCE_PARAMS_KEY = "unsloth_chat_inference_params"; const CHAT_ACTIVE_PRESET_KEY = "unsloth_chat_active_preset"; const CHAT_ACTIVE_PRESET_SOURCE_KEY = "unsloth_chat_active_preset_source"; +export const CHAT_REASONING_ENABLED_KEY = "unsloth_chat_reasoning_enabled"; const REASONING_EFFORT_KEY = "unsloth_reasoning_effort"; const PRESERVE_THINKING_KEY = "unsloth_preserve_thinking"; +export const CHAT_TOOLS_ENABLED_KEY = "unsloth_chat_tools_enabled"; +export const CHAT_CODE_TOOLS_ENABLED_KEY = "unsloth_chat_code_tools_enabled"; export type ReasoningStyle = "enable_thinking" | "reasoning_effort"; export type ReasoningEffort = @@ -62,13 +65,18 @@ function canUseStorage(): boolean { } function loadBool(key: string, fallback: boolean): boolean { - if (!canUseStorage()) return fallback; + const raw = loadOptionalBool(key); + return raw ?? fallback; +} + +export function loadOptionalBool(key: string): boolean | null { + if (!canUseStorage()) return null; try { const raw = localStorage.getItem(key); - if (raw === null) return fallback; + if (raw === null) return null; return raw === "true"; } catch { - return fallback; + return null; } } @@ -294,12 +302,18 @@ type ChatRuntimeStore = { setActiveThreadId: (threadId: string | null) => void; setSettingsPanelOpen: (open: boolean) => void; clearCheckpoint: () => void; - setReasoningEnabled: (enabled: boolean) => void; + setReasoningEnabled: ( + enabled: boolean, + options?: { persist?: boolean }, + ) => void; setLastOpenRouterChosenModel: (chosen: string | null) => void; setReasoningStyle: (style: ReasoningStyle) => void; setReasoningEffort: (effort: ReasoningEffort) => void; setPreserveThinking: (value: boolean) => void; - setToolsEnabled: (enabled: boolean) => void; + setToolsEnabled: ( + enabled: boolean, + options?: { persist?: boolean }, + ) => void; setCodeToolsEnabled: (enabled: boolean) => void; setToolStatus: (status: string | null) => void; setGeneratingStatus: (status: string | null) => void; @@ -333,7 +347,7 @@ export const useChatRuntimeStore = create((set) => ({ modelRequiresTrustRemoteCode: false, supportsReasoning: false, reasoningAlwaysOn: false, - reasoningEnabled: true, + reasoningEnabled: loadBool(CHAT_REASONING_ENABLED_KEY, true), reasoningStyle: "enable_thinking", reasoningEffort: loadReasoningEffort("medium"), supportsReasoningOff: false, @@ -344,8 +358,8 @@ export const useChatRuntimeStore = create((set) => ({ supportsTools: false, supportsBuiltinWebSearch: false, supportsBuiltinCodeExecution: false, - toolsEnabled: false, - codeToolsEnabled: false, + toolsEnabled: loadBool(CHAT_TOOLS_ENABLED_KEY, false), + codeToolsEnabled: loadBool(CHAT_CODE_TOOLS_ENABLED_KEY, false), toolStatus: null, generatingStatus: null, autoHealToolCalls: loadBool(AUTO_HEAL_TOOL_CALLS_KEY, true), @@ -473,7 +487,13 @@ export const useChatRuntimeStore = create((set) => ({ chatTemplateOverride: null, loadedChatTemplateOverride: null, })), - setReasoningEnabled: (reasoningEnabled) => set({ reasoningEnabled }), + setReasoningEnabled: (reasoningEnabled, options) => + set(() => { + if (options?.persist !== false) { + saveBool(CHAT_REASONING_ENABLED_KEY, reasoningEnabled); + } + return { reasoningEnabled }; + }), setLastOpenRouterChosenModel: (lastOpenRouterChosenModel) => set({ lastOpenRouterChosenModel }), setReasoningStyle: (reasoningStyle) => set({ reasoningStyle }), @@ -493,8 +513,18 @@ export const useChatRuntimeStore = create((set) => ({ saveBool(PRESERVE_THINKING_KEY, preserveThinking); return { preserveThinking }; }), - setToolsEnabled: (toolsEnabled) => set({ toolsEnabled }), - setCodeToolsEnabled: (codeToolsEnabled) => set({ codeToolsEnabled }), + setToolsEnabled: (toolsEnabled, options) => + set(() => { + if (options?.persist !== false) { + saveBool(CHAT_TOOLS_ENABLED_KEY, toolsEnabled); + } + return { toolsEnabled }; + }), + setCodeToolsEnabled: (codeToolsEnabled) => + set(() => { + saveBool(CHAT_CODE_TOOLS_ENABLED_KEY, codeToolsEnabled); + return { codeToolsEnabled }; + }), setToolStatus: (toolStatus) => set({ toolStatus }), setGeneratingStatus: (generatingStatus) => set({ generatingStatus }), setAutoHealToolCalls: (autoHealToolCalls) => diff --git a/studio/frontend/src/features/chat/utils/qwen-params.ts b/studio/frontend/src/features/chat/utils/qwen-params.ts index 5eaa2388da..c6e55b7f4c 100644 --- a/studio/frontend/src/features/chat/utils/qwen-params.ts +++ b/studio/frontend/src/features/chat/utils/qwen-params.ts @@ -14,7 +14,10 @@ import { useChatRuntimeStore } from "../stores/chat-runtime-store"; export function applyQwenThinkingParams(thinkingOn: boolean): void { const store = useChatRuntimeStore.getState(); const checkpoint = store.params.checkpoint?.toLowerCase() ?? ""; - if (!checkpoint.includes("qwen3")) { + if ( + !checkpoint.includes("qwen3") || + store.activePresetSource !== "builtin-default" + ) { return; } const needsPresencePenalty = 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 03/42] 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. +

+
+
- {isCustomProvider ? ( + {isCustomProvider && !supportsRemoteModelCatalog(providerType) ? (
) : availableModels.length === 0 && - !MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES.has(providerType) ? null : ( + !allowsManualModelIdsWithCatalog(providerType) ? null : (
{availableModels.length === 0 ? null : ( <> @@ -1288,8 +1357,8 @@ export function ChatProvidersSettings({ )} - {/* Manual IDs allowed for openrouter only. */} - {MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES.has(providerType) ? ( + {/* Manual IDs allowed alongside catalog load. */} + {allowsManualModelIdsWithCatalog(providerType) ? (
{grouped.length === 0 ? ( -
- No external models configured. +
+ {externalModels.length === 0 ? ( + <> + No models from your connections. Set up in Settings → + Connections. + + ) : ( + "No models match your search." + )}
) : ( grouped.map((group) => ( diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 370d73306c..415d294639 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -775,7 +775,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { ) { toast.error("Connections are disabled.", { description: - "Turn on Enable connections in Settings > Connections to use hosted models.", + "Turn on Enable connections in Settings → Connections to use hosted models.", }); throw new Error("Connections disabled."); } @@ -789,20 +789,20 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { : ""; if (isExternalRequest && !externalProvider) { - toast.error("External provider not found.", { - description: "Open Connections and re-add this provider.", + toast.error("Connection not found.", { + description: "Open Settings → Connections and add it again.", }); - throw new Error("External provider not found."); + throw new Error("Connection not found."); } // Local providers (llama.cpp / vLLM / Ollama) allow an empty key — only block hosted providers. const externalProviderIsCustom = externalProvider ? isCustomProviderType(externalProvider.providerType) : false; if (isExternalRequest && !externalApiKey && !externalProviderIsCustom) { - toast.error("Missing API key for selected external provider.", { - description: "Open Connections and set the API key again.", + toast.error("Missing API key for selected connection.", { + description: "Open Settings → Connections and set the API key again.", }); - throw new Error("Missing external provider API key."); + throw new Error("Missing connection API key."); } const outboundMessages = messages diff --git a/studio/frontend/src/features/chat/chat-providers-dialog.tsx b/studio/frontend/src/features/chat/chat-providers-dialog.tsx index f605e4d0ad..51530b4133 100644 --- a/studio/frontend/src/features/chat/chat-providers-dialog.tsx +++ b/studio/frontend/src/features/chat/chat-providers-dialog.tsx @@ -74,7 +74,7 @@ const PROVIDER_FORM_EASE: [number, number, number, number] = [ ]; const PROVIDER_FORM_DURATION = 0.2; const CUSTOM_PROVIDER_MISSING_KEY_MESSAGE = - "No API key found, please make sure API key is added and valid for this provider."; + "No API key found. Add a valid API key for this connection."; const ANTHROPIC_DATED_SNAPSHOT_SUFFIX = /-\d{8}$/; const OPENAI_DEPRECATED_MODELS = new Set(["gpt-5.3"]); const HIDDEN_PROVIDER_TYPES = new Set(["qwen"]); @@ -166,7 +166,7 @@ function emptyCatalogHint(providerType: string): { } { return ( EMPTY_CATALOG_HINTS[providerType] ?? { - title: "No models returned by this provider.", + title: "No models returned by this connection.", description: "Enter model IDs manually below, or check the server.", } ); @@ -290,7 +290,7 @@ export function ChatProvidersSettings({ isManualModelList && isCustomProvider ? "This connection uses manual model IDs" : isCuratedModelList - ? "Full catalog is not fetched for this provider" + ? "Full catalog is not fetched for this connection" : missingModelCatalogBaseUrl ? "Enter a Base URL before loading models" : missingModelCatalogApiKey @@ -414,7 +414,7 @@ export function ChatProvidersSettings({ } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; - toast.error(`Failed to load providers: ${message}`); + toast.error(`Failed to load connections: ${message}`); } finally { if (isMounted) { setRegistryLoading(false); @@ -514,7 +514,7 @@ export function ChatProvidersSettings({ async function loadModels() { if (!providerType) { - toast.error("Choose a provider first."); + toast.error("Choose a connection first."); return; } if (isCustomProvider && !supportsRemoteModelCatalog(providerType)) { @@ -523,7 +523,7 @@ export function ChatProvidersSettings({ } if (isCuratedModelList) { toast.info( - "This provider has a very large model catalog. Use the suggestions and add model IDs manually — full list is not fetched.", + "This connection has a very large model catalog. Use the suggestions and add model IDs manually — full list is not fetched.", ); return; } @@ -591,7 +591,7 @@ export function ChatProvidersSettings({ async function addProvider() { if (!providerType) { - toast.error("Choose a provider first."); + toast.error("Choose a connection first."); return; } const backendProviderType = toExternalBackendProviderType(providerType); @@ -688,10 +688,10 @@ export function ChatProvidersSettings({ ]); resetForm(); setPage("list"); - toast.success("Provider added."); + toast.success("Connection added."); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; - toast.error(`Failed to add provider: ${message}`); + toast.error(`Failed to add connection: ${message}`); } finally { setMutatingProvider(false); } @@ -703,7 +703,7 @@ export function ChatProvidersSettings({ (provider) => provider.id === editingProviderId, ); if (!existing) { - toast.error("Provider not found."); + toast.error("Connection not found."); return; } const isEditingCustomProvider = @@ -799,12 +799,12 @@ export function ChatProvidersSettings({ : provider, ), ); - toast.success("Provider updated."); + toast.success("Connection updated."); resetForm(); setPage("list"); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; - toast.error(`Failed to update provider: ${message}`); + toast.error(`Failed to update connection: ${message}`); } finally { setMutatingProvider(false); } @@ -890,7 +890,7 @@ export function ChatProvidersSettings({ ); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; - toast.error(`Failed to delete provider: ${message}`); + toast.error(`Failed to delete connection: ${message}`); } finally { setMutatingProvider(false); } @@ -906,7 +906,7 @@ export function ChatProvidersSettings({ return; } await editProvider(provider); - toast.info(`No API key found for ${provider.name}. Add one and save.`); + toast.info(`No API key for ${provider.name}. Add one in Connections and save.`); return; } try { @@ -952,8 +952,8 @@ export function ChatProvidersSettings({ size="icon-sm" className="size-8 rounded-[8px]" onClick={closeForm} - aria-label="Back to providers" - title="Back to providers" + aria-label="Back to connections" + title="Back to connections" > @@ -977,10 +977,10 @@ export function ChatProvidersSettings({ htmlFor="provider-preset" className="text-sm font-medium" > - Provider + Connection

- Supported registry or local OpenAI-compatible connection. + OpenAI, Anthropic, or a compatible local endpoint.

- {editingProviderId ? "Save provider" : "Add provider"} + {editingProviderId ? "Save connection" : "Add connection"}
@@ -1495,7 +1495,7 @@ export function ChatProvidersSettings({ 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. + When off, all connections are disabled.

@@ -1508,20 +1508,20 @@ export function ChatProvidersSettings({ > - Add Provider + Add connection - {providers.length} providers · {totalModels} models + {providers.length} connections · {totalModels} models {providers.length === 0 ? (
- No providers yet + No connections yet - Add an external provider to use hosted models from chat. + Add a connection to use hosted models from chat.
@@ -1583,7 +1583,7 @@ export function ChatProvidersSettings({ className="size-7 rounded-[8px] hover:text-foreground" disabled={mutatingProvider} onClick={() => editProvider(provider)} - title="Edit provider" + title="Edit connection" aria-label={`Edit ${provider.name}`} > @@ -1607,7 +1607,7 @@ export function ChatProvidersSettings({ className="size-7 rounded-[8px] hover:text-destructive" disabled={mutatingProvider} onClick={() => void deleteProvider(provider.id)} - title="Delete provider" + title="Delete connection" aria-label={`Delete ${provider.name}`} > @@ -1644,7 +1644,7 @@ export function ChatProvidersDialog({ Connections - Manage external model connections for chat. + Manage model connections for chat. Date: Thu, 21 May 2026 12:15:12 +0300 Subject: [PATCH 09/42] Remove dead chat suggestions wiring (#5665) --- .../src/features/chat/runtime-provider.tsx | 28 +------------------ 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/studio/frontend/src/features/chat/runtime-provider.tsx b/studio/frontend/src/features/chat/runtime-provider.tsx index 20464834d5..ec08c3b55f 100644 --- a/studio/frontend/src/features/chat/runtime-provider.tsx +++ b/studio/frontend/src/features/chat/runtime-provider.tsx @@ -9,7 +9,6 @@ import { ExportedMessageRepository, type ExportedMessageRepositoryItem, type PendingAttachment, - Suggestions, type LocalRuntimeOptions, type ThreadHistoryAdapter, type ThreadMessage, @@ -58,29 +57,6 @@ import { import { syncExportedRepositoryToDexie } from "./utils/delete-thread-message"; import { getImageInputUnavailableReason } from "./utils/image-input-support"; -const DEFAULT_SUGGESTIONS = [ - { - title: "How do you fine-tune an audio model with Unsloth?", - label: "Audio fine-tuning", - prompt: "How do you fine-tune an audio model with Unsloth?", - }, - { - title: "Create a live weather dashboard in HTML using no API key. Show me the code", - label: "Weather dashboard", - prompt: "Create a live weather dashboard in HTML using no API key. Show me the code", - }, - { - title: "Solve the integral of x·sin(x), and verify it", - label: "Integral", - prompt: "Solve the integral of x·sin(x), and verify it step by step", - }, - { - title: "Draw an SVG of a cute sloth & show the code", - label: "SVG sloth", - prompt: "Draw an SVG of a cute sloth & show the code", - }, -]; - type TitleResponse = { choices?: Array<{ message?: { @@ -1034,9 +1010,7 @@ export function ChatRuntimeProvider({ adapter: createDexieAdapter(modelType, pairId), }); - const aui = useAui({ - suggestions: Suggestions(DEFAULT_SUGGESTIONS), - }); + const aui = useAui({}); return ( From b180ae7dd6a51e418956a5aab36fa93a02872a84 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Thu, 21 May 2026 05:08:59 -0700 Subject: [PATCH 10/42] Adding Connect a Provider README changes --- README.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index dc22804074..3699c50736 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,8 @@ Unsloth Studio (Beta) lets you run and train text, [audio](https://unsloth.ai/do * **[Code execution](https://unsloth.ai/docs/new/studio/chat#code-execution)**: lets LLMs test code in Claude artifacts and sandbox environments * **[API inference endpoint](https://unsloth.ai/docs/basics/api)**: Deploy and run local LLMs in Claude Code, Codex tools with Unsloth * [Auto set inference settings](https://unsloth.ai/docs/new/studio/chat#auto-parameter-tuning) and customize chat templates. -* We work directly with teams behind [gpt-oss](https://docs.unsloth.ai/new/gpt-oss-how-to-run-and-fine-tune#unsloth-fixes-for-gpt-oss), [Qwen3](https://www.reddit.com/r/LocalLLaMA/comments/1kaodxu/qwen3_unsloth_dynamic_ggufs_128k_context_bug_fixes/), [Llama 4](https://github.com/ggml-org/llama.cpp/pull/12889), [Mistral](models/tutorials/devstral-how-to-run-and-fine-tune.md), [Gemma 1-3](https://news.ycombinator.com/item?id=39671146), and [Phi-4](https://unsloth.ai/blog/phi4), where we’ve fixed bugs that improve model accuracy. -* Upload images, audio, PDFs, code, DOCX and more file types to chat with. +* We work directly with teams behind [gpt-oss](https://docs.unsloth.ai/new/gpt-oss-how-to-run-and-fine-tune#unsloth-fixes-for-gpt-oss), [Qwen3](https://www.reddit.com/r/LocalLLaMA/comments/1kaodxu/qwen3_unsloth_dynamic_ggufs_128k_context_bug_fixes/), [Llama 4](https://github.com/ggml-org/llama.cpp/pull/12889), [Mistral](https://huggingface.co/mistralai/Mistral-Medium-3.5-128B/discussions/18), [Gemma 1-3](https://news.ycombinator.com/item?id=39671146), and [Phi-4](https://unsloth.ai/blog/phi4), where we’ve fixed bugs that improve model accuracy. +* Chat with images, audio, PDFs, code, DOCX and more. [Connect API providers](https://unsloth.ai/docs/integrations/connections) (OpenAI, Anthropic) or servers (vLLM, Ollama). ### Training * Train and RL **500+ models** up to **2x faster** with up to **70% less VRAM**, with no accuracy loss. * Custom Triton and mathematical **kernels**. See some collabs we did with [PyTorch](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/fp8-reinforcement-learning) and [Hugging Face](https://unsloth.ai/docs/new/faster-moe). @@ -64,9 +64,8 @@ Unsloth Studio (Beta) works on **Windows, Linux, WSL** and **macOS**. * **CPU:** Supported for Chat and Data Recipes currently * **NVIDIA:** Training works on RTX 30/40/50, Blackwell, DGX Spark, Station and more -* **macOS:** Currently supports chat and Data Recipes. **MLX training** is coming very soon +* **macOS:** Training, MLX and GGUF inference are ALL supported. * **AMD:** Chat + Data works. Train with [Unsloth Core](#unsloth-core-code-based). Studio support is out soon. -* **Coming soon:** Training support for Apple MLX, AMD, and Intel. * **Multi-GPU:** Available now, with a major upgrade on the way #### macOS, Linux, WSL: @@ -82,13 +81,10 @@ irm https://unsloth.ai/install.ps1 | iex ```bash unsloth studio -p 8888 ``` -> For cloud VMs or LAN access, add `-H 0.0.0.0` to bind on all interfaces. +For cloud or global access, add `-H 0.0.0.0`. By default, Unsloth is accessible only locally. #### Update -To update, use the same install commands as above. Or run (does not work on Windows): -```bash -unsloth studio update -``` +To update, use the same install commands above or use `unsloth studio update`. #### Docker Use our [Docker image](https://hub.docker.com/r/unsloth/unsloth) ```unsloth/unsloth``` container. Run: @@ -150,6 +146,8 @@ Read our [guide](https://unsloth.ai/docs/get-started/fine-tuning-llms-guide). Ad - See detailed documentation for Unsloth [here](https://unsloth.ai/docs) ## 🦥 Unsloth News +- **Connections**: Connect any API provider (OpenAI, Anthropic) or server (vLLM, Ollama). [Guide](https://unsloth.ai/docs/integrations/connections) +- **MTP**: Run Qwen3.6 MTP in Unsloth. MTP settings are autoset specific to your hardware. [Guide](https://unsloth.ai/docs/models/qwen3.6#mtp-guide) - **API inference endpoint**: Deploy and run local LLMs in Claude Code, Codex tools. [Guide](https://unsloth.ai/docs/basics/api) - **Qwen3.6**: Qwen3.6-35B-A3B can now be trained and run in Unsloth Studio. [Blog](https://unsloth.ai/docs/models/qwen3.6) - **Gemma 4**: Run and train Google’s new models directly in Unsloth. [Blog](https://unsloth.ai/docs/models/gemma-4) From 966d3cda47ef16902ea480fea6c0e590198a8ad7 Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Thu, 21 May 2026 13:45:05 +0100 Subject: [PATCH 11/42] Studio: Claude Code Anthropic API tool compatibility (#5390) * fix: Claude Code Anthropic API tool compatibility * fix: merge Anthropic server tool selections * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix anthropic /v1/messages server-tool alias misrout * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: harden Anthropic /v1/messages tool validation * fix: dispatch Anthropic server tools by only * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: reject Anthropic client tools missing 'name' at boundary AnthropicTool.name was relaxed to Optional[str] to accommodate server-tool declarations. A client tool with input_schema but no name now parses but is silently dropped by anthropic_tools_to_openai, leaving tool calling disabled. Surface as 400 instead. * fix: reject Anthropic client tools with empty 'name' isinstance(name, str) accepts an empty string, but anthropic_tools_to_openai drops entries via 'if not name', producing the same silent-disable fallthrough the boundary check is meant to prevent. Tighten to also reject empty name. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Co-authored-by: Roland Tannous --- .../core/inference/anthropic_compat.py | 10 +- studio/backend/models/inference.py | 7 +- studio/backend/routes/inference.py | 121 +++++++- .../backend/tests/test_anthropic_messages.py | 288 +++++++++++++++++- 4 files changed, 409 insertions(+), 17 deletions(-) diff --git a/studio/backend/core/inference/anthropic_compat.py b/studio/backend/core/inference/anthropic_compat.py index 263718c540..bc792c3b99 100644 --- a/studio/backend/core/inference/anthropic_compat.py +++ b/studio/backend/core/inference/anthropic_compat.py @@ -152,17 +152,21 @@ def anthropic_messages_to_openai( def anthropic_tools_to_openai(tools: list) -> list[dict]: - """Convert Anthropic tool definitions to OpenAI function-tool format.""" + """Convert Anthropic client tools to OpenAI function-tool format.""" result = [] for t in tools: td = t if isinstance(t, dict) else t.model_dump() + name = td.get("name") + input_schema = td.get("input_schema") + if not name or input_schema is None: + continue result.append( { "type": "function", "function": { - "name": td["name"], + "name": name, "description": td.get("description", ""), - "parameters": td.get("input_schema", {}), + "parameters": input_schema, }, } ) diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index e32d134628..f8bf219846 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -1251,9 +1251,12 @@ class AnthropicMessage(BaseModel): class AnthropicTool(BaseModel): - name: str + # Client tools have input_schema; server tools may only have type/name. + type: Optional[str] = None + name: Optional[str] = None description: Optional[str] = None - input_schema: dict + input_schema: Optional[dict] = None + model_config = {"extra": "allow"} class AnthropicMessagesRequest(BaseModel): diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 1b4e7051b0..cde86998d2 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -67,7 +67,11 @@ def _install_httpcore_asyncgen_silencer() -> None: if ( isinstance(exc_value, RuntimeError) and "HTTP11ConnectionByteStream" in obj_repr - and ("cancel scope" in str(exc_value) or "GeneratorExit" in str(exc_value)) + and ( + "cancel scope" in str(exc_value) + or "GeneratorExit" in str(exc_value) + or "no running event loop" in str(exc_value) + ) ): return prior_hook(unraisable) @@ -4215,6 +4219,49 @@ async def openai_responses( # ===================================================================== +_STUDIO_ANTHROPIC_TOOL_ALIASES = { + "web_search": "web_search", + "web_search_20250305": "web_search", + "web_fetch": "web_search", + "web_fetch_20250910": "web_search", + "web_fetch_20260209": "web_search", + "python": "python", + "terminal": "terminal", +} + + +def _anthropic_requested_studio_tools(tools: Optional[list]) -> set[str]: + requested: set[str] = set() + for tool in tools or []: + td = tool if isinstance(tool, dict) else tool.model_dump() + # Client tools always carry input_schema; server tools never do. + if td.get("input_schema") is not None: + continue + # Anthropic dispatches server tools by `type` (not by bare `name`); + # matching name too would let a malformed client tool like + # `{"name": "python"}` silently flip into server-execution mode. + type_ = td.get("type") + if isinstance(type_, str) and type_ in _STUDIO_ANTHROPIC_TOOL_ALIASES: + requested.add(_STUDIO_ANTHROPIC_TOOL_ALIASES[type_]) + return requested + + +def _select_anthropic_server_tools( + all_tools: list[dict], + requested_studio_tools: set[str], + enabled_tools: Optional[list[str]], +) -> list[dict]: + """Select Studio tools requested through Anthropic tools and extensions.""" + if not requested_studio_tools and enabled_tools is None: + return all_tools + + selected_names = set(requested_studio_tools) + if enabled_tools is not None: + selected_names.update(enabled_tools) + + return [tool for tool in all_tools if tool["function"]["name"] in selected_names] + + def _normalize_anthropic_openai_images( openai_messages: list[dict], is_vision: bool ) -> bool: @@ -4338,21 +4385,74 @@ async def anthropic_messages( # 3. neither → plain chat # Server-side agentic loop doesn't support multimodal input — matches # the `not image_b64` gate in /v1/chat/completions. + requested_studio_tools = _anthropic_requested_studio_tools(payload.tools) + + # Reject malformed client tools at the boundary. AnthropicTool was + # relaxed to Optional[name]/Optional[input_schema] for server tools, + # so the converter silently drops incomplete entries — surface them + # as 400. A `type` field marks a server-tool declaration per spec + # (unrecognized server tools are accepted as no-ops); anything else + # without input_schema or name is malformed and must not be allowed + # to silently flip execution mode or disable tool calling. + for tool in payload.tools or []: + td = tool if isinstance(tool, dict) else tool.model_dump() + name, type_, schema = td.get("name"), td.get("type"), td.get("input_schema") + if schema is None and not isinstance(type_, str): + raise HTTPException( + status_code = 400, + detail = f"Tool {name!r} is missing required field 'input_schema'.", + ) + if schema is not None and (not isinstance(name, str) or not name): + raise HTTPException( + status_code = 400, + detail = "Client tool is missing required field 'name'.", + ) + + # Detect client tools from the raw payload (presence of input_schema) + # so the mixed-mode check below isn't fooled by a name collision with + # a server-tool alias that the post-filter would silently drop. + _has_client_tool = any( + (t if isinstance(t, dict) else t.model_dump()).get("input_schema") is not None + for t in payload.tools or [] + ) + + # The server-tool agentic loop executes tools in-process and cannot + # relay unknown client functions back to the caller, so mixed requests + # would silently drop the client tools. Reject explicitly instead. + if requested_studio_tools and _has_client_tool: + raise HTTPException( + status_code = 400, + detail = ( + "Mixing Anthropic server tools (e.g. web_search_20250305) " + "with custom client tools in a single request is not " + "supported. Send them in separate requests." + ), + ) + + openai_client_tools = [ + tool + for tool in anthropic_tools_to_openai(payload.tools or []) + if tool.get("function", {}).get("name") not in requested_studio_tools + ] + + # An Anthropic server-tool declaration implies server-tool mode, but + # only when tools aren't explicitly disabled (CLI --disable-tools or + # per-request enable_tools=false). Explicit False always wins. + _enable = _effective_enable_tools(payload) server_tools = ( - _effective_enable_tools(payload) + (_enable or (_enable is None and bool(requested_studio_tools))) and llama_backend.supports_tools and not _has_image ) client_tools = ( not server_tools - and payload.tools - and len(payload.tools) > 0 + and len(openai_client_tools) > 0 and llama_backend.supports_tools ) # ── Client-side pass-through path ───────────────────────── if client_tools: - openai_tools = anthropic_tools_to_openai(payload.tools) + openai_tools = openai_client_tools if payload.stream: return await _anthropic_passthrough_stream( @@ -4395,12 +4495,11 @@ async def anthropic_messages( if server_tools: from core.inference.tools import ALL_TOOLS - if payload.enabled_tools is not None: - openai_tools = [ - t for t in ALL_TOOLS if t["function"]["name"] in payload.enabled_tools - ] - else: - openai_tools = ALL_TOOLS + openai_tools = _select_anthropic_server_tools( + ALL_TOOLS, + requested_studio_tools, + payload.enabled_tools, + ) # Build tool-use system prompt nudge (same logic as /chat/completions) _tool_names = {t["function"]["name"] for t in openai_tools} diff --git a/studio/backend/tests/test_anthropic_messages.py b/studio/backend/tests/test_anthropic_messages.py index 0825ef9337..842429d5af 100644 --- a/studio/backend/tests/test_anthropic_messages.py +++ b/studio/backend/tests/test_anthropic_messages.py @@ -34,10 +34,18 @@ from core.inference.anthropic_compat import ( AnthropicStreamEmitter, AnthropicPassthroughEmitter, ) -from routes.inference import _normalize_anthropic_openai_images +from routes.inference import ( + _normalize_anthropic_openai_images, + _select_anthropic_server_tools, + _anthropic_requested_studio_tools, + anthropic_messages, +) +from state.tool_policy import reset_tool_policy, set_tool_policy from fastapi import HTTPException +import asyncio import base64 as _b64 from io import BytesIO as _BytesIO +from types import SimpleNamespace # ===================================================================== @@ -78,6 +86,17 @@ class TestAnthropicModels: assert len(req.tools) == 1 assert req.tools[0].name == "web_search" + def test_server_tool_field_parses(self): + req = AnthropicMessagesRequest( + max_tokens = 100, + messages = [{"role": "user", "content": "Hi"}], + tools = [{"type": "web_fetch_20250910", "name": "web_fetch"}], + ) + assert len(req.tools) == 1 + assert req.tools[0].type == "web_fetch_20250910" + assert req.tools[0].name == "web_fetch" + assert req.tools[0].input_schema is None + def test_extra_fields_accepted(self): req = AnthropicMessagesRequest( max_tokens = 100, @@ -424,6 +443,31 @@ class TestAnthropicToolsToOpenAI: def test_empty_list(self): assert anthropic_tools_to_openai([]) == [] + def test_server_tools_are_not_converted_to_openai_functions(self): + tools = [ + {"type": "web_fetch_20250910", "name": "web_fetch"}, + {"type": "web_search_20250305", "name": "web_search"}, + ] + assert anthropic_tools_to_openai(tools) == [] + + def test_server_tool_selection_merges_enabled_tools_extension(self): + all_tools = [ + {"type": "function", "function": {"name": "web_search"}}, + {"type": "function", "function": {"name": "python"}}, + {"type": "function", "function": {"name": "terminal"}}, + ] + + result = _select_anthropic_server_tools( + all_tools, + requested_studio_tools = {"web_search"}, + enabled_tools = ["python"], + ) + + assert [tool["function"]["name"] for tool in result] == [ + "web_search", + "python", + ] + def test_pydantic_model_input(self): tool = AnthropicTool( name = "test", description = "desc", input_schema = {"type": "object"} @@ -1011,3 +1055,245 @@ class TestNormalizeAnthropicOpenAIImages: with pytest.raises(HTTPException) as exc: _normalize_anthropic_openai_images(msgs, is_vision = True) assert exc.value.status_code == 400 + + +# ===================================================================== +# Studio-tool alias detection (/v1/messages tool routing) +# ===================================================================== + + +class TestAnthropicRequestedStudioTools: + def test_recognizes_server_tool_by_type(self): + tools = [{"type": "web_search_20250305", "name": "web_search"}] + assert _anthropic_requested_studio_tools(tools) == {"web_search"} + + def test_bare_name_without_type_is_not_treated_as_server_tool(self): + # Anthropic dispatches server tools by `type`; bare-name matching + # would let a malformed client tool (e.g. user forgot input_schema) + # silently flip the request into server-execution mode. + tools = [{"name": "python"}] + assert _anthropic_requested_studio_tools(tools) == set() + + def test_client_tool_named_python_is_not_misclassified(self): + # input_schema is the client-tool discriminator; presence of it + # must prevent the name from being treated as a Studio alias. + tools = [ + { + "name": "python", + "description": "user's own python", + "input_schema": {"type": "object"}, + } + ] + assert _anthropic_requested_studio_tools(tools) == set() + + def test_mixed_request_only_extracts_server_tools(self): + tools = [ + {"type": "web_search_20250305", "name": "web_search"}, + {"name": "custom_tool", "input_schema": {"type": "object"}}, + ] + assert _anthropic_requested_studio_tools(tools) == {"web_search"} + + def test_pydantic_model_input(self): + tools = [ + AnthropicTool(type = "web_fetch_20250910", name = "web_fetch"), + AnthropicTool(name = "x", input_schema = {"type": "object"}), + ] + assert _anthropic_requested_studio_tools(tools) == {"web_search"} + + def test_empty_and_none(self): + assert _anthropic_requested_studio_tools(None) == set() + assert _anthropic_requested_studio_tools([]) == set() + + +# ===================================================================== +# Route-level tool routing (/v1/messages) +# ===================================================================== + + +class _PlainPathCalled(Exception): + pass + + +class _ToolPathCalled(Exception): + pass + + +def _mock_backend(monkeypatch, **overrides): + """Install a minimal stub backend on routes.inference. + + Generation methods raise sentinel exceptions so the caller can assert + which path the route entered. + """ + import routes.inference as inf_mod + + def _gen_plain(**kwargs): + raise _PlainPathCalled() + + def _gen_tools(**kwargs): + raise _ToolPathCalled() + + backend = SimpleNamespace( + is_loaded = True, + is_vision = False, + supports_tools = True, + model_identifier = "test-model", + generate_chat_completion = _gen_plain, + generate_chat_completion_with_tools = _gen_tools, + ) + backend.__dict__.update(overrides) + monkeypatch.setattr(inf_mod, "get_llama_cpp_backend", lambda: backend) + return backend + + +def _drive(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +def _basic_payload(**fields) -> AnthropicMessagesRequest: + base = { + "max_tokens": 16, + "messages": [{"role": "user", "content": "hi"}], + } + base.update(fields) + return AnthropicMessagesRequest(**base) + + +@pytest.fixture(autouse = True) +def _reset_policy(): + reset_tool_policy() + yield + reset_tool_policy() + + +class TestAnthropicMessagesToolRouting: + def test_mixed_server_and_client_tools_rejected_with_400(self, monkeypatch): + _mock_backend(monkeypatch) + payload = _basic_payload( + tools = [ + {"type": "web_search_20250305", "name": "web_search"}, + {"name": "custom", "input_schema": {"type": "object"}}, + ], + ) + + with pytest.raises(HTTPException) as exc: + _drive(anthropic_messages(payload, request = None, current_subject = "t")) + assert exc.value.status_code == 400 + assert "Mixing Anthropic server tools" in exc.value.detail + + def test_mixed_rejected_when_client_tool_name_collides_with_server_alias( + self, monkeypatch + ): + # Regression: a client tool sharing a name with a mapped server + # tool (e.g. user defines their own "web_search") must still + # trigger the mixed-mode 400 — the post-name filter would + # otherwise drop the client tool and silently route to server-only. + _mock_backend(monkeypatch) + payload = _basic_payload( + tools = [ + {"type": "web_search_20250305", "name": "web_search"}, + {"name": "web_search", "input_schema": {"type": "object"}}, + ], + ) + + with pytest.raises(HTTPException) as exc: + _drive(anthropic_messages(payload, request = None, current_subject = "t")) + assert exc.value.status_code == 400 + assert "Mixing Anthropic server tools" in exc.value.detail + + def test_client_tool_missing_input_schema_rejected_with_400(self, monkeypatch): + _mock_backend(monkeypatch) + payload = _basic_payload( + tools = [{"name": "my_tool", "description": "oops, schema typo"}], + ) + + with pytest.raises(HTTPException) as exc: + _drive(anthropic_messages(payload, request = None, current_subject = "t")) + assert exc.value.status_code == 400 + assert "input_schema" in exc.value.detail + + def test_client_tool_missing_name_rejected_with_400(self, monkeypatch): + # Regression: AnthropicTool.name was relaxed to Optional for server + # tools, so a client-tool payload that has input_schema but omits + # `name` (e.g. typo) now parses successfully but would be silently + # dropped by anthropic_tools_to_openai, leaving the request with + # tool calling disabled. Reject at the boundary instead. + _mock_backend(monkeypatch) + payload = _basic_payload( + tools = [{"input_schema": {"type": "object"}}], + ) + + with pytest.raises(HTTPException) as exc: + _drive(anthropic_messages(payload, request = None, current_subject = "t")) + assert exc.value.status_code == 400 + assert "name" in exc.value.detail + + def test_client_tool_empty_name_rejected_with_400(self, monkeypatch): + # Same silent-disable class as missing-name: `name: ""` passes the + # isinstance check but is dropped by anthropic_tools_to_openai's + # `if not name` guard. Reject at the boundary so the typo surfaces. + _mock_backend(monkeypatch) + payload = _basic_payload( + tools = [{"name": "", "input_schema": {"type": "object"}}], + ) + + with pytest.raises(HTTPException) as exc: + _drive(anthropic_messages(payload, request = None, current_subject = "t")) + assert exc.value.status_code == 400 + assert "name" in exc.value.detail + + def test_alias_named_client_tool_without_schema_rejected_with_400( + self, monkeypatch + ): + # Regression: a typo'd client tool whose name happens to collide + # with a Studio alias (e.g. user meant a custom "python" tool but + # forgot input_schema) must surface a 400, not silently switch + # the request into Studio's built-in python execution. + _mock_backend(monkeypatch) + payload = _basic_payload(tools = [{"name": "python"}]) + + with pytest.raises(HTTPException) as exc: + _drive(anthropic_messages(payload, request = None, current_subject = "t")) + assert exc.value.status_code == 400 + assert "input_schema" in exc.value.detail + + def test_unrecognized_server_tool_accepted_as_noop(self, monkeypatch): + _mock_backend(monkeypatch) + payload = _basic_payload( + tools = [{"type": "code_execution_20250825", "name": "code_execution"}], + ) + + with pytest.raises(_PlainPathCalled): + _drive(anthropic_messages(payload, request = None, current_subject = "t")) + + def test_disable_tools_policy_overrides_server_tool_alias(self, monkeypatch): + # CLI `unsloth run --disable-tools` sets policy=False. A request + # carrying a Studio server-tool alias must NOT enter the agentic + # loop in that configuration. + _mock_backend(monkeypatch) + set_tool_policy(False) + payload = _basic_payload( + tools = [{"type": "web_search_20250305", "name": "web_search"}], + ) + + with pytest.raises(_PlainPathCalled): + _drive(anthropic_messages(payload, request = None, current_subject = "t")) + + def test_server_tool_alias_enters_tool_path_when_policy_unset(self, monkeypatch): + # Mirror of the previous test for the default (None) policy. + _mock_backend(monkeypatch) + payload = _basic_payload( + tools = [{"type": "web_search_20250305", "name": "web_search"}], + ) + + with pytest.raises(_ToolPathCalled): + _drive(anthropic_messages(payload, request = None, current_subject = "t")) + + def test_per_request_enable_tools_false_blocks_server_tool_alias(self, monkeypatch): + _mock_backend(monkeypatch) + payload = _basic_payload( + enable_tools = False, + tools = [{"type": "web_search_20250305", "name": "web_search"}], + ) + + with pytest.raises(_PlainPathCalled): + _drive(anthropic_messages(payload, request = None, current_subject = "t")) From 43beeae39d27b932dcde2435413d3359bc36ef9a Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 22 May 2026 04:30:32 -0700 Subject: [PATCH 12/42] studio/frontend: friendlier 404 fallback for unknown routes (#5664) * studio/frontend: friendlier 404 fallback for unknown routes TanStack Router defaults to a bare "Not Found" string when no route matches. With Studio's root layout that string sits alone in the main content area while the sidebar still renders, which looks broken when the user hits a typo'd path, a stale share link, or a chat URL with an extra path segment. Provide a small DefaultNotFound component to createRouter: sloth mascot, "Page not found" heading, the offending pathname, and a Back to chat button. Studio chrome continues to render around it, so the user gets the same sidebar nav for free. Resolves #5663. * studio/frontend: 404 fallback uses useRouterState + URL-encoded sloth path Address review feedback on #5664: - Read pathname via useRouterState({ select: s => s.location.pathname }) instead of window.location.pathname. Matches the pattern already used in __root.tsx, drops the window-typeof guard, and stays consistent with the router store on subsequent client navigations. - URL-encode the sloth mascot src so the space-containing path resolves cleanly without relying on the browser to encode it. - Add break-all on the pathname paragraph so long offending URLs wrap instead of pushing the card wider than the viewport. --------- Co-authored-by: danielhanchen --- studio/frontend/src/app/router.tsx | 32 ++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/studio/frontend/src/app/router.tsx b/studio/frontend/src/app/router.tsx index d26d8b9dee..c7bc0440bd 100644 --- a/studio/frontend/src/app/router.tsx +++ b/studio/frontend/src/app/router.tsx @@ -1,7 +1,8 @@ // 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 { createRouter } from "@tanstack/react-router"; +import { Link, createRouter, useRouterState } from "@tanstack/react-router"; +import { Button } from "@/components/ui/button"; import { Route as rootRoute } from "./routes/__root"; import { Route as dataRecipesRoute } from "./routes/data-recipes"; import { Route as dataRecipeRoute } from "./routes/data-recipes.$recipeId"; @@ -29,7 +30,34 @@ const routeTree = rootRoute.addChildren([ dataRecipeRoute, ]); -export const router = createRouter({ routeTree }); +function DefaultNotFound() { + const pathname = useRouterState({ select: (s) => s.location.pathname }); + return ( +
+ Sloth mascot +
+

+ Page not found +

+

+ {pathname} does not exist. +

+
+ +
+ ); +} + +export const router = createRouter({ + routeTree, + defaultNotFoundComponent: DefaultNotFound, +}); declare module "@tanstack/react-router" { interface Register { From 1a2183972543e26561d3f4cdda982e67b6da3669 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 04:46:20 -0700 Subject: [PATCH 13/42] chore(deps): bump the npm-oxc-validator group across 1 directory with 2 updates (#5667) Bumps the npm-oxc-validator group with 2 updates in the /studio/backend/core/data_recipe/oxc-validator directory: [oxc-parser](https://github.com/oxc-project/oxc/tree/HEAD/napi/parser) and [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint). Updates `oxc-parser` from 0.123.0 to 0.131.0 - [Release notes](https://github.com/oxc-project/oxc/releases) - [Changelog](https://github.com/oxc-project/oxc/blob/main/napi/parser/CHANGELOG.md) - [Commits](https://github.com/oxc-project/oxc/commits/crates_v0.131.0/napi/parser) Updates `oxlint` from 1.64.0 to 1.65.0 - [Release notes](https://github.com/oxc-project/oxc/releases) - [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md) - [Commits](https://github.com/oxc-project/oxc/commits/oxlint_v1.65.0/npm/oxlint) --- updated-dependencies: - dependency-name: oxc-parser dependency-version: 0.131.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-oxc-validator - dependency-name: oxlint dependency-version: 1.65.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-oxc-validator ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../oxc-validator/package-lock.json | 345 +++++++++--------- .../data_recipe/oxc-validator/package.json | 4 +- 2 files changed, 174 insertions(+), 175 deletions(-) diff --git a/studio/backend/core/data_recipe/oxc-validator/package-lock.json b/studio/backend/core/data_recipe/oxc-validator/package-lock.json index bb2ae29b23..1e630cfd7e 100644 --- a/studio/backend/core/data_recipe/oxc-validator/package-lock.json +++ b/studio/backend/core/data_recipe/oxc-validator/package-lock.json @@ -8,8 +8,8 @@ "name": "unsloth-oxc-validator-runtime", "version": "0.0.1", "dependencies": { - "oxc-parser": "^0.123.0", - "oxlint": "^1.51.0" + "oxc-parser": "^0.131.0", + "oxlint": "^1.65.0" } }, "node_modules/@emnapi/core": { @@ -18,7 +18,6 @@ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" @@ -30,7 +29,6 @@ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -41,7 +39,6 @@ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -65,9 +62,9 @@ } }, "node_modules/@oxc-parser/binding-android-arm-eabi": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.123.0.tgz", - "integrity": "sha512-EHQ58z+6DbZWokMOKg5AB1KuwrXVgfbBLuuLFfzdc7bI5A4igvdvjKMhUv1VBV+0FABiUCOjNKUmMF7ugprwbQ==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.131.0.tgz", + "integrity": "sha512-t2xicr9pfzkSRYx5aPqZqlLaayIwJTqgQ81Jor31Xep2nGyL2Aq3d0K5wOfeR7VevaSdxaS9dzSQP9xDwn8fDg==", "cpu": [ "arm" ], @@ -81,9 +78,9 @@ } }, "node_modules/@oxc-parser/binding-android-arm64": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.123.0.tgz", - "integrity": "sha512-BK1E0zqNoHf38nTHjnGZ+olKHSKNHh65pChjY06yhaWYP8X7yNDqhQDA4neMPRqnPBgpN4/OW1oSMrdJgDi2aw==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.131.0.tgz", + "integrity": "sha512-nlGIod6gw75x1aEDgLS+srj+JRGY0HHm9MI9YgzE/B64l6d6+H3MSP9NOgp0+HTg8tp4vV9rVfgQGgd+TfVZcA==", "cpu": [ "arm64" ], @@ -97,9 +94,9 @@ } }, "node_modules/@oxc-parser/binding-darwin-arm64": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.123.0.tgz", - "integrity": "sha512-dkMPbtTbqU+cm+k4YGOBs4zAuq3Xu+wqjbGQvLAuVO7qHhNY4p5LBNudOmOoi0jxS8h1W6Jmlzv8MAKGpK+iDg==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.131.0.tgz", + "integrity": "sha512-jukuV6xe5RbQKFo7QD34NDCLDZp4PSOm8rmckhNdH/60ymG5zXbDzGBEyc+nTkuLQNama2aSGCt+CPfpjNTqyw==", "cpu": [ "arm64" ], @@ -113,9 +110,9 @@ } }, "node_modules/@oxc-parser/binding-darwin-x64": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.123.0.tgz", - "integrity": "sha512-85pic0rCd59DGdM69jI9xE/Snb2KtrfiU48QigjJXjzxUOenGvH4SAFIjFpO/2ZnI3Kz50D8pht4jKN3t2022Q==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.131.0.tgz", + "integrity": "sha512-g3JOo4khe9rslHm5WYaVDWb0HS/M1MLR3I9S8560MkKIcC96VQY00QjOlsuRyfSj/JDXj8i9T7ryPO2RidiXVg==", "cpu": [ "x64" ], @@ -129,9 +126,9 @@ } }, "node_modules/@oxc-parser/binding-freebsd-x64": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.123.0.tgz", - "integrity": "sha512-mjEiW6z7JtaiHMK/8aJic1lfjkKpzFwK2XFNmm187BFbtDamjGVuKNr2TEyrFEYJyZc217wokR1wrYeZGBQo4Q==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.131.0.tgz", + "integrity": "sha512-1hziITDTxjMePnX+dR9ocVT+EuZkQ8wm4FPAbmbEiKG+Phbo73J1ZnPAA6Y/aGsWF3McOFnQuZIktAFwalkfJQ==", "cpu": [ "x64" ], @@ -145,9 +142,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.123.0.tgz", - "integrity": "sha512-mYxigPtGt6SZfhNZBIJfuDM92cLo8XUW08WuKxzHvcmWu6xndLqwLp99Vg4uHke1AXicQEHU3Wri2X9bHF0Vlw==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.131.0.tgz", + "integrity": "sha512-9uRxfXwyKG9+MwmGQBo2ncPNwZH5HTmCETFM2WiuDBNDCW4NC5ttSQkwCAMrTAWgwMzVBH1CP8pM0v7nebCWXQ==", "cpu": [ "arm" ], @@ -161,9 +158,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.123.0.tgz", - "integrity": "sha512-ttWirDC9eUBn0R4Tzz3aeDaLrx9drPdNiLJ8MXeDBFxd6cwLfTIC27qjsdfGpn942tkVIZY3sjWAnvbwDDjX7g==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.131.0.tgz", + "integrity": "sha512-mgbLvzRShXOLBdWGInf08Af4q+pfj1xD8hSgLClDZ9of/BXkB6+LIhTH7fihiDUipqB3yoSkKBWaZ3Ejlf5Yag==", "cpu": [ "arm" ], @@ -177,9 +174,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm64-gnu": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.123.0.tgz", - "integrity": "sha512-apAHyoMNRYT+2G98Y14caZmsr5LD9PsWpGI7nXmSwK26LGiQneCU6HvHQ+d+AX+RJ5TTWZtEb2RD7OLqAC0cYQ==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.131.0.tgz", + "integrity": "sha512-OPT8++4aN6j2GJ8+3IZHS/byXoZP4aSBn+FoG6rgBJ2fKwPKXWF3MqrFMNW7NKHM28FLY579xYLxJSfgobEqPA==", "cpu": [ "arm64" ], @@ -193,9 +190,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm64-musl": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.123.0.tgz", - "integrity": "sha512-3r99Qa4egjO/iXUBxTlN6Ddt1YkLifG6olzvj8gkoKEK2U/MOW7mQfXRyBmuoMgmZ7O4vk41gO3d21c6VcN3yQ==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.131.0.tgz", + "integrity": "sha512-vtPiwmfVTAXzaxDKsOXG+LwgRAA7WEnaeHzhS5z0GE89gAK18KSXnly7Z6saXXq6L3dVMyK44uoTI03zKxrpmw==", "cpu": [ "arm64" ], @@ -209,9 +206,9 @@ } }, "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.123.0.tgz", - "integrity": "sha512-Hr/Z24kUE4pjJs346g80WDwjyJGrxiw6hExJuOiME/76ZFz68y5L11UzprRkW9FN4HxBB7tLZ/fytczV2fEsiA==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.131.0.tgz", + "integrity": "sha512-8AW8L7w5cGHSdZPcyZX2yR0+GUODsT15rbRjfdD54rv6DMbtuEB19ysLOpKJlRGfH6UNYNpCHaU1uJWgTWf1/w==", "cpu": [ "ppc64" ], @@ -225,9 +222,9 @@ } }, "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.123.0.tgz", - "integrity": "sha512-sxjbhs+8WXeuoLnZ2rBmQ96gPdq3SCmz24reIltsKLUt1EDMgdaQsr7RqwBphw3QAImkMtlPQfAWDWwZyo0xDg==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.131.0.tgz", + "integrity": "sha512-vvpjkjEOUsPcsYf8evE4MO3aGx9+3wodXEBOicGNnOwTuAik8eBONNkgSdhkGsAblQmfVHJyanRnpxglddTXIA==", "cpu": [ "riscv64" ], @@ -241,9 +238,9 @@ } }, "node_modules/@oxc-parser/binding-linux-riscv64-musl": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.123.0.tgz", - "integrity": "sha512-d6xHHhqldA/W+VC7v8uHs24zM69Ad3HnHQ45h+uuBhCsbZx3d0E0wL2K3uJ5mYKTR6UPMFk9VMXcHWwvg1PRZQ==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.131.0.tgz", + "integrity": "sha512-AqmcNC3fClXX+fxQ6VGEN1667xVFiRBkY0CZmDMSiaeFUsv1+UkBPYYi48IUKcA9/ivvoKNRzQl2I4//kT9F/w==", "cpu": [ "riscv64" ], @@ -257,9 +254,9 @@ } }, "node_modules/@oxc-parser/binding-linux-s390x-gnu": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.123.0.tgz", - "integrity": "sha512-+di9A5wJQlv0VodyhADjJ2rC4geyHY+uhJDl3TFjMgYhhlgLZchi9uHD5mfiUEDWHt1x7/eU2u1ge3LLazZmFw==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.131.0.tgz", + "integrity": "sha512-7d3jOMKy7RSQCcDLIci+ySll2FgsOMl/GiRux4q2JNv0zg4EdhFISa9idvrdN/HEUIQQJNg6dmveUeJl2YErGA==", "cpu": [ "s390x" ], @@ -273,9 +270,9 @@ } }, "node_modules/@oxc-parser/binding-linux-x64-gnu": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.123.0.tgz", - "integrity": "sha512-sh7pw2g/u6LE1TaRRQsV9Kv9+1y+CywaaNwWWP+3bnEPk/L692oTG0hmEviUlawI8v3OGC+AhbjtAD+HXWQAkg==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.131.0.tgz", + "integrity": "sha512-JHK/h95qVqVQ+ITER837kcTdwBDFpFaNnOTYGCP0zdUSX/mLKC7tXOoyrTb6vG7iRPwGlcgBil3v2IjYw1FqJA==", "cpu": [ "x64" ], @@ -289,9 +286,9 @@ } }, "node_modules/@oxc-parser/binding-linux-x64-musl": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.123.0.tgz", - "integrity": "sha512-S+LoD8PiJ639JwIqK1knIeqAyYkeCbLHtAgfapszKX0yVCaYP+aer8dJxL25de9qcDjvYWVrYCkuDZzHmOl2Xw==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.131.0.tgz", + "integrity": "sha512-b2BO82O8azXAyf7EUgOPKu145nWypbNyk07HbU09fkzhm9lEA5oPvaN/M8Nlo7tOErVTa2WOgS4QbOnxAPXdDQ==", "cpu": [ "x64" ], @@ -305,9 +302,9 @@ } }, "node_modules/@oxc-parser/binding-openharmony-arm64": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.123.0.tgz", - "integrity": "sha512-/65vryK11q1I+k+7ukDlwZOxUFCLYsoZBZPGZHyet5bIP5e3D8mV3uCuvpWZ9Hoe6vUZFw/nAfCrX59MeuJPgw==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.131.0.tgz", + "integrity": "sha512-GHO9glZaX7LkX/OGfluEPf1yjg+ehiFbUdowbX6uNWOQhmwKWU4m4+nZ9FJkrHNKuxyI1KKertMdGjVKCApKWA==", "cpu": [ "arm64" ], @@ -321,25 +318,27 @@ } }, "node_modules/@oxc-parser/binding-wasm32-wasi": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.123.0.tgz", - "integrity": "sha512-y4OsMGQiAbZzj2Rq0LEfvhR48rQDvbvqsl/dPdn4tdf+z3H79nZuR+lQ/+KUGjD30vpVGem138sBWHFj9UR+Vg==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.131.0.tgz", + "integrity": "sha512-3SkikPaEFoih1N83qLVEDLRLeY4nYsf6JT9SnWiMCQ5lGQdKup6bEuKCqkRiG9dD1IIaFeYz9RjlciPmYoFIWA==", "cpu": [ "wasm32" ], "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.2" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@oxc-parser/binding-win32-arm64-msvc": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.123.0.tgz", - "integrity": "sha512-9lBqI6AXAkjYavkdpizNU3Q51uoVYfp9FJPx19hnCEdPku1jSgzSnvgmCvhCue0GziIvIvIdWgZ41wXQ3EOoBw==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.131.0.tgz", + "integrity": "sha512-Os5bEhryeA2jkH+ZrnZyAC1EP5gs+X4YB1Fjqml7UPD5kU7ecsK1MPEVMfCrdt/GDNpDbavYXiOXOdyJ5b3OPw==", "cpu": [ "arm64" ], @@ -353,9 +352,9 @@ } }, "node_modules/@oxc-parser/binding-win32-ia32-msvc": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.123.0.tgz", - "integrity": "sha512-zJbqBHwSUB7CyvAONy9ewGtQwcQj+ylOhYGETvUPp3KIYx7lolj4Gayof7iA22SU5eMSjO5COL0c8wYhmn9agA==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.131.0.tgz", + "integrity": "sha512-m+jNz9EuF0NXoiptc6B9h5yompZQVW/a5MJeOu5zojfH5yWk82tvF2ccrHkfhgtrS9h9DD5l1Qv8dWlfY7Nz8g==", "cpu": [ "ia32" ], @@ -369,9 +368,9 @@ } }, "node_modules/@oxc-parser/binding-win32-x64-msvc": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.123.0.tgz", - "integrity": "sha512-q7RZvglQvGo3RX5ljtcGSabu2B2c0oDU/6xC3sBMhsV5KRo0PvyxLdordbEN31NTfuZu4Sgl86C76cAURZIHWA==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.131.0.tgz", + "integrity": "sha512-o14Hk8dAyiEUMFEWEgmAwFZvBt1RzAYLM3xeQ+5315JXgVYhoemivgYcbYVRbsFkS71ShMGlAFE0kPnr460rww==", "cpu": [ "x64" ], @@ -385,18 +384,18 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz", - "integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.131.0.tgz", + "integrity": "sha512-PgnWDfV0h+b16XNKbXU7Daib/BFSt/J2mEzfYIBu6JB/wNdlU+kVYXCkGA1A9fWkTbOgbjh4e6NhPeQOYvFhEA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" } }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.64.0.tgz", - "integrity": "sha512-2r6Nq3XXGLHEXKkSj8JtmJ6N4gDw431DPFOg0ZoJHlNjnG6HVMm/ksQ10m0HJ8WBvwgMe1L50UHPaYZutCRPCw==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.65.0.tgz", + "integrity": "sha512-jDVaGNURT5pEA9qcabh6WusIoBNybOMMDPCx+EFt+gxo6rVvoUf0+73Xy5x81+ZrxU+ewk5uRBYifjy5pgkcnA==", "cpu": [ "arm" ], @@ -410,9 +409,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.64.0.tgz", - "integrity": "sha512-ePJMpePgg7fBv+L/hVx1xXRU5/5gd5m0obLA6hPEfLXF3GjpR8idIDbY1dhQYhyz1ms2wdTccSboo6KEd2Oxtg==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.65.0.tgz", + "integrity": "sha512-v0z80IWNA7c9RhUydq9YprBxCVZrQ6Ixls2tdxUC1F/1FFqSfa7xTX+EJf0mj6+BKRg2zWXqWfcbJUnETlLlIw==", "cpu": [ "arm64" ], @@ -426,9 +425,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.64.0.tgz", - "integrity": "sha512-U4DMLQd10gJLuoSTLSGbfv3bGjTlUNsScm9Dgb8wwBqmCzidf1pE1pXV4doGNxqwH3KtVng1AGTINA0NvkGLvQ==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.65.0.tgz", + "integrity": "sha512-pL/mG/5gMzBwp1gdc5+Cwi87F9j3XRnPxHGyVj5Zd+dCEV5YkKt0L70PB3EGmEEHxgn4H+jnMS3xLuXs6mZW/Q==", "cpu": [ "arm64" ], @@ -442,9 +441,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.64.0.tgz", - "integrity": "sha512-GoRIL48QWm4/TAvjN8pB1nAG+1/uqc9EdnWT9zqHeb6wsmjZtywj8VRe5aGW47Fdb64YtLOsdLqVxOvQuz98Wg==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.65.0.tgz", + "integrity": "sha512-jVTneaeuHtqTrKYnhrdH1buhnSorinvpy1sv43ayclfWx/e/DfdRWv+h1fopJcHQbYr5WMcZMmDvnfEBkPZ+1A==", "cpu": [ "x64" ], @@ -458,9 +457,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.64.0.tgz", - "integrity": "sha512-5dFkv4tkg7PxJJGS9/OjrJwjhuHczrd3OQOkRE0wHcLM+ncUnULtzEPWjqGOxTXxZnLWcB91bGiIznx89TVXyQ==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.65.0.tgz", + "integrity": "sha512-8lJQ7B6RloYDUhwVdbSpwT2eKsCN5KP1Scn18ly1tytCuhXhbs0nkfKHT4jWWZBJqmynWuzd+78bF7wILrj6pw==", "cpu": [ "x64" ], @@ -474,9 +473,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.64.0.tgz", - "integrity": "sha512-jsBqMLl/uOL5+Kq/+BtK9FrmiNGUbx8SiyZXv+WlUxA45KuwcLu9BfiSIL3I3DBDgWM3yZizDITnTK9BcqNBQg==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.65.0.tgz", + "integrity": "sha512-EgmZY+DeWhLLEnNl70/49j3ltA8I6X9kxMfexupWi2Vwfp6RonGsBaHtGoedLolaU37ne7eDUgoxa3CFB95GZA==", "cpu": [ "arm" ], @@ -490,9 +489,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.64.0.tgz", - "integrity": "sha512-1lrj8At/Uuc9GhjrVFBQo0NEjfBrTkzpmtHIGAhNnIXqn1CAyGL+qrztUsXb2GIluJrpl9Q7qRLJOb/NqydacQ==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.65.0.tgz", + "integrity": "sha512-OJMWmAYRVBCPPxnYr3j5sXRwHPh1bAuMlTStGco1Z8q3HkvSH4h+A10E9MiRNYmLhUuli5a2P5wmfj8cagiF5Q==", "cpu": [ "arm" ], @@ -506,9 +505,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.64.0.tgz", - "integrity": "sha512-HpSQbubwh03mMhAdy2BYtad/fsY8vDFHDAb6bUwuCYg2VD3xCQgn6ArKcO0oZyLCheacKTv4PrF3Mfu5hgoE2g==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.65.0.tgz", + "integrity": "sha512-D8uNi50LsYKgS0vGARZDRx05TBZeSxAVdLGddSEqQLSU7xsiqdImHPEw55xq8sKA5rCc/4au/5uS7FQALWdLCg==", "cpu": [ "arm64" ], @@ -522,9 +521,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.64.0.tgz", - "integrity": "sha512-00QQ0h0Y7u0G69BgiH3+ky2aaq/QvkDL6DYok8htIuJHxybiux5aQ8jwmg8qIk9wha6UagUP2BAwAzbemcJbpg==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.65.0.tgz", + "integrity": "sha512-IpbA8QGbwFehQhO+YaHwmoI81f93xvywpspf8HrdPCWOIeKwYfM1dhVhO4YKfZewTRRQEPY/JFjTOXTgkwhKrA==", "cpu": [ "arm64" ], @@ -538,9 +537,9 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.64.0.tgz", - "integrity": "sha512-2GaimTV6EMW+s5HS0An3oGbQme3BgHswvfVdGk3EB57Xe9+/gyT+Qd7lNVzb3rtir52vbIPzXfaYArzs5b5zcw==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.65.0.tgz", + "integrity": "sha512-ZSe8HgaZdgyHSv2+/pTG68z10+OarB18CkFKQOhRs3lmmP/p2vuigedK2e9d0ztoG2DU/duJzhxXBSjy/492HQ==", "cpu": [ "ppc64" ], @@ -554,9 +553,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.64.0.tgz", - "integrity": "sha512-H46AtFb9wypjoVwGdlxrm0DsD809NGmtiK9HiyPKTxkSte2YjhC4S+00rOIrwCaxcyPiGid3Y3OMXp5KMAkGZw==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.65.0.tgz", + "integrity": "sha512-DcTERf++v6HyPHukKAr0JFTRqB+YeDEvqzRgNDMaz7jITPf+tlJIwRxodlAqoXMYhNVEZhXdQM5RAAYH8/oPuw==", "cpu": [ "riscv64" ], @@ -570,9 +569,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.64.0.tgz", - "integrity": "sha512-HEgsidjjvvyzdg82icYkuFCf7REDV7B9JFwbIMbVwrKLBY0MrXX+bku3POn/hduZ2yW91IyVDUMq0Bf02KwXQw==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.65.0.tgz", + "integrity": "sha512-xjhMwuFJwRh40NOBzol4gM5gqAa0xPCJU+GQLM6BydV8TbfkIA7JeyCFNhyfbE9Q/5EWcKYTx62R0cRcjP7DAA==", "cpu": [ "riscv64" ], @@ -586,9 +585,9 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.64.0.tgz", - "integrity": "sha512-Axvm8qryotmKN00P5w4JapaSjvP2LOSbdbBJiX+2SuHd3QzhW7TUc8skqgw+ahQZ5DmzEYeHCqauvW8f32Ns6Q==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.65.0.tgz", + "integrity": "sha512-lrWSXb8JzboPWYBG6Kunt/eemvjo2oCFXktShsm3yMToY7HjzKLjxh7CljSvGnnZH9oohNFHOKc9xYpGKCPm6w==", "cpu": [ "s390x" ], @@ -602,9 +601,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.64.0.tgz", - "integrity": "sha512-cR60vSd7+m+KRZ3GQGfDxWwahW5RMXg0qlGvAluZr0fTUYvw0H9N9AXAF/M/PMqgytyqvVNmBAkJG9l7U30Y1g==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.65.0.tgz", + "integrity": "sha512-A7xfghw250m4a1sPV+q44Mow2G5bhiC9FBvhAuIhJS6QovWnqzuL5AFQPEuwOB+PM4DhABkqxVa3Iwe3Y/nFlQ==", "cpu": [ "x64" ], @@ -618,9 +617,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.64.0.tgz", - "integrity": "sha512-2u/aPZ9pEg7HnvZPDsHxUGNnrpr4qaHi+mCgLgpt+LYRzPrS4Px4wPfkIdRdr2GvKnaYyt+XSlto0Vm5sbStTg==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.65.0.tgz", + "integrity": "sha512-reqOun1+pWO3fW6cv7bsa8hHG0TN3t/82qPdaoJo90FwugXiMjKhZMChmH5Z01cFNRHmxN4+543Fy8478cM/iA==", "cpu": [ "x64" ], @@ -634,9 +633,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.64.0.tgz", - "integrity": "sha512-kfhkGfCdoXLSxEkrhDlJrvBYajGmq+ma4EMc53dsOWTq+rIBOlI0vTBmpZNnM5oH2LY/K/w1HAK+UQEgjgpVUg==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.65.0.tgz", + "integrity": "sha512-KQpqOb/juDBO0xyloDkVDhOVxDUgAfZ2OAAVq99TJScJDzT319xry1QzB9LQohV9QGnA7p6m/XATZkMXc84lwA==", "cpu": [ "arm64" ], @@ -650,9 +649,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.64.0.tgz", - "integrity": "sha512-r/cNKBFieONoVu2bb1KkVouq9W+edDUgHumXJGphCRRj+U0xaD4nanrw8ZOqo0IsutPkEM4vCcGBpak6x5aXMg==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.65.0.tgz", + "integrity": "sha512-xfqcOc3nJFeAd1kDY4T9d3XeJIhr00twaaW0kOAzGPyUHkruXtNJv6zz1Ra9fRtSek5VpW2Yoj5AcwPIlT0ZiQ==", "cpu": [ "arm64" ], @@ -666,9 +665,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.64.0.tgz", - "integrity": "sha512-tUw0xUUwEFVZbpJoeCblkv8SJA4Xz3CdXCJbAnBsiNLyxDrk2tLcxEAS6M73Q7hHHDg3OtwI8vZVK3t5RJt4Gw==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.65.0.tgz", + "integrity": "sha512-JV+pXm45p8sdgs3c7LOPAohW23optCNZETFOXUcjn6cS4PYZhEU/RI54Z5dHdMudab3nw7T48PZILthM+Q0COQ==", "cpu": [ "ia32" ], @@ -682,9 +681,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.64.0.tgz", - "integrity": "sha512-9CBR+LO0JVST87fNTzzNxS5I29jIUO5gxT9i9+M3SDHHALElj9sY1Prf12tad3vIRC6OD7Ehtvvh+sn13vSwHw==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.65.0.tgz", + "integrity": "sha512-D7L/oBbskLss21bYrRbFuIs81AiSQV+wRzwck54dOkHIlq2qu1xjLz8u6jCqGH8Fltk8bB5DLBpVhE7v/fA8XQ==", "cpu": [ "x64" ], @@ -708,12 +707,12 @@ } }, "node_modules/oxc-parser": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.123.0.tgz", - "integrity": "sha512-F6ak0tFc01ZGbl5KxvLDQ2K005Z086mp3ByCQBDhUjqXLkapGUkMuJSsYixncdEpkLlcRDcruHR71LD339ADUA==", + "version": "0.131.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.131.0.tgz", + "integrity": "sha512-SJ3/7ZPbgie8dr5Z9BI/M51zZbpXba+hRSG0MDzVwMW5CRQg2fjYE0jHGlLX4eeiibGgC/mzoDFKSDHwVZEHRQ==", "license": "MIT", "dependencies": { - "@oxc-project/types": "^0.123.0" + "@oxc-project/types": "^0.131.0" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -722,32 +721,32 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxc-parser/binding-android-arm-eabi": "0.123.0", - "@oxc-parser/binding-android-arm64": "0.123.0", - "@oxc-parser/binding-darwin-arm64": "0.123.0", - "@oxc-parser/binding-darwin-x64": "0.123.0", - "@oxc-parser/binding-freebsd-x64": "0.123.0", - "@oxc-parser/binding-linux-arm-gnueabihf": "0.123.0", - "@oxc-parser/binding-linux-arm-musleabihf": "0.123.0", - "@oxc-parser/binding-linux-arm64-gnu": "0.123.0", - "@oxc-parser/binding-linux-arm64-musl": "0.123.0", - "@oxc-parser/binding-linux-ppc64-gnu": "0.123.0", - "@oxc-parser/binding-linux-riscv64-gnu": "0.123.0", - "@oxc-parser/binding-linux-riscv64-musl": "0.123.0", - "@oxc-parser/binding-linux-s390x-gnu": "0.123.0", - "@oxc-parser/binding-linux-x64-gnu": "0.123.0", - "@oxc-parser/binding-linux-x64-musl": "0.123.0", - "@oxc-parser/binding-openharmony-arm64": "0.123.0", - "@oxc-parser/binding-wasm32-wasi": "0.123.0", - "@oxc-parser/binding-win32-arm64-msvc": "0.123.0", - "@oxc-parser/binding-win32-ia32-msvc": "0.123.0", - "@oxc-parser/binding-win32-x64-msvc": "0.123.0" + "@oxc-parser/binding-android-arm-eabi": "0.131.0", + "@oxc-parser/binding-android-arm64": "0.131.0", + "@oxc-parser/binding-darwin-arm64": "0.131.0", + "@oxc-parser/binding-darwin-x64": "0.131.0", + "@oxc-parser/binding-freebsd-x64": "0.131.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.131.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.131.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.131.0", + "@oxc-parser/binding-linux-arm64-musl": "0.131.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.131.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.131.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.131.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.131.0", + "@oxc-parser/binding-linux-x64-gnu": "0.131.0", + "@oxc-parser/binding-linux-x64-musl": "0.131.0", + "@oxc-parser/binding-openharmony-arm64": "0.131.0", + "@oxc-parser/binding-wasm32-wasi": "0.131.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.131.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.131.0", + "@oxc-parser/binding-win32-x64-msvc": "0.131.0" } }, "node_modules/oxlint": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.64.0.tgz", - "integrity": "sha512-Star3SNpWPeWFPw7kRXIhXUSn6fdiAl25q15CQzH/9WaOtG6e9CWTc25vNZOCr4PE1yEP1GtKJKIKglhj3OmEQ==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.65.0.tgz", + "integrity": "sha512-ChUuE3Q7XnAbscvT4XLMsH7HFJmLgLVv9lu+RRgFL5wSXnDqUOzTp5IS8qWDBGd/ZDSzQ2tbX8fjAmijlGLC7A==", "license": "MIT", "bin": { "oxlint": "bin/oxlint" @@ -759,25 +758,25 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.64.0", - "@oxlint/binding-android-arm64": "1.64.0", - "@oxlint/binding-darwin-arm64": "1.64.0", - "@oxlint/binding-darwin-x64": "1.64.0", - "@oxlint/binding-freebsd-x64": "1.64.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.64.0", - "@oxlint/binding-linux-arm-musleabihf": "1.64.0", - "@oxlint/binding-linux-arm64-gnu": "1.64.0", - "@oxlint/binding-linux-arm64-musl": "1.64.0", - "@oxlint/binding-linux-ppc64-gnu": "1.64.0", - "@oxlint/binding-linux-riscv64-gnu": "1.64.0", - "@oxlint/binding-linux-riscv64-musl": "1.64.0", - "@oxlint/binding-linux-s390x-gnu": "1.64.0", - "@oxlint/binding-linux-x64-gnu": "1.64.0", - "@oxlint/binding-linux-x64-musl": "1.64.0", - "@oxlint/binding-openharmony-arm64": "1.64.0", - "@oxlint/binding-win32-arm64-msvc": "1.64.0", - "@oxlint/binding-win32-ia32-msvc": "1.64.0", - "@oxlint/binding-win32-x64-msvc": "1.64.0" + "@oxlint/binding-android-arm-eabi": "1.65.0", + "@oxlint/binding-android-arm64": "1.65.0", + "@oxlint/binding-darwin-arm64": "1.65.0", + "@oxlint/binding-darwin-x64": "1.65.0", + "@oxlint/binding-freebsd-x64": "1.65.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.65.0", + "@oxlint/binding-linux-arm-musleabihf": "1.65.0", + "@oxlint/binding-linux-arm64-gnu": "1.65.0", + "@oxlint/binding-linux-arm64-musl": "1.65.0", + "@oxlint/binding-linux-ppc64-gnu": "1.65.0", + "@oxlint/binding-linux-riscv64-gnu": "1.65.0", + "@oxlint/binding-linux-riscv64-musl": "1.65.0", + "@oxlint/binding-linux-s390x-gnu": "1.65.0", + "@oxlint/binding-linux-x64-gnu": "1.65.0", + "@oxlint/binding-linux-x64-musl": "1.65.0", + "@oxlint/binding-openharmony-arm64": "1.65.0", + "@oxlint/binding-win32-arm64-msvc": "1.65.0", + "@oxlint/binding-win32-ia32-msvc": "1.65.0", + "@oxlint/binding-win32-x64-msvc": "1.65.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" diff --git a/studio/backend/core/data_recipe/oxc-validator/package.json b/studio/backend/core/data_recipe/oxc-validator/package.json index 111ae2b257..2817b6c7c3 100644 --- a/studio/backend/core/data_recipe/oxc-validator/package.json +++ b/studio/backend/core/data_recipe/oxc-validator/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "type": "module", "dependencies": { - "oxc-parser": "^0.123.0", - "oxlint": "^1.51.0" + "oxc-parser": "^0.131.0", + "oxlint": "^1.65.0" } } From 549e24e7d83bd33c2b92e06e7828008fd3e66839 Mon Sep 17 00:00:00 2001 From: Harikrishna C Date: Fri, 22 May 2026 17:17:29 +0530 Subject: [PATCH 14/42] Fix chat send button (#5647) Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Co-authored-by: Wasim Yousef Said --- .../src/components/assistant-ui/thread.tsx | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index e4fcff6b0f..13f18d7b6a 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -289,19 +289,31 @@ const PendingAudioChip: FC = () => { const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => { const { inputProps, isComposing, isComposingRef } = useImeComposerInputHandlers(); + const composerText = useAuiState(({ composer }) => composer.text); + const hasAttachments = useAuiState( + ({ composer }) => composer.attachments.length > 0, + ); const hasPendingAttachments = useAuiState(({ composer }) => composer.attachments.some( (attachment) => attachment.status.type === "running", ), ); + const hasPendingAudio = useChatRuntimeStore((s) => Boolean(s.pendingAudioName)); + const hasSendableContent = + composerText.trim().length > 0 || hasAttachments || hasPendingAudio; const handleSubmit = useCallback( (event: FormEvent) => { - if (disabled || isComposingRef.current || hasPendingAttachments) { + if ( + disabled || + !hasSendableContent || + isComposingRef.current || + hasPendingAttachments + ) { event.preventDefault(); } }, - [disabled, hasPendingAttachments, isComposingRef], + [disabled, hasPendingAttachments, hasSendableContent, isComposingRef], ); const composerContent = ( @@ -323,8 +335,12 @@ const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => { {...inputProps} /> isComposingRef.current || hasPendingAttachments} + disabled={ + disabled || !hasSendableContent || isComposing || hasPendingAttachments + } + blockSend={() => + !hasSendableContent || isComposingRef.current || hasPendingAttachments + } /> ); From 4c26def4b327c861ce4f9d0cc00ba05ef3f03667 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 22 May 2026 05:02:15 -0700 Subject: [PATCH 15/42] studio/frontend: correct Think pill aria-label before model loads (#5655) * studio/frontend: correct Think pill aria-label before model loads `reasoningEnabled` defaults to true in the chat-runtime store, so on a fresh /chat with no model the Think pill renders disabled + visually off (LightbulbOffIcon, data-active="false"), but its aria-label still reads "Disable thinking" -- screen readers announce it as if the button is currently on. Add a `disabled` branch between reasoningLockedOn and effectiveReasoningEnabled so the label reads "Thinking (model not loaded)" while the button is unreachable, then falls through to the normal enable/disable copy once a model is loaded. Apply the same fix to the equivalent pill in shared-composer (where the disabled flag is named `reasoningDisabled`). * studio/frontend: Think pill distinguishes !modelLoaded vs unsupported reasoning Address review feedback on #5655 (chatgpt-codex-connector + gemini-code-assist both flagged the same edge case): The previous `disabled` branch labeled the Think pill "Thinking (model not loaded)" whenever the button was disabled, but `disabled` is defined as `!(modelLoaded && effectiveSupportsReasoning)` (in thread.tsx) and `!modelLoaded || !effectiveSupportsReasoning` (in shared-composer.tsx). Both cover the second case where a model IS loaded but does not support reasoning at all (e.g. Llama-3.2-1B-Instruct), which mislabeled the pill for screen-reader users. Split the branch so the no-model case keeps "Thinking (model not loaded)" and the loaded-but-unsupported case reads "Thinking (not supported by this model)". Locked-on / enabled / disabled labels are unchanged. Verified by re-running the Playwright probe: - no model -> aria-label "Thinking (model not loaded)" - Llama-3.2-1B loaded -> aria-label "Thinking (not supported by this model)" - reasoning-capable loaded, OFF -> "Enable thinking" - reasoning-capable loaded, ON -> "Disable thinking" - locked-on model -> "Thinking is required for this model" * studio/frontend: extract Think pill aria-label helper, fix effort dropdown pre-load mislabel Address review consensus on #5655: 1. Extract the duplicate 5-branch aria-label conditional into a shared helper `thinkToggleAriaLabel` (plus a parallel `thinkEffortAriaLabel` for the reasoning-effort dropdown). Both `thread.tsx` and `shared-composer.tsx` now import from `components/assistant-ui/think-aria-label.ts`. 2. While reviewing the diff, an Opus reviewer noticed the same conceptual bug existed in the reasoning-effort dropdown branch in `thread.tsx:627` (the alternate render path used by Claude-style models with effort levels): before a model loaded, the aria-label announced e.g. "Reasoning effort: medium" on a disabled, grayed-out button. Same contradiction as the original bug for the on/off toggle. Now routed through `thinkEffortAriaLabel`, which falls back to "Thinking (model not loaded)" / "Thinking (not supported by this model)" while the button is unreachable and only emits the effort label when the model is loaded and actually supports reasoning. 3. Locked-on stays intentionally absent from `thinkEffortAriaLabel`: the dropdown remains interactive in that case (users can still pick an effort level), so the per-level label is the right announcement. Verified by bun run typecheck (clean) and bun run build (clean). Bundle confirms all six label strings still ship. * studio/frontend: route shared composer effort dropdown through thinkEffortAriaLabel 12/12 reviewers flagged that the earlier think-aria-label helper was only wired into thread.tsx; the parallel reasoning-effort dropdown in shared-composer.tsx still hard-coded the raw "Reasoning effort: medium" label, so screen readers heard a stale effort value when the control was disabled (no model loaded, unsupported reasoning). Route shared-composer's effort button through the same helper, matching thread.tsx. * studio/frontend: shorten think-aria-label helper comments --------- Co-authored-by: danielhanchen --- .../assistant-ui/think-aria-label.ts | 37 +++++++++++++++++++ .../src/components/assistant-ui/thread.tsx | 23 ++++++++---- .../src/features/chat/shared-composer.tsx | 23 ++++++++---- 3 files changed, 67 insertions(+), 16 deletions(-) create mode 100644 studio/frontend/src/components/assistant-ui/think-aria-label.ts diff --git a/studio/frontend/src/components/assistant-ui/think-aria-label.ts b/studio/frontend/src/components/assistant-ui/think-aria-label.ts new file mode 100644 index 0000000000..9a7eb31a86 --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/think-aria-label.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +// Shared aria-labels for the Think pill (toggle + effort dropdown) so both +// controls use the same pre-load / unsupported-model wording. + +export interface ThinkToggleState { + reasoningLockedOn: boolean; + modelLoaded: boolean; + reasoningDisabled: boolean; + effectiveReasoningEnabled: boolean; +} + +export function thinkToggleAriaLabel(state: ThinkToggleState): string { + if (state.reasoningLockedOn) return "Thinking is required for this model"; + if (!state.modelLoaded) return "Thinking (model not loaded)"; + if (state.reasoningDisabled) + return "Thinking (not supported by this model)"; + return state.effectiveReasoningEnabled + ? "Disable thinking" + : "Enable thinking"; +} + +export interface ThinkEffortState { + modelLoaded: boolean; + reasoningDisabled: boolean; + reasoningEffort: string; +} + +// Locked-on isn't special-cased: the effort dropdown stays interactive +// (users can still pick an effort level). +export function thinkEffortAriaLabel(state: ThinkEffortState): string { + if (!state.modelLoaded) return "Thinking (model not loaded)"; + if (state.reasoningDisabled) + return "Thinking (not supported by this model)"; + return `Reasoning effort: ${state.reasoningEffort}`; +} diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 13f18d7b6a..c0d577b12d 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -11,6 +11,10 @@ import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { MessageTiming } from "@/components/assistant-ui/message-timing"; import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning"; import { Sources, SourcesGroup } from "@/components/assistant-ui/sources"; +import { + thinkEffortAriaLabel, + thinkToggleAriaLabel, +} from "@/components/assistant-ui/think-aria-label"; import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { ToolGroup } from "@/components/assistant-ui/tool-group"; import { CodeExecutionToolUI } from "@/components/assistant-ui/tool-ui-code-execution"; @@ -640,7 +644,11 @@ const ReasoningToggle: FC = () => { ? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]" : "hover:bg-primary/10 dark:hover:bg-white/[0.08]", )} - aria-label={`Reasoning effort: ${reasoningEffort}`} + aria-label={thinkEffortAriaLabel({ + modelLoaded, + reasoningDisabled: disabled, + reasoningEffort, + })} > {effectiveReasoningVisualEnabled ? ( @@ -716,13 +724,12 @@ const ReasoningToggle: FC = () => { ? "true" : "false" } - aria-label={ - reasoningLockedOn - ? "Thinking is required for this model" - : effectiveReasoningEnabled - ? "Disable thinking" - : "Enable thinking" - } + aria-label={thinkToggleAriaLabel({ + reasoningLockedOn, + modelLoaded, + reasoningDisabled: disabled, + effectiveReasoningEnabled, + })} > {reasoningLockedOn || (effectiveReasoningEnabled && !disabled) ? ( diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index 4748e324c0..57a8a132d7 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -3,6 +3,10 @@ import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon"; +import { + thinkEffortAriaLabel, + thinkToggleAriaLabel, +} from "@/components/assistant-ui/think-aria-label"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { @@ -898,7 +902,11 @@ export function SharedComposer({ ? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]" : "hover:bg-primary/10 dark:hover:bg-white/[0.08]", )} - aria-label={`Reasoning effort: ${reasoningEffort}`} + aria-label={thinkEffortAriaLabel({ + modelLoaded, + reasoningDisabled, + reasoningEffort, + })} > {effectiveReasoningVisualEnabled ? ( @@ -990,13 +998,12 @@ export function SharedComposer({ ? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]" : "hover:bg-primary/10 dark:hover:bg-white/[0.08]", )} - aria-label={ - reasoningLockedOn - ? "Thinking is required for this model" - : effectiveReasoningEnabled - ? "Disable thinking" - : "Enable thinking" - } + aria-label={thinkToggleAriaLabel({ + reasoningLockedOn, + modelLoaded, + reasoningDisabled, + effectiveReasoningEnabled, + })} > {reasoningLockedOn || (effectiveReasoningEnabled && !reasoningDisabled) ? ( From 71232f749a055690b6560e609ce36f063238f0ae Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 22 May 2026 05:02:19 -0700 Subject: [PATCH 16/42] studio/frontend: fix onboarding CSP violations (#5658) * studio/frontend: fix onboarding CSP violations Two onboarding-only CSP violations were showing up in the browser console on a default install: * `WizardSidebar` rendered the brand sticker from `https://unsloth.ai/cgi/image/unsloth_sticker_no_shadow_*.png`, which is not in the Studio CSP `img-src` allowlist. The sticker rendered as a broken image. * `Confetti` defaulted `globalOptions.useWorker` to `true`, so `canvas-confetti` tried to spawn an OffscreenCanvas worker from a `blob:` URL. CSP `script-src 'self'` blocks it; three blocked- worker errors fired on the final wizard step. Use the bundled `/sticker.png` for the brand image, and default the Confetti wrapper to the main-thread fallback. CSP stays tight. Resolves #5657. * studio/frontend: harden CSP confetti fix + BASE_URL sticker Address review feedback on #5658: 1. confetti.tsx - Hoist the default globalOptions to a module-scope constant so the prop default has a stable identity across renders (canvasRef's dependency array no longer churns every render). - Always force useWorker:false at the confetti.create site, regardless of what the caller passed in globalOptions. Previously a caller that set `{ resize: true }` would silently re-enable the worker and trip the CSP block again. - Add a lazily-mounted, module-scoped CSP-safe instance and route ConfettiButton through it instead of the global confetti() (which defaults to useWorker:true and would otherwise violate CSP). 2. confetti-fireworks.ts - Replace the direct confetti(...) calls (global instance, default worker on) with calls to a shared confetti.create instance with useWorker:false. The guided-tour completion confetti no longer trips the CSP block. 3. wizard-sidebar.tsx - Use import.meta.env.BASE_URL prefix on the sticker src so the asset still resolves when Studio is deployed under a subpath (e.g. /studio/). Defaults to "/" so single-host installs are unchanged. tsc clean, bun run build clean, bundle confirms the changes (`{resize:!0,useWorker:!1}` appears in every relevant call site). * studio/tour: preserve opts.zIndex on shared confetti fireworks canvas Address chatgpt-codex-connector inline review on #5658 follow-up: When canvas-confetti runs against a caller-provided canvas (which is what we need for the CSP fix), the per-fire `zIndex` option is ignored for stacking purposes -- the canvas element's own CSS `z-index` is what the browser uses. The previous follow-up hard-coded the shared canvas to `z-index:99999`, so callers that pass `opts.zIndex` (or expect the old global-confetti behavior of being able to lower fireworks under an overlay) silently lost that knob. Apply `opts.zIndex` to the shared canvas's `style.zIndex` on each call (default 99999 still used when omitted). Same default; behavior is now restored for the lower/raise case. The current only caller (`guided-tour.tsx` invoking `fireConfettiFireworks()` with no args) is unaffected since it never provided `opts.zIndex`. Public API contract is preserved. * studio/frontend: drop dead ConfettiButton + BASE_URL onboarding mascots - confetti.tsx: remove unused ConfettiButton + getSharedConfettiFire singleton (0 callsites) - splash-screen.tsx, wizard-content.tsx: prefix sloth mascot paths with import.meta.env.BASE_URL so onboarding works under non-root subpaths - confetti-fireworks.ts: drop dead per-fire zIndex from defaults (caller-provided canvas ignores it; we already drive stacking via canvas style) * studio/frontend: BASE_URL on HF icon + race-safe shared fireworks init - dataset-step.tsx: prefix the Hugging Face dataset-source icon with import.meta.env.BASE_URL so it resolves correctly under non-root deployments. Last onboarding asset that was still root-relative after the earlier BASE_URL sweep. - confetti-fireworks.ts: cache the in-flight init promise in getSharedFire so two same-tick callers share the dynamic import and the appended overlay canvas. Previously two concurrent fireConfettiFireworks() calls each appended a fixed full-screen canvas and orphaned the first one. * studio/frontend: tighten confetti CSP comments --------- Co-authored-by: danielhanchen --- .../frontend/src/components/ui/confetti.tsx | 262 ++++++++---------- .../onboarding/components/splash-screen.tsx | 2 +- .../components/steps/dataset-step.tsx | 2 +- .../onboarding/components/wizard-content.tsx | 10 +- .../onboarding/components/wizard-sidebar.tsx | 2 +- .../features/tour/lib/confetti-fireworks.ts | 47 +++- 6 files changed, 166 insertions(+), 159 deletions(-) diff --git a/studio/frontend/src/components/ui/confetti.tsx b/studio/frontend/src/components/ui/confetti.tsx index acd618d218..892bffdb18 100644 --- a/studio/frontend/src/components/ui/confetti.tsx +++ b/studio/frontend/src/components/ui/confetti.tsx @@ -1,150 +1,118 @@ // 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 type { - GlobalOptions as ConfettiGlobalOptions, - CreateTypes as ConfettiInstance, - Options as ConfettiOptions, -} from "canvas-confetti"; -import confetti from "canvas-confetti"; -import type { ReactNode } from "react"; -import type React from "react"; -import { - createContext, - forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, -} from "react"; - -import { Button } from "@/components/ui/button"; - -type Api = { - fire: (options?: ConfettiOptions) => void; -}; - -type Props = React.ComponentPropsWithRef<"canvas"> & { - options?: ConfettiOptions; - globalOptions?: ConfettiGlobalOptions; - manualstart?: boolean; - children?: ReactNode; -}; - -export type ConfettiRef = Api | null; - -const ConfettiContext = createContext({} as Api); - -// Define component first -const ConfettiComponent = forwardRef((props, ref) => { - const { - options, - globalOptions = { resize: true, useWorker: true }, - manualstart = false, - children, - ...rest - } = props; - const instanceRef = useRef(null); - - const canvasRef = useCallback( - (node: HTMLCanvasElement) => { - if (node !== null) { - if (instanceRef.current) return; - instanceRef.current = confetti.create(node, { - ...globalOptions, - resize: true, - }); - } else { - if (instanceRef.current) { - instanceRef.current.reset(); - instanceRef.current = null; - } - } - }, - [globalOptions], - ); - - const fire = useCallback( - async (opts = {}) => { - try { - await instanceRef.current?.({ ...options, ...opts }); - } catch (error) { - console.error("Confetti error:", error); - } - }, - [options], - ); - - const api = useMemo( - () => ({ - fire, - }), - [fire], - ); - - useImperativeHandle(ref, () => api, [api]); - - useEffect(() => { - if (!manualstart) { - (async () => { - try { - await fire(); - } catch (error) { - console.error("Confetti effect error:", error); - } - })(); - } - }, [manualstart, fire]); - - return ( - - - {children} - - ); -}); - -// Set display name immediately -ConfettiComponent.displayName = "Confetti"; - -// Export as Confetti -export const Confetti = ConfettiComponent; - -interface ConfettiButtonProps extends React.ComponentProps<"button"> { - options?: ConfettiOptions & - ConfettiGlobalOptions & { canvas?: HTMLCanvasElement }; -} - -const ConfettiButtonComponent = ({ - options, - children, - ...props -}: ConfettiButtonProps) => { - const handleClick = async (event: React.MouseEvent) => { - try { - const rect = event.currentTarget.getBoundingClientRect(); - const x = rect.left + rect.width / 2; - const y = rect.top + rect.height / 2; - await confetti({ - ...options, - origin: { - x: x / window.innerWidth, - y: y / window.innerHeight, - }, - }); - } catch (error) { - console.error("Confetti button error:", error); - } - }; - - return ( - - ); -}; - -ConfettiButtonComponent.displayName = "ConfettiButton"; - -export const ConfettiButton = ConfettiButtonComponent; +import type { + GlobalOptions as ConfettiGlobalOptions, + CreateTypes as ConfettiInstance, + Options as ConfettiOptions, +} from "canvas-confetti"; +import confetti from "canvas-confetti"; +import type { ReactNode } from "react"; +import type React from "react"; +import { + createContext, + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, +} from "react"; + +type Api = { + fire: (options?: ConfettiOptions) => void; +}; + +type Props = React.ComponentPropsWithRef<"canvas"> & { + options?: ConfettiOptions; + globalOptions?: ConfettiGlobalOptions; + manualstart?: boolean; + children?: ReactNode; +}; + +export type ConfettiRef = Api | null; + +const ConfettiContext = createContext({} as Api); + +// Studio CSP blocks canvas-confetti's default blob: worker, so force +// useWorker: false. Module-scoped so the prop default keeps stable +// identity across renders (`canvasRef` depends on `globalOptions`). +const DEFAULT_GLOBAL_OPTIONS: ConfettiGlobalOptions = { + resize: true, + useWorker: false, +}; + +const ConfettiComponent = forwardRef((props, ref) => { + const { + options, + globalOptions = DEFAULT_GLOBAL_OPTIONS, + manualstart = false, + children, + ...rest + } = props; + const instanceRef = useRef(null); + + const canvasRef = useCallback( + (node: HTMLCanvasElement) => { + if (node !== null) { + if (instanceRef.current) return; + instanceRef.current = confetti.create(node, { + ...globalOptions, + resize: true, + // Force off after the spread so caller globalOptions can't + // re-enable the worker and trip CSP. + useWorker: false, + }); + } else { + if (instanceRef.current) { + instanceRef.current.reset(); + instanceRef.current = null; + } + } + }, + [globalOptions], + ); + + const fire = useCallback( + async (opts = {}) => { + try { + await instanceRef.current?.({ ...options, ...opts }); + } catch (error) { + console.error("Confetti error:", error); + } + }, + [options], + ); + + const api = useMemo( + () => ({ + fire, + }), + [fire], + ); + + useImperativeHandle(ref, () => api, [api]); + + useEffect(() => { + if (!manualstart) { + (async () => { + try { + await fire(); + } catch (error) { + console.error("Confetti effect error:", error); + } + })(); + } + }, [manualstart, fire]); + + return ( + + + {children} + + ); +}); + +ConfettiComponent.displayName = "Confetti"; + +export const Confetti = ConfettiComponent; diff --git a/studio/frontend/src/features/onboarding/components/splash-screen.tsx b/studio/frontend/src/features/onboarding/components/splash-screen.tsx index ce828ee7b4..70438a04d8 100644 --- a/studio/frontend/src/features/onboarding/components/splash-screen.tsx +++ b/studio/frontend/src/features/onboarding/components/splash-screen.tsx @@ -20,7 +20,7 @@ export function SplashScreen({ {/* Mascot */}
= { - 1: "/Sloth emojis/large sloth wave.png", - 2: "/Sloth emojis/sloth magnify final.png", - 3: "/Sloth emojis/sloth huglove large.png", - 4: "/Sloth emojis/large sloth glasses.png", - 5: "/Sloth emojis/large sloth yay.png", + 1: `${import.meta.env.BASE_URL}Sloth emojis/large sloth wave.png`, + 2: `${import.meta.env.BASE_URL}Sloth emojis/sloth magnify final.png`, + 3: `${import.meta.env.BASE_URL}Sloth emojis/sloth huglove large.png`, + 4: `${import.meta.env.BASE_URL}Sloth emojis/large sloth glasses.png`, + 5: `${import.meta.env.BASE_URL}Sloth emojis/large sloth yay.png`, }; export function WizardContent() { diff --git a/studio/frontend/src/features/onboarding/components/wizard-sidebar.tsx b/studio/frontend/src/features/onboarding/components/wizard-sidebar.tsx index d22ac19e51..4637336c6c 100644 --- a/studio/frontend/src/features/onboarding/components/wizard-sidebar.tsx +++ b/studio/frontend/src/features/onboarding/components/wizard-sidebar.tsx @@ -18,7 +18,7 @@ export function WizardSidebar({ returnTo }: { returnTo: string }) {