From 4ef0453cc9e6d43e5d8fdc902aa5ec8b3b4a2970 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Thu, 16 Apr 2026 21:09:02 +0400 Subject: [PATCH 1/3] fix(studio): reject images in OpenAI tool passthrough for text-only GGUFs The new tool passthrough branch runs before _extract_content_parts, skipping the existing not is_vision guard. Requests combining tools with an image on a text-only tool-capable GGUF were forwarded to llama-server, producing opaque upstream errors instead of the pre-existing clear 400. Restore the guard inline at the dispatch point, checking both legacy image_base64 and inline image_url parts. --- studio/backend/routes/inference.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index dc94b2fa84..96b9f827c0 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -1147,6 +1147,23 @@ async def openai_chat_completions( and not payload.enable_tools and ((payload.tools and len(payload.tools) > 0) or _has_tool_messages) ): + # Preserve the vision guard that would otherwise run in the + # non-passthrough path below: text-only tool-capable GGUFs + # should return a clear 400 here rather than forwarding the + # image to llama-server and surfacing an opaque upstream error. + if not llama_backend.is_vision and ( + payload.image_base64 + or any( + isinstance(m.content, list) + and any(isinstance(p, ImageContentPart) for p in m.content) + for m in payload.messages + ) + ): + raise HTTPException( + status_code = 400, + detail = "Image provided but current GGUF model does not support vision.", + ) + cancel_event = threading.Event() completion_id = f"chatcmpl-{uuid.uuid4().hex[:12]}" if payload.stream: From bc9ddb3af690194e7fae25d7740a04d16184431e Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Thu, 16 Apr 2026 19:11:35 +0200 Subject: [PATCH 2/3] Fix onboarding followups (#5064) * Fix onboarding followups * Rename sidebar studio to train --- .../frontend/src/components/app-sidebar.tsx | 4 +- .../onboarding/components/wizard-footer.tsx | 2 +- .../features/settings/tabs/general-tab.tsx | 38 ++++++++++--------- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 8264f329a3..329175fa9c 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -330,7 +330,7 @@ export function AppSidebar() { { @@ -511,7 +511,7 @@ export function AppSidebar() { />
Unsloth - Studio + Train
diff --git a/studio/frontend/src/features/onboarding/components/wizard-footer.tsx b/studio/frontend/src/features/onboarding/components/wizard-footer.tsx index 4588c0a632..399bf115f1 100644 --- a/studio/frontend/src/features/onboarding/components/wizard-footer.tsx +++ b/studio/frontend/src/features/onboarding/components/wizard-footer.tsx @@ -69,7 +69,7 @@ export function WizardFooter({ if (currentStep === 1 && sessionStorage.getItem("unsloth_chat_only") === "1") { sessionStorage.removeItem("unsloth_chat_only"); markOnboardingDone(); - window.location.assign(returnTo); + window.location.assign("/chat"); } else { nextStep(); } diff --git a/studio/frontend/src/features/settings/tabs/general-tab.tsx b/studio/frontend/src/features/settings/tabs/general-tab.tsx index a79b134e97..874508a6fb 100644 --- a/studio/frontend/src/features/settings/tabs/general-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/general-tab.tsx @@ -12,6 +12,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; +import { usePlatformStore } from "@/config/env"; import { resetOnboardingDone } from "@/features/auth"; import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store"; import { useSettingsDialogStore } from "@/features/settings"; @@ -95,6 +96,7 @@ export function GeneralTab() { const setHfToken = useChatRuntimeStore((s) => s.setHfToken); const autoTitle = useChatRuntimeStore((s) => s.autoTitle); const setAutoTitle = useChatRuntimeStore((s) => s.setAutoTitle); + const chatOnly = usePlatformStore((s) => s.chatOnly); const redirectTo = `${pathname}${search}`; const [draftToken, setDraftToken] = useState(hfToken ?? ""); @@ -170,24 +172,26 @@ export function GeneralTab() { - - - - - + + + + )} Date: Thu, 16 Apr 2026 21:15:51 +0400 Subject: [PATCH 3/3] fix(studio): require tool_call_id on role=tool chat messages Enforce the OpenAI spec rule that role="tool" messages must carry a tool_call_id. Without it, upstream backends cannot associate a tool result with the assistant's prior tool_calls entry and the request fails in non-obvious ways through the passthrough path. Reject at the request boundary with a 422 instead. --- studio/backend/models/inference.py | 15 ++++++++++++++- .../tests/test_openai_tool_passthrough.py | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index f918a73656..10706235e1 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -11,7 +11,7 @@ import time import uuid from typing import Annotated, Any, Dict, Literal, Optional, List, Union -from pydantic import BaseModel, Discriminator, Field, Tag +from pydantic import BaseModel, Discriminator, Field, Tag, model_validator class LoadRequest(BaseModel): @@ -363,6 +363,19 @@ class ChatMessage(BaseModel): description = "OpenAI tool-result messages: name of the tool whose result this is.", ) + @model_validator(mode = "after") + def _require_tool_call_id_for_tool_role(self) -> "ChatMessage": + # OpenAI's spec requires `tool_call_id` on role="tool" messages so + # the upstream backend can associate the result with the assistant's + # prior `tool_calls` entry. Reject malformed tool-result messages at + # the request boundary instead of forwarding them to llama-server + # through the passthrough path, where the failure mode is opaque. + if self.role == "tool" and not self.tool_call_id: + raise ValueError( + 'role="tool" messages require "tool_call_id" per the OpenAI spec.' + ) + return self + class ChatCompletionRequest(BaseModel): """ diff --git a/studio/backend/tests/test_openai_tool_passthrough.py b/studio/backend/tests/test_openai_tool_passthrough.py index 1c0dec1264..cd50ab91f8 100644 --- a/studio/backend/tests/test_openai_tool_passthrough.py +++ b/studio/backend/tests/test_openai_tool_passthrough.py @@ -114,6 +114,23 @@ class TestChatMessageToolRoles: msg = ChatMessage(role = "assistant") assert msg.content is None + def test_tool_role_missing_tool_call_id_rejected(self): + # Per OpenAI spec, role="tool" messages must carry tool_call_id so + # upstream backends can associate the result with its prior call. + # Pin the boundary-level rejection so a malformed tool-result + # message never reaches the passthrough path. + with pytest.raises(ValidationError) as exc_info: + ChatMessage(role = "tool", content = '{"temperature": 72}') + assert "tool_call_id" in str(exc_info.value) + + def test_tool_role_empty_tool_call_id_rejected(self): + with pytest.raises(ValidationError): + ChatMessage( + role = "tool", + tool_call_id = "", + content = '{"temperature": 72}', + ) + # ===================================================================== # ChatCompletionRequest — standard OpenAI tool fields