From 5d2dca801cb18a2918a440167a480ff6bde6cdfd Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:18:25 +0000 Subject: [PATCH 01/26] studio: add HF/local model selection UI for GGUF export (#4365) * feat(studio): add HF/local model selection UI for GGUF export * fix(studio):fix selector ring clipping * fix(studio): export page trust_remote_code control and label styling * fix(studio): accept hf_token in load_checkpoint orchestrator method The route was passing hf_token to load_checkpoint() but the method didn't accept it, causing a TypeError on every /api/export/load-checkpoint request. * fix(studio): clear HF model selection when input is edited Previously selectedSourceModel was only cleared when the input became empty, so editing to a different repo ID after selecting a model would silently keep the old selection. --------- Co-authored-by: Roland Tannous --- studio/backend/core/export/orchestrator.py | 2 + .../src/features/export/export-page.tsx | 771 ++++++++++++++---- 2 files changed, 615 insertions(+), 158 deletions(-) diff --git a/studio/backend/core/export/orchestrator.py b/studio/backend/core/export/orchestrator.py index a9fbe659b3..500bc9e706 100644 --- a/studio/backend/core/export/orchestrator.py +++ b/studio/backend/core/export/orchestrator.py @@ -217,6 +217,7 @@ class ExportOrchestrator: max_seq_length: int = 2048, load_in_4bit: bool = True, trust_remote_code: bool = False, + hf_token: Optional[str] = None, ) -> Tuple[bool, str]: """Load a checkpoint for export. @@ -227,6 +228,7 @@ class ExportOrchestrator: "max_seq_length": max_seq_length, "load_in_4bit": load_in_4bit, "trust_remote_code": trust_remote_code, + "hf_token": hf_token, } # Always kill existing subprocess and spawn fresh. diff --git a/studio/frontend/src/features/export/export-page.tsx b/studio/frontend/src/features/export/export-page.tsx index edf5b666a3..fd43fdb90d 100644 --- a/studio/frontend/src/features/export/export-page.tsx +++ b/studio/frontend/src/features/export/export-page.tsx @@ -3,6 +3,19 @@ import { SectionCard } from "@/components/section-card"; import { Button } from "@/components/ui/button"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "@/components/ui/input-group"; import { Select, SelectContent, @@ -11,17 +24,34 @@ import { SelectValue, } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { useTrainingConfigStore } from "@/features/training"; -import { AlertCircleIcon, InformationCircleIcon, PackageIcon } from "@hugeicons/core-free-icons"; +import { + listLocalModels, + type LocalModelInfo, + useTrainingConfigStore, +} from "@/features/training"; +import { + useDebouncedValue, + useHfModelSearch, + useHfTokenValidation, +} from "@/hooks"; +import { + AlertCircleIcon, + FolderSearchIcon, + InformationCircleIcon, + Key01Icon, + PackageIcon, + Search01Icon, +} from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { AnimatePresence, motion } from "motion/react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { collapseAnim } from "./anim"; import type { ModelCheckpoints } from "./api/export-api"; @@ -60,6 +90,21 @@ export function ExportPage() { const [selectedModelIdx, setSelectedModelIdx] = useState(null); const [checkpoint, setCheckpoint] = useState(null); + const [sourceMode, setSourceMode] = useState<"checkpoint" | "model">( + "checkpoint", + ); + const [modelSource, setModelSource] = useState<"hf" | "local">("hf"); + const [hfExportTrustRemoteCode, setHfExportTrustRemoteCode] = + useState(true); + const [modelInput, setModelInput] = useState(""); + const [selectedSourceModel, setSelectedSourceModel] = useState( + null, + ); + const [localModelInput, setLocalModelInput] = useState(""); + const [localModels, setLocalModels] = useState([]); + const [isLoadingLocalModels, setIsLoadingLocalModels] = useState(true); + const [localModelsError, setLocalModelsError] = useState(null); + const debouncedModelQuery = useDebouncedValue(modelInput); const [exportMethod, setExportMethod] = useState(null); const [quantLevels, setQuantLevels] = useState([]); @@ -74,6 +119,9 @@ export function ExportPage() { const [exportError, setExportError] = useState(null); const [exportSuccess, setExportSuccess] = useState(false); + const hfComboboxAnchorRef = useRef(null); + const localComboboxAnchorRef = useRef(null); + const tour = useGuidedTourController({ id: "export", steps: exportTourSteps, @@ -105,6 +153,27 @@ export function ExportPage() { }; }, []); + // ---- Fetch local models for direct export ---- + useEffect(() => { + const controller = new AbortController(); + void listLocalModels(controller.signal) + .then((models) => { + if (controller.signal.aborted) return; + setLocalModels(models); + }) + .catch((error) => { + if (controller.signal.aborted) return; + setLocalModelsError( + error instanceof Error ? error.message : "Failed to load local models", + ); + }) + .finally(() => { + if (controller.signal.aborted) return; + setIsLoadingLocalModels(false); + }); + return () => controller.abort(); + }, []); + // ---- Derived state ---- const selectedModelData = useMemo( () => @@ -127,6 +196,83 @@ export function ExportPage() { const trainingMethodLabel = selectedModelData?.peft_type ? "LoRA / QLoRA" : "Full Fine-tune"; + const sourceBaseModelName = sourceMode === "model" + ? selectedSourceModel ?? "—" + : baseModelName; + + const { + results: hfResults, + isLoading: isLoadingHfModels, + error: hfSearchError, + } = useHfModelSearch(debouncedModelQuery, { + accessToken: hfToken || undefined, + excludeGguf: true, + }); + const { error: tokenValidationError, isChecking: isCheckingToken } = + useHfTokenValidation(hfToken); + + const hfResultIds = useMemo(() => { + const ids = hfResults.map((r) => r.id); + if ( + selectedSourceModel && + modelSource === "hf" && + !ids.includes(selectedSourceModel) + ) { + ids.push(selectedSourceModel); + } + return ids; + }, [hfResults, modelSource, selectedSourceModel]); + + const exportableLocalModels = useMemo( + () => + localModels.filter((m) => { + if (m.path.endsWith(".gguf")) return false; + if (m.id.toLowerCase().includes("-gguf")) return false; + return true; + }), + [localModels], + ); + + const localMetaById = useMemo(() => { + const map = new Map(); + for (const model of exportableLocalModels) map.set(model.id, model); + return map; + }, [exportableLocalModels]); + + const localResultIds = useMemo(() => { + const ids = exportableLocalModels.map((model) => model.id); + const manual = localModelInput.trim(); + if (manual && !ids.includes(manual)) { + ids.unshift(manual); + } + return ids; + }, [exportableLocalModels, localModelInput]); + + const localFilteredIds = useMemo(() => { + const q = localModelInput.trim().toLowerCase(); + if (!q) return localResultIds; + return localResultIds.filter((id) => { + const meta = localMetaById.get(id); + if (id.toLowerCase().includes(q)) return true; + if (meta?.display_name.toLowerCase().includes(q)) return true; + if (meta?.path.toLowerCase().includes(q)) return true; + return false; + }); + }, [localMetaById, localModelInput, localResultIds]); + + const exportGuideSteps = useMemo( + () => + sourceMode === "model" + ? [ + "Select a Hugging Face or local model to export from", + "GGUF is used for non-finetuned model exports", + "Pick one or more GGUF quantization levels", + "Click Export and choose your destination", + "Test your model and compare outputs in Chat", + ] + : GUIDE_STEPS, + [sourceMode], + ); // Reset checkpoint when the selected model changes useEffect(() => { @@ -144,6 +290,25 @@ export function ExportPage() { } }, [isAdapter, isQuantized, exportMethod]); + const handleSourceModeSwitch = useCallback( + (next: "checkpoint" | "model") => { + setSourceMode(next); + if (next === "model") { + setExportMethod("gguf"); + } + setSelectedSourceModel(null); + setLocalModelInput(""); + setModelInput(""); + }, + [], + ); + + useEffect(() => { + setSelectedSourceModel(null); + setLocalModelInput(""); + setModelInput(""); + }, [modelSource]); + const handleMethodChange = (method: ExportMethod) => { setExportMethod(method); if (method !== "gguf") { @@ -152,19 +317,24 @@ export function ExportPage() { }; const estimatedSize = getEstimatedSize(exportMethod, quantLevels); - const canExport = - checkpoint && + const selectedExportSource = + sourceMode === "checkpoint" ? checkpoint : selectedSourceModel; + const canExport = !!( + selectedExportSource && exportMethod && - (exportMethod !== "gguf" || quantLevels.length > 0); + (exportMethod !== "gguf" || quantLevels.length > 0) + ); // ---- Export handler ---- const handleExport = useCallback(async () => { - if (!checkpoint) return; + const source = sourceMode === "checkpoint" ? checkpoint : selectedSourceModel; + if (!source) return; - const selectedCp = checkpointsForModel.find( - (cp) => cp.display_name === checkpoint, - ); - if (!selectedCp) return; + const selectedCp = sourceMode === "checkpoint" + ? checkpointsForModel.find((cp) => cp.display_name === checkpoint) + : null; + if (sourceMode === "checkpoint" && !selectedCp) return; + const checkpointPath = selectedCp?.path; setExporting(true); setExportError(null); @@ -174,7 +344,8 @@ export function ExportPage() { // For other formats, nest under training-run/checkpoint const saveDir = exportMethod === "gguf" - ? `${baseModelName.split("/").pop() ?? selectedModelIdx ?? "model"}-finetune-gguf` + ? `${(sourceBaseModelName.split("/").pop() ?? selectedModelIdx ?? "model") + .replace(/[^a-zA-Z0-9._-]/g, "-")}-gguf` : `${selectedModelIdx ?? "model"}/${checkpoint}`; const pushToHub = destination === "hub"; const repoId = pushToHub && hfUsername && modelName @@ -183,8 +354,18 @@ export function ExportPage() { const token = pushToHub && hfToken ? hfToken : undefined; try { - // 1. Load checkpoint - await loadCheckpoint({ checkpoint_path: selectedCp.path }); + // 1. Load model source + if (sourceMode === "checkpoint") { + if (!checkpointPath) return; + await loadCheckpoint({ checkpoint_path: checkpointPath }); + } else { + await loadCheckpoint({ + checkpoint_path: source, + load_in_4bit: false, + trust_remote_code: + modelSource === "hf" ? hfExportTrustRemoteCode : true, + }); + } // 2. Run export based on method if (exportMethod === "merged") { @@ -242,16 +423,21 @@ export function ExportPage() { }, [ checkpoint, checkpointsForModel, + sourceMode, + selectedSourceModel, selectedModelIdx, selectedModelData, exportMethod, isAdapter, + sourceBaseModelName, quantLevels, destination, hfUsername, modelName, hfToken, privateRepo, + modelSource, + hfExportTrustRemoteCode, ]); // ---- Render ---- @@ -265,14 +451,14 @@ export function ExportPage() { Export Model

- Export your fine-tuned model for deployment + Export fine-tuned or base models for deployment

} title="Export Configuration" - description="Select checkpoint, method, and quantization" + description="Select source, method, and quantization" accent="emerald" featured={true} className="shadow-border ring-1 ring-border" @@ -296,11 +482,10 @@ export function ExportPage() { <> {/* Top row: Dropdowns + metadata | Guide */}
-
- {/* Training run dropdown */} -
+
+
+ + ); + })} + + +
- {/* Checkpoint dropdown */} -
- + - - - - - {checkpointsForModel.map((cp) => ( - - - {cp.display_name} - {cp.loss != null && ( - - loss: {cp.loss.toFixed(4)} + + + {checkpointsForModel.map((cp) => ( + + + {cp.display_name} + {cp.loss != null && ( + + loss: {cp.loss.toFixed(4)} + + )} - )} - - - ))} - - -
+ + ))} + + +
+ + ) : ( + +
+ + +
-
- - Training Info - -
-
- Base Model - {baseModelName} -
-
- Method - - {trainingMethodLabel} - -
-
- Checkpoints - - {checkpointsForModel.length} - -
- {isAdapter && ( -
- LoRA Rank - {loraRank} + {modelSource === "hf" ? ( + <> +
+ +
+ { + setModelInput(val); + setSelectedSourceModel(null); + }} + itemToStringValue={(id) => id} + autoHighlight={true} + > + + + + + + + {isLoadingHfModels ? ( +
+ Searching… +
+ ) : ( + No models found + )} + + {(id: string) => ( + + + {id} + + + )} + +
+
+
+ {(tokenValidationError ?? hfSearchError) && ( +

+ {tokenValidationError ?? hfSearchError} +

+ )} +
+
+ + + + + + + + Loads custom Python from the repo if the model + needs it. Turn off if you do not trust the + source. + + +
+
+ + + + + + setHfToken(e.target.value)} + /> + + {isCheckingToken && ( +

Checking token…

+ )} +
+ + ) : ( +
+ +
+ { + const next = id ?? ""; + setLocalModelInput(next); + setSelectedSourceModel(next || null); + }} + onInputValueChange={setLocalModelInput} + itemToStringValue={(id) => id} + autoHighlight={true} + > + + setSelectedSourceModel(localModelInput.trim() || null) + } + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + setSelectedSourceModel(localModelInput.trim() || null); + }} + > + + + + + + {isLoadingLocalModels ? ( +
+ Scanning... +
+ ) : localModelsError ? ( +
+ {localModelsError} +
+ ) : ( + No local models found + )} + + {(id: string) => { + const model = localMetaById.get(id); + const source = + model?.source === "hf_cache" + ? "HF cache" + : "Local dir"; + return ( + + + {model?.display_name ?? id} + + + {source} + + + ); + }} + +
+
+
+ {isLoadingLocalModels ? ( +

+ Scanning local models... +

+ ) : localModelsError ? ( +

{localModelsError}

+ ) : ( +

+ {exportableLocalModels.length > 0 + ? `${exportableLocalModels.length} local/cached models found` + : "No local models found. Enter path manually."} +

+ )}
)} + +
+

