Merge remote-tracking branch 'unslothai/feature/tool-choice-kwarg-openai-format' into pr-5061-head

# Conflicts:
#	studio/backend/models/inference.py
#	studio/backend/routes/inference.py
This commit is contained in:
Daniel Han 2026-04-16 18:19:32 +00:00
commit 8a265a2143
5 changed files with 50 additions and 22 deletions

View file

@ -374,7 +374,9 @@ class ChatMessage(BaseModel):
if self.content is None:
raise ValueError("tool messages require content")
if not self.tool_call_id:
raise ValueError("tool messages require tool_call_id")
raise ValueError(
'role="tool" messages require "tool_call_id" per the OpenAI spec.'
)
else:
if self.content is None:
raise ValueError(f"{self.role} messages require content")

View file

@ -111,9 +111,31 @@ class TestChatMessageToolRoles:
ChatMessage(role = "function", content = "x")
def test_content_absent_defaults_to_none(self):
msg = ChatMessage(role = "assistant")
msg = ChatMessage(
role = "assistant",
tool_calls = [
{"id": "c1", "type": "function", "function": {"name": "f", "arguments": "{}"}}
],
)
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

View file

@ -330,7 +330,7 @@ export function AppSidebar() {
<SidebarMenu>
<NavItem
icon={ZapIcon}
label="Studio"
label="Train"
active={pathname === "/studio" || pathname.startsWith("/studio/")}
disabled={chatOnly}
onClick={() => {
@ -511,7 +511,7 @@ export function AppSidebar() {
/>
<div className="flex flex-col gap-0.5 leading-none group-data-[collapsible=icon]:hidden">
<span className="truncate text-sm font-semibold">Unsloth</span>
<span className="truncate text-[11px] text-muted-foreground">Studio</span>
<span className="truncate text-[11px] text-muted-foreground">Train</span>
</div>
<ChevronsUpDown strokeWidth={1.25} className="ml-auto size-4 text-muted-foreground group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>

View file

@ -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();
}

View file

@ -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() {
</SettingsRow>
</SettingsSection>
<SettingsSection title="Getting started">
<SettingsRow
label="Start onboarding"
description="Open the setup wizard again without changing your account."
>
<Button
variant="outline"
size="sm"
onClick={() => {
resetOnboardingDone();
closeDialog();
navigate({ to: "/onboarding", search: { redirectTo } });
}}
{!chatOnly && (
<SettingsSection title="Getting started">
<SettingsRow
label="Start onboarding"
description="Open the setup wizard again without changing your account."
>
Start onboarding
</Button>
</SettingsRow>
</SettingsSection>
<Button
variant="outline"
size="sm"
onClick={() => {
resetOnboardingDone();
closeDialog();
navigate({ to: "/onboarding", search: { redirectTo } });
}}
>
Start onboarding
</Button>
</SettingsRow>
</SettingsSection>
)}
<SettingsSection title="Danger zone">
<SettingsRow