+ Direct model exports currently support GGUF only. +

+
+ + )} + + + {sourceMode === "checkpoint" && ( +
+ + Training Info + +
+
+ Base Model + {baseModelName} +
+
+ Method + + {trainingMethodLabel} + +
+
+ Checkpoints + + {checkpointsForModel.length} + +
+ {isAdapter && ( +
+ LoRA Rank + {loraRank} +
+ )} +
-
+ )}
@@ -462,7 +915,7 @@ export function ExportPage() { Quick Guide
    - {GUIDE_STEPS.map((step, i) => ( + {exportGuideSteps.map((step, i) => (
  1. {exportMethod === "gguf" && ( - + )} @@ -530,12 +985,12 @@ export function ExportPage() { Date: Sat, 28 Mar 2026 22:26:49 +0400 Subject: [PATCH 02/26] Fix blank page on Windows due to broken .js MIME type (#4674) * Fix blank page on Windows due to broken .js MIME type in registry * Update studio/backend/main.py adding defensive suggestion by gemini where we make the mimetypes specific to windows platforms Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- studio/backend/main.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/studio/backend/main.py b/studio/backend/main.py index 65f5e7fe90..67908d8617 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -23,10 +23,23 @@ if _backend_dir not in sys.path: # See: https://github.com/python/cpython/issues/102396 import _platform_compat # noqa: F401 +import mimetypes import shutil import warnings from contextlib import asynccontextmanager +# Fix broken Windows registry MIME types. Some Windows installs map .js to +# "text/plain" in the registry (HKCR\.js\Content Type). Python's mimetypes +# module reads from the registry, and FastAPI/Starlette's StaticFiles uses +# mimetypes.guess_type() to set Content-Type headers. Browsers enforce strict +# MIME checking for ES module scripts (). Use \s* before > in both script and style patterns. * Address reviewer findings: SSRF, timeout crash, XML regex, dedup - SSRF: resolve hostname via getaddrinfo and reject private, loopback, link-local, multicast, and reserved addresses before fetching - Timeout: handle timeout=None (unlimited mode) in URL fetch path by defaulting to 60s instead of crashing on min(None, 60) - Download cap: read at most max_chars*4+1 bytes instead of the full response body before truncating - XML regex: match both and markup in the history/stream cleanup (inference.py) - CodeQL: use [^>]* in closing script/style tags to handle any whitespace or attributes before > - Dedup: track whether each tool call failed so retries after transient errors are allowed; only block consecutive identical calls that both succeeded - Final-answer synthesis: guard on max_tool_iterations > 0 so callers who disable tools do not get a false "used all calls" turn * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix redirect SSRF, SSE streaming regression, dedup off-by-one - SSRF redirect bypass: disable auto-redirect in urllib, manually follow up to 5 hops with host validation at each step. Prevents public URLs from redirecting to loopback/private targets. - SSE streaming: track prev_text on the raw cumulative and strip XML from the delta only, so completed tool_call tags do not cause the cumulative to shrink and drop trailing real text. - Dedup off-by-one: check the immediately previous call (window=1) instead of requiring 2 matching history entries, so the second identical successful call is blocked rather than the third. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix redirect HTTPError handling and tighten error prefixes - Redirect fix: urllib raises HTTPError (not a normal response) when the redirect handler returns None. Catch HTTPError for 3xx codes and extract the Location header from the exception object. - Error prefixes: remove overly broad "No " prefix that matched "No results found." (a valid empty-search outcome, not an error). Replace with specific prefixes like "Blocked:", "No query provided", "Failed to resolve". This ensures empty search results are correctly classified as non-errors for duplicate-call tracking. * Fix SSE cross-chunk XML leaks, cleanup review findings - SSE streaming: sanitize the full cumulative text before diffing against the previous sanitized snapshot, so XML tags that span chunk boundaries are stripped correctly. The previous delta-based approach leaked split tags. - DRAINING fallback: use _strip_tool_markup() helper instead of a manual regex that only handled but not . - Move hashlib import, _TOOL_XML_RE compile, and datetime import to module level per style guide. - Remove unused _hit_tool_cap variable. * Fix DNS rebinding, charset detection, HTTPError handling, dedup double-record - DNS rebinding: resolve hostname once via getaddrinfo, pin the returned IP, rewrite the URL to connect to the pinned IP with a Host header. Each redirect hop re-resolves and re-validates. Closes the TOCTOU window between validation and connection. - Charset: use resp.headers.get_content_charset() instead of hardcoding utf-8, so pages with other encodings decode correctly. - HTTPError: return descriptive "HTTP {code} {reason}" instead of re-raising into a generic "Search failed" message. - Dedup: remove redundant _record_tool_call in the duplicate branch; the single call at the end of the loop handles all cases. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/core/inference/llama_cpp.py | 103 +++++++-- studio/backend/core/inference/tools.py | 204 +++++++++++++++++- studio/backend/models/inference.py | 2 +- studio/backend/routes/inference.py | 81 ++++++- .../tests/tool_calling_benchmark_results.md | 62 ++++++ .../chat/stores/chat-runtime-store.ts | 2 +- 6 files changed, 428 insertions(+), 26 deletions(-) create mode 100644 studio/backend/tests/tool_calling_benchmark_results.md diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index f5361a6e8c..c1f87ff936 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -10,7 +10,9 @@ through its OpenAI-compatible /v1/chat/completions endpoint. import atexit import contextlib +import hashlib import json +import re import struct import structlog from loggers import get_logger @@ -2120,7 +2122,7 @@ class LlamaCppBackend: stop: Optional[list[str]] = None, cancel_event: Optional[threading.Event] = None, enable_thinking: Optional[bool] = None, - max_tool_iterations: int = 10, + max_tool_iterations: int = 25, auto_heal_tool_calls: bool = True, tool_call_timeout: int = 300, session_id: Optional[str] = None, @@ -2172,6 +2174,29 @@ class LlamaCppBackend: ) _MAX_BUFFER_CHARS = 32 + # ── Duplicate tool-call detection ──────────────────────── + # Track recent (tool_name, arguments) hashes to detect loops + # where the model repeats the exact same call. Retries after + # a transient failure are allowed (only block when the previous + # identical call succeeded). + _tool_call_history: list[tuple[str, bool]] = [] # (key, failed) + + def _tool_call_key(name: str, args: dict) -> str: + raw = json.dumps({"t": name, "a": args}, sort_keys = True) + return hashlib.md5(raw.encode()).hexdigest() + + def _is_duplicate_call(name: str, args: dict) -> bool: + """Block if the immediately previous call was identical and succeeded.""" + if not _tool_call_history: + return False + key = _tool_call_key(name, args) + last_key, last_failed = _tool_call_history[-1] + return last_key == key and not last_failed + + def _record_tool_call(name: str, args: dict, failed: bool) -> None: + key = _tool_call_key(name, args) + _tool_call_history.append((key, failed)) + for iteration in range(max_tool_iterations): if cancel_event is not None and cancel_event.is_set(): return @@ -2568,6 +2593,11 @@ class LlamaCppBackend: # Merge accumulated metrics from prior tool # iterations so they are not silently dropped. yield {"type": "status", "text": ""} + if content_accum: + # Strip leaked tool-call XML before yielding + content_accum = _strip_tool_markup( + content_accum, final = True + ) if content_accum: yield {"type": "content", "text": content_accum} _fu = _iter_usage or {} @@ -2661,16 +2691,27 @@ class LlamaCppBackend: "arguments": arguments, } - _effective_timeout = ( - None if tool_call_timeout >= 9999 else tool_call_timeout - ) - result = execute_tool( - tool_name, - arguments, - cancel_event = cancel_event, - timeout = _effective_timeout, - session_id = session_id, - ) + # ── Duplicate call detection ────────────── + if _is_duplicate_call(tool_name, arguments): + result = ( + "You already made this exact call. " + "Do not repeat the same tool call. " + "Try a different approach: fetch a URL " + "from previous results, use Python to " + "process data you already have, or " + "provide your final answer now." + ) + else: + _effective_timeout = ( + None if tool_call_timeout >= 9999 else tool_call_timeout + ) + result = execute_tool( + tool_name, + arguments, + cancel_event = cancel_event, + timeout = _effective_timeout, + session_id = session_id, + ) yield { "type": "tool_end", @@ -2679,10 +2720,32 @@ class LlamaCppBackend: "result": result, } + # Nudge model to try a different approach on errors + _error_prefixes = ( + "Error", + "Search failed", + "Execution error", + "Blocked:", + "Exit code", + "Failed to fetch", + "Failed to resolve", + "No query provided", + ) + _is_error = isinstance(result, str) and result.lstrip().startswith( + _error_prefixes + ) + _record_tool_call(tool_name, arguments, failed = _is_error) + _result_content = result + if _is_error: + _result_content = ( + result + "\n\nThe tool call encountered an issue. " + "Please try a different approach or rephrase your request." + ) + tool_msg = { "role": "tool", "name": tool_name, - "content": result, + "content": _result_content, } tool_call_id = tc.get("id") if tool_call_id: @@ -2699,6 +2762,22 @@ class LlamaCppBackend: return raise + # ── Tool iteration cap reached -- synthesize final answer ── + # The model used all iterations without producing a final text + # response. Inject a nudge so the final streaming pass produces + # a useful answer instead of continuing to request tools. + if max_tool_iterations > 0: + conversation.append( + { + "role": "user", + "content": ( + "You have used all available tool calls. Based on " + "everything you have found so far, provide your final " + "answer now. Do not call any more tools." + ), + } + ) + # Clear status yield {"type": "status", "text": ""} diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 55bfa095f9..65302fe2f3 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -57,16 +57,23 @@ WEB_SEARCH_TOOL = { "type": "function", "function": { "name": "web_search", - "description": "Search the web for current information, recent events, or facts you are uncertain about.", + "description": ( + "Search the web and fetch page content. Returns snippets for all results. " + "Use the url parameter to fetch full page text from a specific URL." + ), "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "The search query", - } + }, + "url": { + "type": "string", + "description": "A URL to fetch full page content from (instead of searching). Use this to read a page found in search results.", + }, }, - "required": ["query"], + "required": [], }, }, } @@ -131,7 +138,11 @@ def execute_tool( ) effective_timeout = _EXEC_TIMEOUT if timeout is _TIMEOUT_UNSET else timeout if name == "web_search": - return _web_search(arguments.get("query", ""), timeout = effective_timeout) + return _web_search( + arguments.get("query", ""), + url = arguments.get("url"), + timeout = effective_timeout, + ) if name == "python": return _python_exec( arguments.get("code", ""), cancel_event, effective_timeout, session_id @@ -143,9 +154,180 @@ def execute_tool( return f"Unknown tool: {name}" -def _web_search(query: str, max_results: int = 5, timeout: int = _EXEC_TIMEOUT) -> str: - """Search the web using DuckDuckGo and return formatted results.""" - if not query.strip(): +_MAX_PAGE_CHARS = 16000 # limit fetched page text +_MAX_FETCH_BYTES = _MAX_PAGE_CHARS * 4 + 1 # cap raw download size + + +def _validate_and_resolve_host(hostname: str, port: int) -> tuple[bool, str, str]: + """Resolve *hostname*, reject non-public IPs, return a pinned IP string. + + Returns ``(ok, reason_or_empty, resolved_ip)``. The caller should + connect to *resolved_ip* (with a ``Host`` header) to prevent DNS + rebinding between validation and the actual fetch. + """ + import ipaddress + import socket + + try: + infos = socket.getaddrinfo(hostname, port, type = socket.SOCK_STREAM) + except OSError as e: + return False, f"Failed to resolve host: {e}", "" + + if not infos: + return False, f"Failed to resolve host: no addresses for {hostname!r}", "" + + for *_, sockaddr in infos: + ip = ipaddress.ip_address(sockaddr[0]) + if ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_multicast + or ip.is_reserved + or ip.is_unspecified + ): + return False, f"Blocked: refusing to fetch non-public address {ip}.", "" + + # Return the first resolved address for pinning + first_ip = infos[0][4][0] + return True, "", first_ip + + +def _fetch_page_text( + url: str, max_chars: int = _MAX_PAGE_CHARS, timeout: int = 30 +) -> str: + """Fetch a URL and return plain text content (HTML tags stripped). + + Blocks private/loopback/link-local targets (SSRF protection) and caps + the download size to avoid unbounded memory usage. + """ + import re as _re + from urllib.parse import urlparse + + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + return f"Blocked: only http/https URLs are allowed (got {parsed.scheme!r})." + if not parsed.hostname: + return "Blocked: URL is missing a hostname." + + port = parsed.port or (443 if parsed.scheme == "https" else 80) + ok, reason, pinned_ip = _validate_and_resolve_host(parsed.hostname, port) + if not ok: + return reason + + try: + import urllib.request + from urllib.error import HTTPError as _HTTPError + from urllib.parse import urljoin, urlunparse + + # Disable auto-redirect so we can validate each hop for SSRF. + # urllib raises HTTPError for 3xx when the handler returns None, + # so we catch that and extract the Location header manually. + class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + opener = urllib.request.build_opener(_NoRedirect) + max_bytes = max_chars * 4 + 1 + current_url = url + current_host = parsed.hostname + + for _hop in range(5): + # Pin to the validated IP to prevent DNS rebinding. + # Rewrite the URL to use the IP and set the Host header. + cp = urlparse(current_url) + ip_netloc = f"{pinned_ip}:{cp.port}" if cp.port else pinned_ip + pinned_url = urlunparse(cp._replace(netloc = ip_netloc)) + + req = urllib.request.Request( + pinned_url, + headers = { + "User-Agent": "UnslothStudio/1.0", + "Host": current_host, + }, + ) + try: + resp = opener.open(req, timeout = timeout) + except _HTTPError as e: + if e.code not in (301, 302, 303, 307, 308): + return ( + f"Failed to fetch URL: HTTP {e.code} {getattr(e, 'reason', '')}" + ) + location = e.headers.get("Location") + if not location: + return "Failed to fetch URL: redirect missing Location header." + current_url = urljoin(current_url, location) + rp = urlparse(current_url) + if rp.scheme not in ("http", "https") or not rp.hostname: + return "Blocked: redirect target is not a valid http/https URL." + rp_port = rp.port or (443 if rp.scheme == "https" else 80) + ok2, reason2, pinned_ip = _validate_and_resolve_host( + rp.hostname, + rp_port, + ) + if not ok2: + return reason2 + current_host = rp.hostname + continue + # Success -- read capped body + raw_bytes = resp.read(max_bytes) + break + else: + return "Failed to fetch URL: too many redirects." + + charset = resp.headers.get_content_charset() or "utf-8" + raw_html = raw_bytes.decode(charset, errors = "replace") + except _HTTPError as e: + return f"Failed to fetch URL: HTTP {e.code} {getattr(e, 'reason', '')}" + except Exception as e: + return f"Failed to fetch URL: {e}" + + # Convert HTML to text -- prefer html2text for clean markdown output + try: + import html2text as _h2t + + converter = _h2t.HTML2Text() + converter.ignore_links = False + converter.ignore_images = True + converter.body_width = 0 # no wrapping + text = converter.handle(raw_html).strip() + except ImportError: + # Fallback: regex-based stripping + text = _re.sub( + r"]*>.*?]*>", + "", + raw_html, + flags = _re.DOTALL | _re.IGNORECASE, + ) + text = _re.sub( + r"]*>.*?]*>", "", text, flags = _re.DOTALL | _re.IGNORECASE + ) + text = _re.sub(r"<[^>]+>", " ", text) + text = _re.sub(r"\s+", " ", text).strip() + + if not text: + return "(page returned no readable text)" + if len(text) > max_chars: + text = text[:max_chars] + f"\n\n... (truncated, {len(text)} chars total)" + return text + + +def _web_search( + query: str, + max_results: int = 5, + timeout: int = _EXEC_TIMEOUT, + url: str | None = None, +) -> str: + """Search the web using DuckDuckGo and return formatted results. + + If ``url`` is provided, fetches that page directly instead of searching. + """ + # Direct URL fetch mode + if url and url.strip(): + fetch_timeout = 60 if timeout is None else min(timeout, 60) + return _fetch_page_text(url.strip(), timeout = fetch_timeout) + + if not query or not query.strip(): return "No query provided." try: from ddgs import DDGS @@ -160,7 +342,13 @@ def _web_search(query: str, max_results: int = 5, timeout: int = _EXEC_TIMEOUT) f"URL: {r.get('href', '')}\n" f"Snippet: {r.get('body', '')}" ) - return "\n\n---\n\n".join(parts) + text = "\n\n---\n\n".join(parts) + text += ( + "\n\n---\n\nIMPORTANT: These are only short snippets. " + "To get the full page content, call web_search with " + 'the url parameter (e.g. {"url": ""}).' + ) + return text except Exception as e: return f"Search failed: {e}" diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index aabfba9b3a..77f70b9bd6 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -344,7 +344,7 @@ class ChatCompletionRequest(BaseModel): description = "[x-unsloth] Auto-detect and fix malformed tool calls from model output.", ) max_tool_calls_per_message: Optional[int] = Field( - 10, + 25, ge = 0, description = "[x-unsloth] Maximum number of tool call iterations per message (0 = disabled, 9999 = unlimited).", ) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 1a94256059..9bce371775 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -86,8 +86,15 @@ import io import wave import base64 import numpy as np +from datetime import date as _date router = APIRouter() + +# Regex for stripping leaked tool-call XML from assistant messages/stream +_TOOL_XML_RE = _re.compile( + r".*?|.*?", + _re.DOTALL, +) logger = get_logger(__name__) @@ -1078,6 +1085,68 @@ async def openai_chat_completions( else: tools_to_use = ALL_TOOLS + # ── Tool-use system prompt nudge ────────────────────── + _tool_names = {t["function"]["name"] for t in tools_to_use} + _has_web = "web_search" in _tool_names + _has_code = "python" in _tool_names or "terminal" in _tool_names + + _date_line = f"The current date is {_date.today().isoformat()}." + + _web_tips = ( + "When you search and find a relevant URL in the results, " + "fetch its full content by calling web_search with the url parameter. " + "Do not repeat the same search query. If a search returns " + "no useful results, try rephrasing or fetching a result URL directly." + ) + _code_tips = ( + "Use code execution for math, calculations, data processing, " + "or to parse and analyze information from tool results." + ) + + if _has_web and _has_code: + _nudge = ( + _date_line + " " + "You have access to tools. When appropriate, prefer using " + "tools rather than answering from memory. " + + _web_tips + + " " + + _code_tips + ) + elif _has_code: + _nudge = ( + _date_line + " " + "You have access to tools. When appropriate, prefer using " + "code execution rather than answering from memory. " + _code_tips + ) + elif _has_web: + _nudge = ( + _date_line + " " + "You have access to tools. When appropriate, prefer using " + "web search for up-to-date or uncertain factual " + "information rather than answering from memory. " + _web_tips + ) + else: + _nudge = "" + + if _nudge: + # Append nudge to system prompt (preserve user's prompt) + if system_prompt: + system_prompt = system_prompt.rstrip() + "\n\n" + _nudge + else: + system_prompt = _nudge + # Rebuild gguf_messages with updated system prompt + gguf_messages = [] + if system_prompt: + gguf_messages.append({"role": "system", "content": system_prompt}) + gguf_messages.extend(chat_messages) + + # ── Strip stale tool-call XML from conversation history ─ + for _msg in gguf_messages: + if _msg.get("role") == "assistant" and isinstance( + _msg.get("content"), str + ): + _msg["content"] = _TOOL_XML_RE.sub("", _msg["content"]).strip() + def gguf_generate_with_tools(): return llama_backend.generate_chat_completion_with_tools( messages = gguf_messages, @@ -1096,7 +1165,7 @@ async def openai_chat_completions( else True, max_tool_iterations = payload.max_tool_calls_per_message if payload.max_tool_calls_per_message is not None - else 10, + else 25, tool_call_timeout = payload.tool_call_timeout if payload.tool_call_timeout is not None else 300, @@ -1158,9 +1227,13 @@ async def openai_chat_completions( continue # "content" type -- cumulative text - cumulative = event.get("text", "") - new_text = cumulative[len(prev_text) :] - prev_text = cumulative + # Sanitize the full cumulative then diff against + # the last sanitized snapshot so cross-chunk XML + # tags are handled correctly. + raw_cumulative = event.get("text", "") + clean_cumulative = _TOOL_XML_RE.sub("", raw_cumulative) + new_text = clean_cumulative[len(prev_text) :] + prev_text = clean_cumulative if not new_text: continue chunk = ChatCompletionChunk( diff --git a/studio/backend/tests/tool_calling_benchmark_results.md b/studio/backend/tests/tool_calling_benchmark_results.md new file mode 100644 index 0000000000..c2b0687895 --- /dev/null +++ b/studio/backend/tests/tool_calling_benchmark_results.md @@ -0,0 +1,62 @@ +# GGUF Tool Calling Benchmark Results + +Prompt: "List and categorize all the songs that charted #3 on the Billboard Hot 100 in 2015." +10 runs per configuration, web search + code execution + thinking enabled. +GPU: NVIDIA B200, CUDA_VISIBLE_DEVICES=2. + +Ground truth: 4 songs peaked at #3 in 2015 -- "Love Me like You Do" (Ellie Goulding), "Earned It" (The Weeknd), "Watch Me" (Silento), "Drag Me Down" (One Direction). + +## Cartesian Grid: Model x Quant x KV Cache + +| Model | Quant | KV Cache | OK/10 | Avg Time | Avg Tools | XML Leaks | URL Fetch | Peak3 Avg | All 4/4 | Best Songs | +|-------|-------|----------|-------|----------|-----------|-----------|-----------|-----------|---------|------------| +| 4B | UD-Q4_K_XL | f16 | 10/10 | 9.8s | 3.5 | 0/10 | 4/10 | 0.8/4 | 2/10 | 9 | +| 4B | UD-Q4_K_XL | bf16 | 10/10 | 10.6s | 4.5 | 0/10 | 4/10 | 0.4/4 | 1/10 | 5 | +| 4B | Q8_0 | f16 | 10/10 | 4.9s | 2.4 | 0/10 | 8/10 | 0.4/4 | 1/10 | 5 | +| 4B | Q8_0 | bf16 | 10/10 | 8.0s | 3.0 | 0/10 | 5/10 | 0.0/4 | 0/10 | 0 | +| 9B | UD-Q4_K_XL | f16 | 10/10 | 6.7s | 2.0 | 0/10 | 5/10 | 0.0/4 | 0/10 | 3 | +| 9B | UD-Q4_K_XL | bf16 | 9/10 | 49.5s | 2.4 | 0/10 | 5/10 | 0.0/4 | 0/10 | 1 | +| 9B | Q8_0 | f16 | 10/10 | 7.4s | 2.5 | 0/10 | 5/10 | 0.0/4 | 0/10 | 2 | +| 9B | Q8_0 | bf16 | 10/10 | 10.4s | 2.7 | 0/10 | 6/10 | 1.0/4 | 2/10 | 15 | +| **27B** | **UD-Q4_K_XL** | **bf16** | **9/10** | **131.1s** | **13.8** | **0/10** | **7/10** | **2.7/4** | **6/10** | **27** | +| 27B | UD-Q4_K_XL | f16 | 7/10 | 201.6s | 14.1 | 0/10 | 8/10 | 2.0/4 | 5/10 | 26 | +| 27B | Q8_0 | f16 | 4/10 | 312.5s | 16.0 | 1/10 | 10/10 | 2.4/4 | 6/10 | 28 | +| 27B | Q8_0 | bf16 | 5/10 | 258.4s | 16.5 | 2/10 | 10/10 | 0.9/4 | 1/10 | 27 | +| 35B-A3B | UD-Q4_K_XL | f16 | 3/10 | 353.6s | 14.7 | 1/10 | 6/10 | 1.2/4 | 3/10 | 27 | +| 35B-A3B | UD-Q4_K_XL | bf16 | 3/10 | 356.2s | 17.2 | 1/10 | 8/10 | 1.6/4 | 4/10 | 27 | +| 35B-A3B | Q8_0 | f16 | 2/10 | 372.1s | 17.6 | 1/10 | 7/10 | 1.2/4 | 3/10 | 26 | +| 35B-A3B | Q8_0 | bf16 | 6/10 | 267.7s | 17.5 | 1/10 | 8/10 | 2.4/4 | 6/10 | 27 | + +**Column definitions:** +- **Peak3 Avg**: Average number of correct peak-#3 songs found per run (out of 4) +- **All 4/4**: Runs where all 4 correct songs were identified +- **Best Songs**: Maximum number of Billboard 2015 songs mentioned in any single run (out of 31 tracked) +- **URL Fetch**: Runs where the model used web_search with `url` parameter to fetch full page content + +## Key Findings + +1. **27B UD-Q4_K_XL + bf16 KV is the sweet spot.** 6/10 runs found all 4 correct songs, 0 XML leaks, 131s average. Best balance of accuracy, speed, and reliability. + +2. **Larger models use tools more effectively.** 27B and 35B-A3B models used 13-17 tool calls per query (vs 2-4 for 4B/9B), performing multiple searches and URL fetches to find the answer. + +3. **27B Q8_0 had the highest raw accuracy (6/10 all-4/4) but lower reliability** -- only 4/10 OK runs due to timeouts on long agentic chains. The UD-Q4_K_XL quant is more practical. + +4. **4B models were fastest (5-10s) but least accurate.** They occasionally found all 4 songs (2/10 best case) when they happened to fetch the right Wikipedia page. + +5. **9B was surprisingly weaker than 4B on this task.** It used fewer tool calls and rarely extracted song data from fetched pages. The 9B model may need higher temperature or different prompting for this specific task type. + +6. **35B-A3B had reliability issues.** Most runs timed out or errored due to slow per-token generation with many tool iterations. When it completed (2-6/10 OK), accuracy was comparable to 27B. + +7. **bf16 KV cache had mixed effects.** For 27B it improved both speed (131s vs 202s) and accuracy (6/10 vs 5/10 all-4/4). For smaller models it had no consistent benefit. + +8. **XML leaks are nearly eliminated.** 0/10 for all 4B and 9B configs, and only 1-2/10 for the largest models (which generate much more text in complex agentic loops). + +## Before vs After (4B UD-Q4_K_XL, f16 KV) + +| Metric | Before Changes | After Changes | +|--------|---------------|---------------| +| XML leaks | 10/10 | 0/10 | +| URL fetches | 0/10 | 4/10 | +| Peak3 accuracy | 0.0/4 | 0.8/4 | +| Runs with all 4 songs | 0/10 | 2/10 | +| Avg time | 12.3s | 9.8s | 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 ca1044b3dc..8cea234f21 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -224,7 +224,7 @@ export const useChatRuntimeStore = create((set) => ({ toolStatus: null, generatingStatus: null, autoHealToolCalls: loadBool(AUTO_HEAL_TOOL_CALLS_KEY, true), - maxToolCallsPerMessage: loadInt(MAX_TOOL_CALLS_KEY, 10), + maxToolCallsPerMessage: loadInt(MAX_TOOL_CALLS_KEY, 25), toolCallTimeout: loadInt(TOOL_CALL_TIMEOUT_KEY, 5), kvCacheDtype: null, loadedKvCacheDtype: null, From 9451bb1bacc7cb5b7b9f2ad3448a19b4c7752bbd Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:05:55 +0100 Subject: [PATCH 23/26] fix(export): preserve selected/manual model on enter and blur (#4726) --- .../src/features/export/export-page.tsx | 96 ++++++++++++++++--- 1 file changed, 84 insertions(+), 12 deletions(-) diff --git a/studio/frontend/src/features/export/export-page.tsx b/studio/frontend/src/features/export/export-page.tsx index 98634c40ff..ee27e9888e 100644 --- a/studio/frontend/src/features/export/export-page.tsx +++ b/studio/frontend/src/features/export/export-page.tsx @@ -75,6 +75,8 @@ import { import { GuidedTour, useGuidedTourController } from "@/features/tour"; import { exportTourSteps } from "./tour"; +const SEARCH_INPUT_REASONS = new Set(["input-change", "input-paste", "input-clear"]); + export function ExportPage() { const { hfToken, setHfToken } = useTrainingConfigStore( useShallow((s) => ({ @@ -122,6 +124,9 @@ export function ExportPage() { const hfComboboxAnchorRef = useRef(null); const localComboboxAnchorRef = useRef(null); + const selectingHfModelRef = useRef(false); + const hfModelInputRef = useRef(""); + const localModelInputRef = useRef(""); const tour = useGuidedTourController({ id: "export", @@ -310,6 +315,14 @@ export function ExportPage() { setModelInput(""); }, [modelSource]); + useEffect(() => { + hfModelInputRef.current = modelInput; + }, [modelInput]); + + useEffect(() => { + localModelInputRef.current = localModelInput; + }, [localModelInput]); + const handleMethodChange = (method: ExportMethod) => { setExportMethod(method); if (method !== "gguf") { @@ -326,6 +339,58 @@ export function ExportPage() { (exportMethod !== "gguf" || quantLevels.length > 0) ); + const applyHfSourceModel = useCallback((value: string) => { + const next = value.trim(); + setModelInput(next); + setSelectedSourceModel(next || null); + }, []); + + const handleHfSourceModelSelect = useCallback((id: string | null) => { + selectingHfModelRef.current = true; + const next = id ?? ""; + hfModelInputRef.current = next; + setModelInput(next); + setSelectedSourceModel(id); + }, []); + + const handleHfSourceInputChange = useCallback( + (value: string, eventDetails?: { reason?: string }) => { + hfModelInputRef.current = value; + if (selectingHfModelRef.current) { + selectingHfModelRef.current = false; + return; + } + if (!SEARCH_INPUT_REASONS.has(eventDetails?.reason ?? "")) { + return; + } + setModelInput(value); + if (value.trim() === "") { + setSelectedSourceModel(null); + } + }, + [], + ); + + const applyLocalSourceModel = useCallback((value: string) => { + const next = value.trim(); + setLocalModelInput(next); + setSelectedSourceModel(next || null); + }, []); + + const handleLocalSourceInputChange = useCallback( + (value: string, eventDetails?: { reason?: string }) => { + localModelInputRef.current = value; + if (!SEARCH_INPUT_REASONS.has(eventDetails?.reason ?? "")) { + return; + } + setLocalModelInput(value); + if (value.trim() === "") { + setSelectedSourceModel(null); + } + }, + [], + ); + // ---- Export handler ---- const handleExport = useCallback(async () => { const source = sourceMode === "checkpoint" ? checkpoint : selectedSourceModel; @@ -680,16 +745,24 @@ export function ExportPage() { items={hfResultIds} filteredItems={hfResultIds} filter={null} - value={selectedSourceModel} - onValueChange={setSelectedSourceModel} - onInputValueChange={(val) => { - setModelInput(val); - setSelectedSourceModel(null); - }} + value={modelInput || selectedSourceModel || null} + onValueChange={handleHfSourceModelSelect} + onInputValueChange={handleHfSourceInputChange} itemToStringValue={(id) => id} autoHighlight={true} > - + + applyHfSourceModel(hfModelInputRef.current) + } + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + applyHfSourceModel(hfModelInputRef.current); + }} + > @@ -792,10 +865,11 @@ export function ExportPage() { value={localModelInput || null} onValueChange={(id) => { const next = id ?? ""; + localModelInputRef.current = next; setLocalModelInput(next); setSelectedSourceModel(next || null); }} - onInputValueChange={setLocalModelInput} + onInputValueChange={handleLocalSourceInputChange} itemToStringValue={(id) => id} autoHighlight={true} > @@ -806,13 +880,11 @@ export function ExportPage() { : "./models/my-model" } className="w-full" - onBlur={() => - setSelectedSourceModel(localModelInput.trim() || null) - } + onBlur={() => applyLocalSourceModel(localModelInputRef.current)} onKeyDown={(event) => { if (event.key !== "Enter") return; event.preventDefault(); - setSelectedSourceModel(localModelInput.trim() || null); + applyLocalSourceModel(localModelInputRef.current); }} > From 9a8b6223068e6cc65a829db8174d2c49b412ba0f Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 31 Mar 2026 06:15:18 -0700 Subject: [PATCH 24/26] Studio: simplify tool-call dedup and replace html2text with builtin converter (#4722) * Simplify tool-call dedup: drop hashlib, inline helpers The duplicate tool-call detector only compares calls within a single request from the same JSON parser, so dict key order is guaranteed identical for identical calls (Python 3.7+ insertion-ordered dicts). - Replace hashlib.md5(json.dumps(...)) with name + str(args) - Inline _tool_call_key, _is_duplicate_call, _record_tool_call since each was a one-liner used once - Remove unused hashlib import * Remove tool_calling_benchmark_results.md from repo * Replace html2text with builtin HTML-to-Markdown converter Drop the external html2text (GPL-3.0) dependency and its regex fallback. Add _html_to_md.py (~190 lines, stdlib only) using html.parser.HTMLParser that handles headings, links, bold/italic, lists, tables, blockquotes, code blocks, and entity decoding. Strips script/style/head tags entirely. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Use json.dumps(sort_keys=True) for tool-call dedup key str(dict) is sensitive to insertion order, so semantically identical calls with different key ordering would bypass duplicate detection. Switch to json.dumps with sort_keys=True for a canonical representation. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Revert dedup key to str(arguments) json.dumps(sort_keys=True) is unnecessary here -- the arguments dict always comes from the same JSON parser within a single request, so key insertion order is deterministic (Python 3.7+). str() is faster and sufficient for consecutive-call dedup. * Address review comments on _html_to_md.py - Remove "hr" from _BLOCK_TAGS so the dedicated hr handler is reachable - Prefix all newlines with ">" inside blockquotes (multi-line support) - Emit full ![alt](url) for images instead of alt text only - Replace newlines with spaces inside table cells - Track header cells per-row (_row_has_th) instead of last-cell-only - Strip trailing tabs in addition to spaces in cleanup regex * Fix blockquote rendering, truncated-HTML buffer flush, and dedup key canonicalization _html_to_md.py: - Rewrite blockquote handling with stack-based buffer approach so nested blockquotes, pre blocks inside blockquotes, and multi-paragraph quotes all render correctly with proper "> " prefix on every line. - Add flush_pending() to recover content from truncated HTML where closing tags are missing (common when _fetch_page_text caps the download size). Flushes open , ,
    , and blockquote buffers.
    - Skip  tags to match prior html2text ignore_images=True behavior
      and avoid data-URI amplification consuming the output budget.
    - Collapse all whitespace (including newlines) in non-pre content per
      standard HTML whitespace rules: \s+ -> single space.
    - Escape pipe characters in table cell content to prevent column breakage.
    - Emit separator row after the first row for tables without  headers.
    - Guard against IndexError on _ol_counter for orphan 
  2. elements. - Normalize CRLF line endings before parsing. llama_cpp.py: - Restore canonical dedup key with json.dumps(sort_keys=True) so that semantically identical tool calls with different JSON key order are correctly detected as duplicates. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix table optional end tags, inline code whitespace, and link text normalization _html_to_md.py: - Extract _finish_cell() and _finish_row() helpers to handle HTML tables that omit optional , , or end tags. This is valid HTML and common on real web pages -- previously the parser would silently drop earlier cells and entire rows. - Call _finish_cell()/_finish_row() from handle_starttag for //, handle_endtag for ///, and flush_pending() so all three paths (normal close, implicit close, truncated HTML) use the same row-finalization logic including header separator emission. - Add _in_inline_code flag so handle_data() preserves literal whitespace inside spans instead of collapsing it. Source like pip install unsloth now correctly renders as `pip install unsloth` rather than `pip install unsloth`. - Extract _finish_link() helper that normalizes accumulated link text with \s+ -> single space before building the Markdown link. Prevents block- level content inside tags (e.g.
    one
    two
    ) from producing multiline [one\n\ntwo](href) link labels. - Empty blockquotes now produce no output instead of a stray ">". - Remove unused _bq_depth field (all routing uses _bq_stack). - Flush open cells and rows in handle_endtag("table") for robustness. * Support
      ,
      /
      /
      , and preserve code block whitespace _html_to_md.py: - Honor
        attribute so ordered lists preserve their original numbering instead of always restarting from 1. Important for docs/tutorials that continue numbering across sections. - Add dl, dt, dd to _BLOCK_TAGS so definition lists (common on MDN, Python docs, Django docs) produce separated text instead of concatenated blobs. - Rewrite _cleanup() to be fence-aware: content inside fenced code blocks is now preserved verbatim (intentional blank lines in
         content are
          no longer collapsed). Outside code blocks, blank runs are limited to one
          and trailing whitespace is stripped.
        - Fix _prefix_blockquote() to strip trailing whitespace before collapsing
          blank lines, preventing the "\n\n \n\n" pattern from sneaking through.
        
        * Suppress whitespace-only text nodes between table structural elements
        
        Indented HTML tables (nearly all real-world pages) produce whitespace
        text nodes between 
    , , etc. that land in the output as leading spaces before table rows, breaking Markdown table alignment. Skip whitespace-only text nodes when inside a table but not inside a cell, so indentation from source HTML does not leak into the output. * Revert dedup key to str(arguments) with explanatory comment json.dumps(sort_keys=True) is unnecessary overhead here: arguments always comes from json.loads on model output within a single request, so dict insertion order is deterministic in Python 3.7+. A repeated call from the model produces the same JSON, which parses to the same dict repr. str() avoids re-serialization on every tool call. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/core/inference/_html_to_md.py | 439 ++++++++++++++++++ studio/backend/core/inference/llama_cpp.py | 26 +- studio/backend/core/inference/tools.py | 25 +- .../tests/tool_calling_benchmark_results.md | 62 --- 4 files changed, 449 insertions(+), 103 deletions(-) create mode 100644 studio/backend/core/inference/_html_to_md.py delete mode 100644 studio/backend/tests/tool_calling_benchmark_results.md diff --git a/studio/backend/core/inference/_html_to_md.py b/studio/backend/core/inference/_html_to_md.py new file mode 100644 index 0000000000..d96b8168e2 --- /dev/null +++ b/studio/backend/core/inference/_html_to_md.py @@ -0,0 +1,439 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Minimal HTML-to-Markdown converter using only the standard library. + +Replaces the external ``html2text`` (GPL-3.0) dependency with a ~250-line +``html.parser.HTMLParser`` subclass. Covers headings, links, bold/italic, +lists, tables, blockquotes, code blocks, and entity decoding. +""" + +from __future__ import annotations + +import html +import re +from html.parser import HTMLParser + +__all__ = ["html_to_markdown"] + +_SKIP_TAGS = frozenset({"script", "style", "head", "noscript", "svg", "math"}) +_BLOCK_TAGS = frozenset( + { + "p", + "div", + "section", + "article", + "header", + "footer", + "main", + "aside", + "nav", + "figure", + "figcaption", + "details", + "summary", + "dl", + "dt", + "dd", + } +) +_HEADING_TAGS = frozenset({"h1", "h2", "h3", "h4", "h5", "h6"}) +_INLINE_EMPHASIS = {"strong": "**", "b": "**", "em": "*", "i": "*"} + + +class _MarkdownRenderer(HTMLParser): + """HTMLParser subclass that emits Markdown tokens into a list.""" + + def __init__(self): + super().__init__(convert_charrefs = False) + self._out: list[str] = [] + self._skip_depth: int = 0 + + # Link state + self._link_href: str | None = None + self._link_text_parts: list[str] = [] + self._in_link: bool = False + + # List state + self._list_stack: list[str] = [] # "ul" or "ol" + self._ol_counter: list[int] = [] + + # Table state + self._in_table: bool = False + self._current_row: list[str] = [] + self._cell_parts: list[str] = [] + self._in_cell: bool = False + self._header_row_done: bool = False + self._row_has_th: bool = False + self._is_first_row: bool = False + + # Pre/code state + self._in_pre: bool = False + self._pre_parts: list[str] = [] + self._in_inline_code: bool = False + + # Blockquote state -- stack of output buffers so nested + # blockquotes each collect their own content and get prefixed + # with the correct number of ">" markers on close. + self._bq_stack: list[list[str]] = [] + + # ------------------------------------------------------------------ + def _emit(self, text: str) -> None: + if self._in_link: + self._link_text_parts.append(text) + elif self._in_cell: + self._cell_parts.append(text) + elif self._in_pre: + self._pre_parts.append(text) + elif self._bq_stack: + self._bq_stack[-1].append(text) + else: + self._out.append(text) + + # ------------------------------------------------------------------ + def _prefix_blockquote(self, content: str) -> str: + """Prefix every line of *content* with ``> ``.""" + # Strip trailing whitespace first, then collapse blank lines + content = re.sub(r"[ \t]+$", "", content, flags = re.MULTILINE) + content = re.sub(r"\n{3,}", "\n\n", content).strip() + if not content: + return "" + lines = content.split("\n") + prefixed: list[str] = [] + for line in lines: + if line.strip(): + prefixed.append("> " + line) + else: + prefixed.append(">") + return "\n".join(prefixed) + + # ------------------------------------------------------------------ + # Table helpers -- flush open cells and rows so that HTML with + # omitted optional end tags (, ) does not lose data. + # ------------------------------------------------------------------ + def _finish_cell(self) -> None: + if not self._in_cell: + return + self._in_cell = False + cell_text = "".join(self._cell_parts).strip().replace("\n", " ") + cell_text = cell_text.replace("|", "\\|") + self._current_row.append(cell_text) + self._cell_parts = [] + + def _finish_row(self) -> None: + if not self._current_row: + return + line = "| " + " | ".join(self._current_row) + " |" + self._emit(line + "\n") + if not self._header_row_done and (self._row_has_th or self._is_first_row): + sep = "| " + " | ".join("---" for _ in self._current_row) + " |" + self._emit(sep + "\n") + self._header_row_done = True + self._is_first_row = False + self._current_row = [] + self._row_has_th = False + + # ------------------------------------------------------------------ + # Link text helper -- normalize whitespace so block-level content + # inside an does not produce multiline Markdown link labels. + # ------------------------------------------------------------------ + def _finish_link(self) -> None: + text = re.sub(r"\s+", " ", "".join(self._link_text_parts)).strip() + href = self._link_href or "" + self._in_link = False + if href and text: + self._emit(f"[{text}]({href})") + elif text: + self._emit(text) + + # ------------------------------------------------------------------ + # Tag handlers + # ------------------------------------------------------------------ + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + tag = tag.lower() + + if tag in _SKIP_TAGS: + self._skip_depth += 1 + return + if self._skip_depth: + return + + attr_dict = dict(attrs) + + if tag in _HEADING_TAGS: + level = int(tag[1]) + self._emit("\n\n" + "#" * level + " ") + + elif tag == "a": + self._link_href = attr_dict.get("href") + self._link_text_parts = [] + self._in_link = True + + elif tag in _INLINE_EMPHASIS: + self._emit(_INLINE_EMPHASIS[tag]) + + elif tag == "br": + self._emit("\n") + + elif tag in _BLOCK_TAGS: + self._emit("\n\n") + + elif tag == "hr": + self._emit("\n\n---\n\n") + + elif tag == "blockquote": + self._emit("\n\n") + self._bq_stack.append([]) + + elif tag == "ul": + self._list_stack.append("ul") + self._emit("\n") + + elif tag == "ol": + self._list_stack.append("ol") + start_attr = attr_dict.get("start") + try: + start = int(start_attr) if start_attr is not None else 1 + except (ValueError, TypeError): + start = 1 + self._ol_counter.append(start - 1) + self._emit("\n") + + elif tag == "li": + indent = " " * max(0, len(self._list_stack) - 1) + if self._list_stack and self._list_stack[-1] == "ol": + if self._ol_counter: + self._ol_counter[-1] += 1 + self._emit(f"\n{indent}{self._ol_counter[-1]}. ") + else: + self._emit(f"\n{indent}1. ") + else: + self._emit(f"\n{indent}* ") + + elif tag == "pre": + self._pre_parts = [] + self._in_pre = True + + elif tag == "code" and not self._in_pre: + self._in_inline_code = True + self._emit("`") + + elif tag == "table": + self._in_table = True + self._header_row_done = False + self._is_first_row = True + self._emit("\n\n") + + elif tag == "tr": + # Flush any open cell/row from a previous row that may + # have omitted its optional or end tags. + self._finish_cell() + self._finish_row() + + elif tag in ("th", "td"): + # Flush any open cell (handles omitted /) + self._finish_cell() + self._finish_row() + self._in_table = False + self._emit("\n") + + # ------------------------------------------------------------------ + # Text / entity handlers + # ------------------------------------------------------------------ + def handle_data(self, data: str) -> None: + if self._skip_depth: + return + if self._in_pre: + self._pre_parts.append(data) + return + # Preserve literal whitespace inside inline spans + if self._in_inline_code: + self._emit(data) + return + # Collapse all whitespace (including newlines) per HTML rules + text = re.sub(r"\s+", " ", data) + # Suppress whitespace-only text nodes between table structural + # elements (indentation from source HTML) to prevent leading + # spaces from breaking Markdown table row alignment. + if self._in_table and not self._in_cell and not text.strip(): + return + self._emit(text) + + def handle_entityref(self, name: str) -> None: + if self._skip_depth: + return + self._emit(html.unescape(f"&{name};")) + + def handle_charref(self, name: str) -> None: + if self._skip_depth: + return + self._emit(html.unescape(f"&#{name};")) + + # ------------------------------------------------------------------ + # Flush pending buffers (handles truncated HTML from capped fetches) + # ------------------------------------------------------------------ + def flush_pending(self) -> None: + """Flush any open side-buffers into ``_out``. + + Called after ``close()`` to recover content from truncated HTML + where closing tags were never seen (common when ``_fetch_page_text`` + caps the download by byte count). + """ + # Flush innermost buffers first so their content propagates outward. + + if self._in_link: + self._finish_link() + + if self._in_inline_code: + self._in_inline_code = False + self._emit("`") + + self._finish_cell() + self._finish_row() + + if self._in_pre: + raw = "".join(self._pre_parts) + self._in_pre = False + block = "```\n" + raw + "\n```" + self._emit("\n\n" + block + "\n\n") + + # Flatten any open blockquote buffers (innermost first) + while self._bq_stack: + content = "".join(self._bq_stack.pop()) + prefixed = self._prefix_blockquote(content) + if not prefixed: + continue + if self._bq_stack: + self._bq_stack[-1].append("\n\n" + prefixed + "\n\n") + else: + self._out.append("\n\n" + prefixed + "\n\n") + + +# ------------------------------------------------------------------ +# Post-processing +# ------------------------------------------------------------------ +def _cleanup(text: str) -> str: + """Normalize whitespace and blank lines in the final output. + + Preserves content inside fenced code blocks verbatim so that + intentional blank lines in ``
    `` content are not collapsed.
    +    """
    +    lines = text.split("\n")
    +    out: list[str] = []
    +    in_fence = False
    +    blank_run = 0
    +
    +    for line in lines:
    +        stripped = line.rstrip(" \t")
    +        if stripped.startswith("```"):
    +            in_fence = not in_fence
    +            blank_run = 0
    +            out.append(stripped)
    +            continue
    +
    +        if in_fence:
    +            # Preserve code block content exactly as-is
    +            out.append(line)
    +            continue
    +
    +        if not stripped:
    +            blank_run += 1
    +            if blank_run <= 1:
    +                out.append("")
    +            continue
    +
    +        blank_run = 0
    +        out.append(stripped)
    +
    +    return "\n".join(out).strip()
    +
    +
    +# ------------------------------------------------------------------
    +# Public API
    +# ------------------------------------------------------------------
    +def html_to_markdown(source_html: str) -> str:
    +    """Convert an HTML string to Markdown.
    +
    +    Handles headings, links, bold/italic, lists (ordered and unordered),
    +    tables, blockquotes, code blocks, and HTML entities.  ``
    ) + self._finish_cell() + self._cell_parts = [] + self._in_cell = True + if tag == "th": + self._row_has_th = True + + elif tag == "img": + # Skip images -- keeps fetched page text focused on readable + # content and avoids data-URI amplification. + return + + def handle_endtag(self, tag: str) -> None: + tag = tag.lower() + + if tag in _SKIP_TAGS: + self._skip_depth = max(0, self._skip_depth - 1) + return + if self._skip_depth: + return + + if tag in _HEADING_TAGS: + self._emit("\n\n") + + elif tag == "a": + self._finish_link() + + elif tag in _INLINE_EMPHASIS: + self._emit(_INLINE_EMPHASIS[tag]) + + elif tag in _BLOCK_TAGS: + self._emit("\n\n") + + elif tag == "blockquote": + if self._bq_stack: + content = "".join(self._bq_stack.pop()) + prefixed = self._prefix_blockquote(content) + if prefixed: + self._emit("\n\n" + prefixed + "\n\n") + + elif tag == "ul": + if self._list_stack and self._list_stack[-1] == "ul": + self._list_stack.pop() + self._emit("\n") + + elif tag == "ol": + if self._list_stack and self._list_stack[-1] == "ol": + self._list_stack.pop() + if self._ol_counter: + self._ol_counter.pop() + self._emit("\n") + + elif tag == "pre": + raw = "".join(self._pre_parts) + self._in_pre = False + block = "```\n" + raw + "\n```" + self._emit("\n\n" + block + "\n\n") + + elif tag == "code" and not self._in_pre: + self._in_inline_code = False + self._emit("`") + + elif tag in ("th", "td"): + self._finish_cell() + + elif tag == "tr": + self._finish_cell() + self._finish_row() + + elif tag == "table": + # Flush any remaining row (handles omitted