Merge remote-tracking branch 'origin/main' into r6763_mainmerge
# Conflicts: # studio/backend/routes/models.py
This commit is contained in:
commit
3c6cae3863
43 changed files with 5976 additions and 412 deletions
|
|
@ -98,6 +98,14 @@
|
|||
"evidence": "L587: while True: sha256:06c2c7f15d73bf192e5e3272c5ff5fcaeff7f6774fef5f4eca6ef473ae50e2b3",
|
||||
"evidence_hash": "57acd497f404c203e4450d0580ad85aa8a33406e8d64ad06fbac6cf47d97b24d"
|
||||
},
|
||||
{
|
||||
"package": "fastapi",
|
||||
"file": "fastapi/routing.py",
|
||||
"check": "C2 polling/beaconing loop detected",
|
||||
"severity": "CRITICAL",
|
||||
"evidence": "L592: while True: sha256:84283c09277ded3296998b2a6a838744457b606829cf5ab5d0da6f222ff020a0",
|
||||
"evidence_hash": "a7295004315e26a8f3c64fb837521e9fdd7268219bb43e000fb0236ab0259223"
|
||||
},
|
||||
{
|
||||
"package": "fastmcp-slim",
|
||||
"file": "fastmcp/cli/apps_dev.py",
|
||||
|
|
|
|||
|
|
@ -11128,16 +11128,20 @@ class LlamaCppBackend:
|
|||
build_rag_autoinject,
|
||||
execute_tool,
|
||||
is_always_safe_tool,
|
||||
is_potentially_unsafe_tool_call,
|
||||
is_high_risk_tool_call,
|
||||
)
|
||||
|
||||
# Normalize the mode: "full" and bypass_permissions are the same
|
||||
# switch, whichever arrives first wins toward the permissive side.
|
||||
# "off" keeps the sandbox but never prompts.
|
||||
# "full" and bypass_permissions are the same switch, whichever arrives
|
||||
# first wins. "off" keeps the sandbox but never prompts. Unset defaults to
|
||||
# "auto"; unknown falls back to the stricter "ask". An explicit
|
||||
# confirm_tool_calls=True with no mode is already resolved to "ask" at the
|
||||
# request layer, so it never arrives here as an ambiguous unset.
|
||||
if permission_mode == "full":
|
||||
bypass_permissions = True
|
||||
elif bypass_permissions:
|
||||
permission_mode = "full"
|
||||
elif permission_mode is None:
|
||||
permission_mode = "auto"
|
||||
elif permission_mode not in ("ask", "auto", "off"):
|
||||
permission_mode = "ask"
|
||||
|
||||
|
|
@ -12231,18 +12235,16 @@ class LlamaCppBackend:
|
|||
decision.as_assistant_tool_call()
|
||||
)
|
||||
|
||||
# Bypass wins over the confirm gate at the loop level too,
|
||||
# so a direct internal caller with both flags never prompts.
|
||||
# In "auto" mode only calls detected as potentially unsafe
|
||||
# pause; read-only calls run straight through. "off" never
|
||||
# prompts (sandbox stays on).
|
||||
# Bypass wins here too, so a direct internal caller with both
|
||||
# flags never prompts. "auto" pauses only high-risk calls;
|
||||
# "off" never prompts (sandbox stays on).
|
||||
needs_confirm = (
|
||||
bool(confirm_tool_calls)
|
||||
and not bypass_permissions
|
||||
and permission_mode != "off"
|
||||
)
|
||||
if needs_confirm and permission_mode == "auto":
|
||||
needs_confirm = is_potentially_unsafe_tool_call(
|
||||
needs_confirm = is_high_risk_tool_call(
|
||||
decision.tool_name, decision.arguments
|
||||
)
|
||||
approval_id = new_approval_id() if needs_confirm else ""
|
||||
|
|
|
|||
|
|
@ -147,10 +147,16 @@ def _build_index() -> dict[str, _LocalGgufEntry]:
|
|||
)
|
||||
from utils.paths import legacy_hf_cache_dir, hf_default_cache_dir, lmstudio_model_dirs
|
||||
from utils.hf_cache_settings import known_hf_hub_caches
|
||||
from core.inference.model_ids import public_model_id
|
||||
|
||||
index: dict[str, _LocalGgufEntry] = {}
|
||||
seen_hf: set[str] = set()
|
||||
|
||||
try:
|
||||
active_root = str(Path(_resolve_hf_cache_dir()).resolve())
|
||||
except Exception:
|
||||
active_root = None
|
||||
|
||||
def _scan_hf_once(directory) -> list:
|
||||
if directory is None:
|
||||
return []
|
||||
|
|
@ -162,7 +168,13 @@ def _build_index() -> dict[str, _LocalGgufEntry]:
|
|||
if rp in seen_hf:
|
||||
return []
|
||||
seen_hf.add(rp)
|
||||
return _scan_hf_cache(directory)
|
||||
# Only the active cache loads by repo id. Say so, or an inactive repo is
|
||||
# indexed under an id it cannot load by, and its snapshot basename (what
|
||||
# /v1/models advertises once loaded by path) is never a key at all.
|
||||
# No format classification here: nothing on this path reads model_format,
|
||||
# and its recursive walk would duplicate the one _local_gguf_entry already
|
||||
# does per snapshot, on the request path.
|
||||
return _scan_hf_cache(directory, active_cache = rp == active_root, classify_format = False)
|
||||
except Exception as exc: # a missing/malformed root must skip, never crash the index
|
||||
logger.debug("auto-switch: skipping HF cache dir %r: %s", directory, exc)
|
||||
return []
|
||||
|
|
@ -220,12 +232,61 @@ def _build_index() -> dict[str, _LocalGgufEntry]:
|
|||
continue
|
||||
# Index every alias (including the path) so a client can resolve by any of
|
||||
# them, even though only the non-path loader_id is advertised.
|
||||
for key in (raw_id, getattr(info, "model_id", None), getattr(info, "display_name", None)):
|
||||
for key in (
|
||||
raw_id,
|
||||
getattr(info, "model_id", None),
|
||||
getattr(info, "display_name", None),
|
||||
public_model_id(raw_id),
|
||||
):
|
||||
if key:
|
||||
index.setdefault(key.strip().lower(), entry)
|
||||
# Other revisions of the same repo resolve to their own weights, so a pin on
|
||||
# one keeps working after Hugging Face writes a newer snapshot.
|
||||
for name, sibling_entry in _sibling_revision_entries(raw_id, loader_id):
|
||||
index.setdefault(name.strip().lower(), sibling_entry)
|
||||
return index
|
||||
|
||||
|
||||
def _sibling_revision_entries(raw_id: str, loader_id: str):
|
||||
"""Yield ``(revision_name, entry)`` for the repo's OTHER cached revisions.
|
||||
|
||||
An inactive-cache repo carries its snapshot path as the id, and /v1/models
|
||||
advertises only that directory's basename once loaded, so anything durable
|
||||
pinned to it (a subagent config) holds one revision hash. Hugging Face writes a
|
||||
new snapshot dir on every update, and the scan emits a single entry per repo
|
||||
pointed at the newest one, so that pin would otherwise stop resolving and drop
|
||||
through to whatever model is loaded.
|
||||
|
||||
Each revision gets an entry for its OWN directory rather than an alias onto the
|
||||
scanned one: aliasing would redirect a pin that names an older complete revision
|
||||
onto a newer half-downloaded snapshot and break a request that works today.
|
||||
Incomplete revisions are skipped for the same reason.
|
||||
|
||||
Sibling names are only revisions inside a real cache repo
|
||||
(``<root>/models--org--name/snapshots/<rev>``). A scan folder that merely happens
|
||||
to be called ``snapshots`` holds unrelated models, and treating those as
|
||||
revisions would silently serve one model in place of another.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
snapshots = Path(raw_id).parent
|
||||
if snapshots.name != "snapshots" or not snapshots.parent.name.startswith("models--"):
|
||||
return
|
||||
from routes.models import snapshot_variants_all_complete
|
||||
|
||||
try:
|
||||
siblings = [p for p in snapshots.iterdir() if p.is_dir() and p.name != Path(raw_id).name]
|
||||
except OSError:
|
||||
return
|
||||
for sibling in siblings:
|
||||
if not snapshot_variants_all_complete(str(sibling)):
|
||||
continue
|
||||
entry = _local_gguf_entry(loader_id, SimpleNamespace(path = str(sibling)))
|
||||
if entry is not None:
|
||||
yield sibling.name, entry
|
||||
|
||||
|
||||
def _index() -> dict[str, _LocalGgufEntry]:
|
||||
global _scan
|
||||
# Build under the lock so concurrent callers with an expired cache don't all
|
||||
|
|
|
|||
|
|
@ -514,13 +514,17 @@ def run_safetensors_tool_loop(
|
|||
"""
|
||||
conversation = list(messages)
|
||||
|
||||
# Normalize the mode (mirrors the GGUF loop): "full" and
|
||||
# bypass_permissions are the same switch; unset/unknown behaves as "ask".
|
||||
# "off" keeps the sandbox but never prompts.
|
||||
# Mirrors the GGUF loop: "full" and bypass_permissions are the same switch;
|
||||
# unset defaults to "auto", unknown falls back to the stricter "ask"; "off"
|
||||
# keeps the sandbox but never prompts. An explicit confirm_tool_calls=True with
|
||||
# no mode is already resolved to "ask" at the request layer, so it never
|
||||
# arrives here as an ambiguous unset.
|
||||
if permission_mode == "full":
|
||||
bypass_permissions = True
|
||||
elif bypass_permissions:
|
||||
permission_mode = "full"
|
||||
elif permission_mode is None:
|
||||
permission_mode = "auto"
|
||||
elif permission_mode not in ("ask", "auto", "off"):
|
||||
permission_mode = "ask"
|
||||
|
||||
|
|
@ -1189,18 +1193,15 @@ def run_safetensors_tool_loop(
|
|||
else:
|
||||
assistant_msg.setdefault("tool_calls", []).append(decision.as_assistant_tool_call())
|
||||
|
||||
# Bypass wins over the confirm gate at the loop level too, so a
|
||||
# direct internal caller passing both flags never prompts. In
|
||||
# "auto" mode only calls detected as potentially unsafe pause.
|
||||
# "off" never prompts (sandbox stays on).
|
||||
# Bypass wins here too, so a direct internal caller with both flags
|
||||
# never prompts. "auto" pauses only high-risk calls; "off" never
|
||||
# prompts (sandbox stays on).
|
||||
needs_confirm = (
|
||||
bool(confirm_tool_calls) and not bypass_permissions and permission_mode != "off"
|
||||
)
|
||||
if needs_confirm and permission_mode == "auto":
|
||||
from core.inference.tools import is_potentially_unsafe_tool_call
|
||||
needs_confirm = is_potentially_unsafe_tool_call(
|
||||
decision.tool_name, decision.arguments
|
||||
)
|
||||
from core.inference.tools import is_high_risk_tool_call
|
||||
needs_confirm = is_high_risk_tool_call(decision.tool_name, decision.arguments)
|
||||
approval_id = new_approval_id() if needs_confirm else ""
|
||||
decision_slot = begin_tool_decision(session_id, approval_id) if needs_confirm else None
|
||||
start_event = decision.tool_start_event()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -919,11 +919,11 @@ class ThinkingConfig(BaseModel):
|
|||
|
||||
|
||||
# Recognized permission_mode values. The field accepts a plain string rather than
|
||||
# a Literal so an unrecognized value from a newer UI/client degrades to the
|
||||
# safest gate ("ask") instead of a 422; the tool loops apply the same unknown ->
|
||||
# ask fallback, so normalizing here keeps that forward-compat path reachable at
|
||||
# the API boundary. None stays unset ("behaves as 'ask'" without self-enabling
|
||||
# the confirm gate).
|
||||
# a Literal so an unrecognized value from a newer UI/client degrades to the safest
|
||||
# gate ("ask") instead of a 422. None stays unset at the request boundary: the tool
|
||||
# loops normalize it to the product default "auto", while the route's confirm-gate
|
||||
# derivation keeps an unset mode lenient (a non-streaming request cannot prompt, so
|
||||
# it runs) to keep non-streaming clients and health checks working.
|
||||
_KNOWN_PERMISSION_MODES = ("ask", "auto", "off", "full")
|
||||
|
||||
|
||||
|
|
@ -1086,11 +1086,13 @@ class ChatCompletionRequest(BaseModel):
|
|||
"[x-unsloth] Permission level for local tool calls. 'ask' pauses every "
|
||||
"call for approval; 'ask'/'auto' enable the confirmation gate on their "
|
||||
"own (needs a streaming request to deliver prompts). 'auto' ('Approve for "
|
||||
"me') only pauses calls detected as potentially unsafe (state-mutating "
|
||||
"terminal/python/MCP calls); read-only calls run immediately, and the "
|
||||
"sandbox stays on. 'full' is equivalent to bypass_permissions=true (no "
|
||||
"confirmation, no sandbox). Unset behaves as 'ask'. An unrecognized value "
|
||||
"(e.g. from a newer client) is treated as 'ask'."
|
||||
"me') only pauses calls detected as high risk (credential reads, privilege "
|
||||
"escalation, destructive/persistence, network exfil); ordinary calls run "
|
||||
"immediately, and the sandbox stays on. 'full' is equivalent to "
|
||||
"bypass_permissions=true (no confirmation, no sandbox). Unset defaults to "
|
||||
"'auto' for the per-call gate; a non-streaming request without an explicit "
|
||||
"mode cannot prompt and runs the loop. An unrecognized value (e.g. from a "
|
||||
"newer client) is treated as 'ask'."
|
||||
),
|
||||
)
|
||||
auto_heal_tool_calls: Optional[bool] = Field(
|
||||
|
|
@ -1376,6 +1378,21 @@ class ChatCompletionRequest(BaseModel):
|
|||
elif self.permission_mode == "off":
|
||||
# "Off" never prompts, so route guards must see confirm disabled.
|
||||
self.confirm_tool_calls = False
|
||||
elif (
|
||||
self.permission_mode is None
|
||||
and self.confirm_tool_calls is True
|
||||
and not (self.provider_id or self.provider_type)
|
||||
):
|
||||
# An explicit confirm_tool_calls=True with no mode opted into the
|
||||
# pre-permission-mode contract of gating every call, so resolve it to
|
||||
# "ask" rather than let the loop apply the "auto" default, which would
|
||||
# silently weaken that opt-in to high-risk calls only. Unlike the "ask"
|
||||
# branch below this only sets permission_mode, which is inert unless
|
||||
# Unsloth's own tool loop runs, so it needs no enable_tools/mcp gate --
|
||||
# deliberate, since a process-wide --enable-tools policy can force the
|
||||
# loop when the request sets neither flag. A bare unset request
|
||||
# (confirm_tool_calls is None) still defaults to auto.
|
||||
self.permission_mode = "ask"
|
||||
elif (
|
||||
self.permission_mode == "ask"
|
||||
and self.confirm_tool_calls is None
|
||||
|
|
@ -2059,7 +2076,7 @@ class AnthropicMessagesRequest(BaseModel):
|
|||
)
|
||||
permission_mode: Optional[str] = Field(
|
||||
None,
|
||||
description = "[x-unsloth] Permission level for local tool calls: 'ask' pauses every call, 'auto' only pauses calls detected as potentially unsafe, 'off' never pauses (sandbox stays on), 'full' equals bypass_permissions=true. Unset behaves as 'ask'; an unrecognized value (e.g. from a newer client) is treated as 'ask'. Declared explicitly so omitted requests default to None instead of raising AttributeError.",
|
||||
description = "[x-unsloth] Permission level for local tool calls: 'ask' pauses every call, 'auto' ('Approve for me') only pauses calls detected as high risk, 'off' never pauses (sandbox stays on), 'full' equals bypass_permissions=true. Unset defaults to 'auto' for the per-call gate; a non-streaming request without an explicit mode runs the loop. An unrecognized value (e.g. from a newer client) is treated as 'ask'. Declared explicitly so omitted requests default to None instead of raising AttributeError.",
|
||||
)
|
||||
auto_heal_tool_calls: Optional[bool] = Field(
|
||||
True,
|
||||
|
|
|
|||
|
|
@ -143,6 +143,12 @@ class GgufVariantDetail(BaseModel):
|
|||
update_available: bool = Field(
|
||||
False, description = "Whether a newer version of this variant is available on HF"
|
||||
)
|
||||
partial: bool = Field(
|
||||
False,
|
||||
description = "Whether this variant is an interrupted download. The hub service "
|
||||
"already computes it; carry it through so callers can hide a quant whose shards "
|
||||
"are incomplete instead of offering one that cannot load.",
|
||||
)
|
||||
|
||||
|
||||
class GgufVariantsResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -2153,14 +2153,13 @@ def _explicit_studio_tool_loop_requested(payload) -> bool:
|
|||
def _permission_mode_confirm(payload) -> bool:
|
||||
"""Effective confirm-gate intent for Unsloth's own local tool loop.
|
||||
|
||||
Honors the documented default that an unset permission_mode behaves as
|
||||
"ask". An explicit confirm_tool_calls (True or False) wins; explicit
|
||||
ask/auto always engage the gate (a non-streaming one is then rejected, since
|
||||
it cannot prompt); off/full never prompt. An unset mode defaults to ask, but
|
||||
that is only realizable on a streaming request, so a non-streaming unset
|
||||
request keeps the legacy run-without-gate behavior instead of 400ing. Used
|
||||
at the pre-switch guard and the per-backend tool paths so a forced tool loop
|
||||
(CLI --enable-tools) with the default mode still gates streaming requests.
|
||||
An explicit confirm_tool_calls (True or False) wins; explicit ask/auto always
|
||||
engage the gate (a non-streaming one is then rejected, since it cannot prompt);
|
||||
off/full never prompt. An unset mode stays lenient here even though the loop
|
||||
defaults it to "auto": a non-streaming request keeps the legacy
|
||||
run-without-gate behavior instead of 400ing, so non-streaming clients and
|
||||
health checks keep working. Used at the pre-switch guard and the per-backend
|
||||
tool paths so a forced tool loop (CLI --enable-tools) still gates streaming.
|
||||
"""
|
||||
if payload.confirm_tool_calls is not None:
|
||||
return bool(payload.confirm_tool_calls)
|
||||
|
|
|
|||
|
|
@ -336,7 +336,11 @@ def _scan_models_dir(models_dir: Path, *, limit: int | None = None) -> List[Loca
|
|||
try:
|
||||
if not child.is_dir():
|
||||
continue
|
||||
has_gguf = any(child.glob("*.gguf"))
|
||||
gguf_names = [p.name for p in child.glob("*.gguf")]
|
||||
has_gguf = bool(gguf_names)
|
||||
# mmproj alone is a vision adapter, not servable weights, so it decides
|
||||
# presence but never format (same rule as _dir_model_format).
|
||||
has_main_gguf = any(_is_main_gguf_filename(n) for n in gguf_names)
|
||||
has_non_gguf_weights = _has_non_gguf_weights(child)
|
||||
has_config = (child / "config.json").exists() or (
|
||||
child / "adapter_config.json"
|
||||
|
|
@ -358,7 +362,7 @@ def _scan_models_dir(models_dir: Path, *, limit: int | None = None) -> List[Loca
|
|||
# A folder whose only weights are .gguf is GGUF-format even when it also
|
||||
# ships a config.json (common for HF GGUF repos); such folders often lack
|
||||
# a -GGUF suffix, so surface the format for the UI's GGUF classification.
|
||||
model_format = "gguf" if has_gguf and not has_non_gguf_weights else None
|
||||
model_format = "gguf" if has_main_gguf and not has_non_gguf_weights else None
|
||||
found.append(
|
||||
LocalModelInfo(
|
||||
id = str(child),
|
||||
|
|
@ -374,7 +378,8 @@ def _scan_models_dir(models_dir: Path, *, limit: int | None = None) -> List[Loca
|
|||
for gguf_file in models_dir.glob("*.gguf"):
|
||||
if limit is not None and len(found) >= limit:
|
||||
break
|
||||
if gguf_file.is_file():
|
||||
# A standalone mmproj is a vision adapter, not servable weights.
|
||||
if gguf_file.is_file() and _is_main_gguf_filename(gguf_file.name):
|
||||
try:
|
||||
updated_at = gguf_file.stat().st_mtime
|
||||
except OSError:
|
||||
|
|
@ -414,7 +419,12 @@ def _scan_models_dir(models_dir: Path, *, limit: int | None = None) -> List[Loca
|
|||
return found
|
||||
|
||||
|
||||
def _scan_hf_cache(cache_dir: Path, *, active_cache: bool = True) -> List[LocalModelInfo]:
|
||||
def _scan_hf_cache(
|
||||
cache_dir: Path,
|
||||
*,
|
||||
active_cache: bool = True,
|
||||
classify_format: bool = True,
|
||||
) -> List[LocalModelInfo]:
|
||||
if not cache_dir.exists() or not cache_dir.is_dir():
|
||||
return []
|
||||
|
||||
|
|
@ -439,13 +449,23 @@ def _scan_hf_cache(cache_dir: Path, *, active_cache: bool = True) -> List[LocalM
|
|||
partial = partial or hf_cache_scan.is_gguf_repo_partial(model_id, repo_dir)
|
||||
|
||||
load_id = model_id
|
||||
snapshot = _resolve_hf_cache_realpath(repo_dir)
|
||||
if not active_cache:
|
||||
load_id = _resolve_hf_cache_realpath(repo_dir) or str(repo_dir.resolve())
|
||||
load_id = snapshot or str(repo_dir.resolve())
|
||||
# Classify from the snapshot's own weights. A GGUF repo without a -GGUF
|
||||
# suffix is common, and leaving this unset makes every consumer guess from
|
||||
# the name; the snapshot is already resolved just above.
|
||||
model_format = (
|
||||
_dir_model_format(Path(snapshot), recursive = True)
|
||||
if snapshot and classify_format
|
||||
else None
|
||||
)
|
||||
found.append(
|
||||
LocalModelInfo(
|
||||
id = load_id,
|
||||
model_id = model_id,
|
||||
display_name = model_id.split("/")[-1],
|
||||
model_format = model_format,
|
||||
path = load_id if not active_cache else str(repo_dir),
|
||||
source = "hf_cache",
|
||||
active_cache = active_cache,
|
||||
|
|
@ -456,16 +476,30 @@ def _scan_hf_cache(cache_dir: Path, *, active_cache: bool = True) -> List[LocalM
|
|||
return found
|
||||
|
||||
|
||||
def _dir_model_format(path: Path) -> Optional[str]:
|
||||
def _dir_model_format(path: Path, recursive: bool = False) -> Optional[str]:
|
||||
"""Return ``"gguf"`` for a directory whose only weights are ``.gguf`` files.
|
||||
|
||||
LM Studio and custom GGUF folders frequently lack a ``-GGUF`` name suffix,
|
||||
so the UI relies on this hint to route them through the GGUF load path
|
||||
rather than treating them as plain local checkpoints.
|
||||
rather than treating them as plain local checkpoints. A directory whose only
|
||||
``.gguf`` is an mmproj vision adapter is not one: the variant selector drops
|
||||
mmproj, so that path would find nothing to serve.
|
||||
|
||||
``recursive`` is for HF cache snapshots, which keep split quants in per-quant
|
||||
subdirectories: a flat glob sees no ``.gguf`` there and would report the
|
||||
snapshot as non-GGUF, hiding every sharded repo from the GGUF pickers. It looks
|
||||
one level down rather than walking the tree, because that is where split quants
|
||||
live and ``/api/models/local`` is async: an unbounded ``rglob`` per repo would
|
||||
have to exhaust every non-GGUF snapshot before concluding there is no GGUF,
|
||||
blocking the event loop on a large cache.
|
||||
"""
|
||||
try:
|
||||
if not any(path.glob("*.gguf")):
|
||||
return None
|
||||
found = path.glob("*.gguf")
|
||||
if not any(_is_main_gguf_filename(p.name) for p in found):
|
||||
if not recursive:
|
||||
return None
|
||||
if not any(_is_main_gguf_filename(p.name) for p in path.glob("*/*.gguf")):
|
||||
return None
|
||||
return None if _has_non_gguf_weights(path) else "gguf"
|
||||
except OSError:
|
||||
return None
|
||||
|
|
@ -502,7 +536,7 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]:
|
|||
for child in lm_dir.iterdir():
|
||||
try:
|
||||
if not child.is_dir():
|
||||
if child.suffix == ".gguf" and child.is_file():
|
||||
if _is_main_gguf_filename(child.name) and child.is_file():
|
||||
try:
|
||||
updated_at = child.stat().st_mtime
|
||||
except OSError:
|
||||
|
|
@ -565,7 +599,7 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]:
|
|||
updated_at = updated_at,
|
||||
),
|
||||
)
|
||||
elif model_dir.suffix == ".gguf" and model_dir.is_file():
|
||||
elif _is_main_gguf_filename(model_dir.name) and model_dir.is_file():
|
||||
try:
|
||||
updated_at = model_dir.stat().st_mtime
|
||||
except OSError:
|
||||
|
|
@ -2974,6 +3008,7 @@ async def get_gguf_variants(
|
|||
),
|
||||
downloaded = bool(v.downloaded),
|
||||
update_available = bool(getattr(v, "update_available", False)),
|
||||
partial = bool(getattr(v, "partial", False)),
|
||||
)
|
||||
for v in response.variants
|
||||
],
|
||||
|
|
@ -3399,11 +3434,80 @@ def _local_is_diffusers(model: "LocalModelInfo") -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def snapshot_variants_all_complete(snapshot: str) -> bool:
|
||||
"""True when every quant the variant lister would advertise from *snapshot* is
|
||||
fully on disk.
|
||||
|
||||
One complete quant is not enough: the picker enumerates the whole directory, so a
|
||||
half-downloaded split quant sitting beside a good one still gets offered and the
|
||||
generated command asks llama-server for shards that are absent. Both sides derive
|
||||
their labels from ``extract_quant_label`` over paths relative to the snapshot, so
|
||||
the sets are directly comparable.
|
||||
"""
|
||||
from hub.utils import inventory_scan
|
||||
from hub.utils.gguf import list_local_gguf_variants
|
||||
|
||||
try:
|
||||
variants, _ = list_local_gguf_variants(snapshot)
|
||||
offered = {v.quant for v in variants if getattr(v, "quant", None)}
|
||||
if not offered:
|
||||
return False
|
||||
return offered <= inventory_scan._completed_gguf_variants(Path(snapshot))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _repo_gguf_load_id(repo_info, active_root: Optional[Path]) -> Optional[str]:
|
||||
"""Snapshot dir holding the newest primary GGUF, for a repo outside the active
|
||||
hub cache that does not resolve by id. ``None`` when the id works or no
|
||||
snapshot is recorded, since the repo dir itself is not loadable.
|
||||
"""
|
||||
repo_path = getattr(repo_info, "repo_path", None)
|
||||
if repo_path is None or active_root is None:
|
||||
return None
|
||||
try:
|
||||
if repo_path.parent.resolve(strict = False) == active_root:
|
||||
return None
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
pass
|
||||
# Order by snapshot directory mtime, matching hub.utils.gguf.iter_hf_cache_snapshots,
|
||||
# which is what variant discovery reads. Blob mtimes would disagree with it whenever
|
||||
# Hugging Face reuses an older blob in a newer snapshot, and the command would then
|
||||
# name a snapshot that does not hold the quant the picker offered.
|
||||
candidates: List[tuple[float, str]] = []
|
||||
for revision in repo_info.revisions:
|
||||
snapshot = getattr(revision, "snapshot_path", None)
|
||||
if snapshot is None:
|
||||
continue
|
||||
if not any(_is_main_gguf_filename(f.file_name) for f in revision.files):
|
||||
continue
|
||||
try:
|
||||
mtime = Path(snapshot).stat().st_mtime
|
||||
except OSError:
|
||||
mtime = 0.0
|
||||
candidates.append((mtime, str(snapshot)))
|
||||
candidates.sort(key = lambda c: c[0], reverse = True)
|
||||
# Newest first, but skip one holding only part of a split quant: an interrupted
|
||||
# download would otherwise beat an older snapshot that can still load. Scanning
|
||||
# stops at the first usable snapshot, so the usual case walks one directory.
|
||||
for _, snapshot in candidates:
|
||||
if snapshot_variants_all_complete(snapshot):
|
||||
return snapshot
|
||||
# Nothing complete anywhere: publishing a half-downloaded snapshot would put that
|
||||
# path in the copied command and fail on load. Drop the id so the repo id is used,
|
||||
# which fetches the missing shards instead.
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/cached-gguf")
|
||||
async def list_cached_gguf(current_subject: str = Depends(get_current_subject)):
|
||||
"""List GGUF repos downloaded to HF cache, legacy Unsloth cache, and HF default cache."""
|
||||
try:
|
||||
cache_scans = _all_hf_cache_scans()
|
||||
try:
|
||||
active_root = _resolve_hf_cache_dir().resolve(strict = False)
|
||||
except Exception:
|
||||
active_root = None
|
||||
|
||||
seen_lower: dict[str, dict] = {}
|
||||
for hf_cache in cache_scans:
|
||||
|
|
@ -3430,6 +3534,9 @@ async def list_cached_gguf(current_subject: str = Depends(get_current_subject)):
|
|||
"has_vision": _repo_has_mmproj(repo_info),
|
||||
"task": _repo_gguf_task(repo_info),
|
||||
}
|
||||
load_id = _repo_gguf_load_id(repo_info, active_root)
|
||||
if load_id:
|
||||
row["load_id"] = load_id
|
||||
# Keep the newest timestamp across duplicate caches;
|
||||
# attach only when known so absent rows sort as oldest.
|
||||
lm = max(last_modified, (existing or {}).get("last_modified", 0.0))
|
||||
|
|
|
|||
|
|
@ -663,9 +663,9 @@ def test_bypass_env_does_not_add_unset_windows_profile_vars(monkeypatch, tmp_pat
|
|||
|
||||
@_POSIX_ONLY
|
||||
def test_bypass_exec_hardens_parent_proc_env(monkeypatch, captured_popen):
|
||||
# Stripping the child env is not enough: a same-UID child can read the
|
||||
# parent's /proc environ. The exec paths must invoke the parent hardening
|
||||
# when (and only when) the sandbox is disabled.
|
||||
# Stripping the child env is not enough: a same-UID child can read the parent's
|
||||
# /proc environ. Both exec paths harden the parent in bypass mode (fail closed)
|
||||
# and in sandboxed mode too (best-effort backstop for a classifier miss).
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_harden():
|
||||
|
|
@ -680,7 +680,7 @@ def test_bypass_exec_hardens_parent_proc_env(monkeypatch, captured_popen):
|
|||
calls["n"] = 0
|
||||
_python_exec("print(1)", None, 5, "t", disable_sandbox = False)
|
||||
_bash_exec("echo hi", None, 5, "t", disable_sandbox = False)
|
||||
assert calls["n"] == 0 # never hardened on the sandboxed path
|
||||
assert calls["n"] == 2 # sandboxed path now hardens too (best-effort)
|
||||
|
||||
|
||||
def test_bypass_exec_fails_closed_when_hardening_fails(monkeypatch, captured_popen):
|
||||
|
|
|
|||
|
|
@ -126,6 +126,185 @@ def test_collect_local_models_prefers_complete_previous_copy(monkeypatch, tmp_pa
|
|||
assert row.active_cache is False
|
||||
|
||||
|
||||
def test_list_cached_gguf_reports_snapshot_load_id_for_inactive_cache(monkeypatch, tmp_path):
|
||||
"""Only a repo outside the active cache needs a snapshot load_id."""
|
||||
active = tmp_path / "active"
|
||||
snapshot = tmp_path / "legacy" / "models--Org--Away" / "snapshots" / "rev"
|
||||
snapshot.mkdir(parents = True)
|
||||
(snapshot / "Q4_K_M.gguf").write_bytes(b"\0")
|
||||
away = _repo(
|
||||
"Org/Away",
|
||||
[],
|
||||
tmp_path / "legacy" / "models--Org--Away",
|
||||
revisions = [
|
||||
SimpleNamespace(files = [_file("Q4_K_M.gguf", 5_000)], snapshot_path = snapshot),
|
||||
],
|
||||
)
|
||||
here = _repo("Org/Here", [_file("Q4_K_M.gguf", 6_000)], active / "models--Org--Here")
|
||||
|
||||
monkeypatch.setattr(
|
||||
models_route, "_all_hf_cache_scans", lambda: [SimpleNamespace(repos = [away, here])]
|
||||
)
|
||||
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
|
||||
|
||||
rows = {
|
||||
c["repo_id"]: c
|
||||
for c in asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))["cached"]
|
||||
}
|
||||
|
||||
assert rows["Org/Away"]["load_id"] == str(snapshot)
|
||||
assert "load_id" not in rows["Org/Here"]
|
||||
|
||||
|
||||
def test_list_cached_gguf_load_id_follows_snapshot_dir_mtime(monkeypatch, tmp_path):
|
||||
"""Pick the snapshot variant discovery reads: newest directory, not newest blob."""
|
||||
import os
|
||||
|
||||
active = tmp_path / "active"
|
||||
repo_dir = tmp_path / "legacy" / "models--Org--Multi"
|
||||
older, newer = repo_dir / "snapshots" / "rev-a", repo_dir / "snapshots" / "rev-b"
|
||||
for path in (older, newer):
|
||||
path.mkdir(parents = True)
|
||||
(older / "Q4_K_M.gguf").write_bytes(b"\0")
|
||||
(newer / "Q8_0.gguf").write_bytes(b"\0")
|
||||
os.utime(older, (1_000, 1_000))
|
||||
os.utime(newer, (2_000, 2_000))
|
||||
|
||||
repo = _repo(
|
||||
"Org/Multi",
|
||||
[],
|
||||
repo_dir,
|
||||
revisions = [
|
||||
# The older directory holds the newer blob, which is what diverges.
|
||||
SimpleNamespace(
|
||||
files = [_file("Q4_K_M.gguf", 5_000, blob_path = "b1")], snapshot_path = older
|
||||
),
|
||||
SimpleNamespace(files = [_file("Q8_0.gguf", 6_000, blob_path = "b2")], snapshot_path = newer),
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
models_route, "_all_hf_cache_scans", lambda: [SimpleNamespace(repos = [repo])]
|
||||
)
|
||||
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
|
||||
monkeypatch.setattr(
|
||||
models_route, "_blob_mtime", lambda f: 9_000 if f.blob_path == "b1" else 1.0
|
||||
)
|
||||
|
||||
rows = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))["cached"]
|
||||
|
||||
assert rows[0]["load_id"] == str(newer)
|
||||
|
||||
|
||||
def test_list_cached_gguf_load_id_skips_partial_split_snapshot(monkeypatch, tmp_path):
|
||||
"""A half-downloaded split quant must not beat an older snapshot that can load."""
|
||||
import os
|
||||
|
||||
active = tmp_path / "active"
|
||||
repo_dir = tmp_path / "legacy" / "models--Org--Split"
|
||||
older, newer = repo_dir / "snapshots" / "rev-a", repo_dir / "snapshots" / "rev-b"
|
||||
for path in (older, newer):
|
||||
path.mkdir(parents = True)
|
||||
(older / "Model-Q8_0.gguf").write_bytes(b"\0")
|
||||
# Only part 1 of 3 landed before the download was interrupted.
|
||||
(newer / "Model-Q4_K_M-00001-of-00003.gguf").write_bytes(b"\0")
|
||||
os.utime(older, (1_000, 1_000))
|
||||
os.utime(newer, (2_000, 2_000))
|
||||
|
||||
repo = _repo(
|
||||
"Org/Split",
|
||||
[],
|
||||
repo_dir,
|
||||
revisions = [
|
||||
SimpleNamespace(files = [_file("Model-Q8_0.gguf", 5_000)], snapshot_path = older),
|
||||
SimpleNamespace(
|
||||
files = [_file("Model-Q4_K_M-00001-of-00003.gguf", 6_000)], snapshot_path = newer
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
models_route, "_all_hf_cache_scans", lambda: [SimpleNamespace(repos = [repo])]
|
||||
)
|
||||
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
|
||||
|
||||
rows = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))["cached"]
|
||||
|
||||
assert rows[0]["load_id"] == str(older)
|
||||
|
||||
|
||||
def test_list_cached_gguf_omits_load_id_when_no_snapshot_is_complete(monkeypatch, tmp_path):
|
||||
"""With only a half-downloaded split quant, fall back to the repo id, not a path."""
|
||||
active = tmp_path / "active"
|
||||
repo_dir = tmp_path / "legacy" / "models--Org--Torn"
|
||||
snapshot = repo_dir / "snapshots" / "rev"
|
||||
snapshot.mkdir(parents = True)
|
||||
(snapshot / "Model-Q4_K_M-00001-of-00003.gguf").write_bytes(b"\0")
|
||||
|
||||
repo = _repo(
|
||||
"Org/Torn",
|
||||
[],
|
||||
repo_dir,
|
||||
revisions = [
|
||||
SimpleNamespace(
|
||||
files = [_file("Model-Q4_K_M-00001-of-00003.gguf", 6_000)], snapshot_path = snapshot
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
models_route, "_all_hf_cache_scans", lambda: [SimpleNamespace(repos = [repo])]
|
||||
)
|
||||
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
|
||||
|
||||
rows = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))["cached"]
|
||||
|
||||
assert "load_id" not in rows[0]
|
||||
|
||||
|
||||
def test_list_cached_gguf_skips_snapshot_with_one_incomplete_variant(monkeypatch, tmp_path):
|
||||
"""A good quant beside a half-downloaded one is still not a safe load target."""
|
||||
import os
|
||||
|
||||
active = tmp_path / "active"
|
||||
repo_dir = tmp_path / "legacy" / "models--Org--Mixed"
|
||||
older, newer = repo_dir / "snapshots" / "rev-a", repo_dir / "snapshots" / "rev-b"
|
||||
for path in (older, newer):
|
||||
path.mkdir(parents = True)
|
||||
(older / "Model-Q8_0.gguf").write_bytes(b"\0")
|
||||
# rev-b has a complete Q8_0 AND a half-downloaded split Q4_K_M. The picker
|
||||
# enumerates the whole directory, so it would offer the broken one.
|
||||
(newer / "Model-Q8_0.gguf").write_bytes(b"\0")
|
||||
(newer / "Model-Q4_K_M-00001-of-00003.gguf").write_bytes(b"\0")
|
||||
os.utime(older, (1_000, 1_000))
|
||||
os.utime(newer, (2_000, 2_000))
|
||||
|
||||
repo = _repo(
|
||||
"Org/Mixed",
|
||||
[],
|
||||
repo_dir,
|
||||
revisions = [
|
||||
SimpleNamespace(files = [_file("Model-Q8_0.gguf", 5_000)], snapshot_path = older),
|
||||
SimpleNamespace(
|
||||
files = [
|
||||
_file("Model-Q8_0.gguf", 5_000),
|
||||
_file("Model-Q4_K_M-00001-of-00003.gguf", 6_000),
|
||||
],
|
||||
snapshot_path = newer,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
models_route, "_all_hf_cache_scans", lambda: [SimpleNamespace(repos = [repo])]
|
||||
)
|
||||
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
|
||||
|
||||
rows = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))["cached"]
|
||||
|
||||
assert rows[0]["load_id"] == str(older)
|
||||
|
||||
|
||||
def test_list_cached_gguf_includes_non_suffix_repo_when_cache_contains_gguf(monkeypatch, tmp_path):
|
||||
repo = _repo(
|
||||
"HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
|
||||
|
|
|
|||
|
|
@ -1866,6 +1866,8 @@ def test_confirm_tool_calls_allow_executes_gguf_tool(monkeypatch):
|
|||
tools = [{"type": "function", "function": {"name": "python"}}],
|
||||
max_tool_iterations = 1,
|
||||
confirm_tool_calls = True,
|
||||
# Unset defaults to "auto", which would not prompt this safe print(1).
|
||||
permission_mode = "ask",
|
||||
session_id = "sess",
|
||||
)
|
||||
)
|
||||
|
|
@ -1898,6 +1900,8 @@ def test_confirm_tool_calls_close_after_prompt_cleans_gguf_slot(monkeypatch):
|
|||
tools = [{"type": "function", "function": {"name": "python"}}],
|
||||
max_tool_iterations = 1,
|
||||
confirm_tool_calls = True,
|
||||
# Unset defaults to "auto", which would not prompt this safe print(1).
|
||||
permission_mode = "ask",
|
||||
session_id = "sess",
|
||||
)
|
||||
try:
|
||||
|
|
@ -1931,6 +1935,9 @@ def test_confirm_tool_calls_skips_gguf_rag_autoinject(monkeypatch):
|
|||
tools = [{"type": "function", "function": {"name": "search_knowledge_base"}}],
|
||||
max_tool_iterations = 1,
|
||||
confirm_tool_calls = True,
|
||||
# "ask" gates every call so autoinject waits; unset defaults to
|
||||
# "auto", where this safe retrieval never gates.
|
||||
permission_mode = "ask",
|
||||
session_id = "sess",
|
||||
rag_scope = {"thread_id": "t1"},
|
||||
)
|
||||
|
|
@ -1975,6 +1982,8 @@ def test_confirm_tool_calls_deny_skips_gguf_tool_and_retry_can_execute(monkeypat
|
|||
tools = [{"type": "function", "function": {"name": "python"}}],
|
||||
max_tool_iterations = 2,
|
||||
confirm_tool_calls = True,
|
||||
# Unset defaults to "auto", which would not prompt this safe print(1).
|
||||
permission_mode = "ask",
|
||||
session_id = "sess",
|
||||
)
|
||||
)
|
||||
|
|
@ -2668,7 +2677,7 @@ def test_ordinary_json_with_name_key_is_shown_not_treated_as_tool_call(monkeypat
|
|||
calls: list[tuple[str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
"core.inference.tools.execute_tool",
|
||||
lambda n, a, **_k: (calls.append((n, a)) or "x"),
|
||||
lambda n, a, **_k: calls.append((n, a)) or "x",
|
||||
)
|
||||
|
||||
events = list(
|
||||
|
|
@ -2725,7 +2734,7 @@ def test_gguf_truncated_ordinary_json_with_name_key_is_shown_not_suppressed(monk
|
|||
calls: list[tuple[str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
"core.inference.tools.execute_tool",
|
||||
lambda n, a, **_k: (calls.append((n, a)) or "x"),
|
||||
lambda n, a, **_k: calls.append((n, a)) or "x",
|
||||
)
|
||||
|
||||
events = list(
|
||||
|
|
@ -2752,7 +2761,7 @@ def test_gguf_truncated_disabled_name_json_is_preserved_when_tools_active(monkey
|
|||
calls: list[tuple[str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
"core.inference.tools.execute_tool",
|
||||
lambda n, a, **_k: (calls.append((n, a)) or "x"),
|
||||
lambda n, a, **_k: calls.append((n, a)) or "x",
|
||||
)
|
||||
|
||||
events = list(
|
||||
|
|
@ -2809,7 +2818,7 @@ def test_gguf_oversized_disabled_name_json_is_preserved(monkeypatch):
|
|||
calls: list[tuple[str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
"core.inference.tools.execute_tool",
|
||||
lambda n, a, **_k: (calls.append((n, a)) or "x"),
|
||||
lambda n, a, **_k: calls.append((n, a)) or "x",
|
||||
)
|
||||
|
||||
events = list(
|
||||
|
|
@ -2992,7 +3001,7 @@ def test_gguf_initial_buffer_flush_holds_split_rehearsal_name(monkeypatch):
|
|||
calls: list[tuple[str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
"core.inference.tools.execute_tool",
|
||||
lambda name, arguments, **_k: (calls.append((name, arguments)) or "result"),
|
||||
lambda name, arguments, **_k: calls.append((name, arguments)) or "result",
|
||||
)
|
||||
|
||||
events = list(
|
||||
|
|
@ -3029,7 +3038,7 @@ def test_gguf_rehearsal_name_after_prose_in_streaming_is_not_leaked(monkeypatch)
|
|||
calls: list[tuple[str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
"core.inference.tools.execute_tool",
|
||||
lambda name, arguments, **_k: (calls.append((name, arguments)) or "result"),
|
||||
lambda name, arguments, **_k: calls.append((name, arguments)) or "result",
|
||||
)
|
||||
|
||||
events = list(
|
||||
|
|
@ -3062,7 +3071,7 @@ def test_gguf_plain_answer_ending_with_tool_name_word_is_preserved(monkeypatch):
|
|||
calls: list[tuple[str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
"core.inference.tools.execute_tool",
|
||||
lambda name, arguments, **_k: (calls.append((name, arguments)) or "result"),
|
||||
lambda name, arguments, **_k: calls.append((name, arguments)) or "result",
|
||||
)
|
||||
|
||||
events = list(
|
||||
|
|
@ -3097,7 +3106,7 @@ def test_gguf_long_tool_name_split_rehearsal_is_not_capped_and_executes(monkeypa
|
|||
calls: list[tuple[str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
"core.inference.tools.execute_tool",
|
||||
lambda n, a, **_k: (calls.append((n, a)) or "result"),
|
||||
lambda n, a, **_k: calls.append((n, a)) or "result",
|
||||
)
|
||||
|
||||
events = list(
|
||||
|
|
@ -3131,7 +3140,7 @@ def test_gguf_streaming_keeps_bare_args_before_think_block(monkeypatch):
|
|||
calls: list[tuple[str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
"core.inference.tools.execute_tool",
|
||||
lambda name, arguments, **_k: (calls.append((name, arguments)) or "result"),
|
||||
lambda name, arguments, **_k: calls.append((name, arguments)) or "result",
|
||||
)
|
||||
|
||||
events = list(
|
||||
|
|
@ -3163,7 +3172,7 @@ def test_gguf_inactive_name_args_in_prose_is_not_drained(monkeypatch):
|
|||
calls: list[tuple[str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
"core.inference.tools.execute_tool",
|
||||
lambda name, arguments, **_k: (calls.append((name, arguments)) or "result"),
|
||||
lambda name, arguments, **_k: calls.append((name, arguments)) or "result",
|
||||
)
|
||||
|
||||
events = list(
|
||||
|
|
@ -3197,7 +3206,7 @@ def test_gguf_inactive_rehearsal_before_active_call_executes_and_keeps_prose(mon
|
|||
calls: list[tuple[str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
"core.inference.tools.execute_tool",
|
||||
lambda name, arguments, **_k: (calls.append((name, arguments)) or "result"),
|
||||
lambda name, arguments, **_k: calls.append((name, arguments)) or "result",
|
||||
)
|
||||
|
||||
events = list(
|
||||
|
|
@ -3257,7 +3266,7 @@ def test_gguf_oversized_bare_json_not_leaked_and_executes(monkeypatch):
|
|||
calls: list[tuple[str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
"core.inference.tools.execute_tool",
|
||||
lambda name, arguments, **_k: (calls.append((name, arguments)) or "OK"),
|
||||
lambda name, arguments, **_k: calls.append((name, arguments)) or "OK",
|
||||
)
|
||||
|
||||
events = list(
|
||||
|
|
@ -3321,7 +3330,7 @@ def test_gguf_textual_fallback_caps_distinct_tool_calls_per_turn(monkeypatch):
|
|||
calls: list[tuple[str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
"core.inference.tools.execute_tool",
|
||||
lambda name, arguments, **_k: (calls.append((name, arguments)) or "OK"),
|
||||
lambda name, arguments, **_k: calls.append((name, arguments)) or "OK",
|
||||
)
|
||||
|
||||
list(
|
||||
|
|
@ -3348,7 +3357,7 @@ def test_gguf_textual_fallback_collapses_duplicate_tool_calls(monkeypatch):
|
|||
calls: list[tuple[str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
"core.inference.tools.execute_tool",
|
||||
lambda name, arguments, **_k: (calls.append((name, arguments)) or "OK"),
|
||||
lambda name, arguments, **_k: calls.append((name, arguments)) or "OK",
|
||||
)
|
||||
|
||||
list(
|
||||
|
|
@ -3373,7 +3382,7 @@ def test_gguf_drain_truncated_enabled_name_json_preserved_when_auto_heal_disable
|
|||
calls: list[tuple[str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
"core.inference.tools.execute_tool",
|
||||
lambda name, arguments, **_k: (calls.append((name, arguments)) or "result"),
|
||||
lambda name, arguments, **_k: calls.append((name, arguments)) or "result",
|
||||
)
|
||||
events = list(
|
||||
backend.generate_chat_completion_with_tools(
|
||||
|
|
@ -3408,7 +3417,7 @@ def test_gguf_valid_tool_calls_respect_max_tool_iterations(monkeypatch):
|
|||
calls: list[tuple[str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
"core.inference.tools.execute_tool",
|
||||
lambda name, arguments, **_k: (calls.append((name, arguments)) or "result"),
|
||||
lambda name, arguments, **_k: calls.append((name, arguments)) or "result",
|
||||
)
|
||||
|
||||
list(
|
||||
|
|
|
|||
|
|
@ -46,6 +46,68 @@ def test_dir_model_format_gguf_only(tmp_path):
|
|||
assert models_route._dir_model_format(d) == "gguf"
|
||||
|
||||
|
||||
def test_dir_model_format_mmproj_only_is_not_gguf(tmp_path):
|
||||
# A lone vision adapter has nothing servable: the variant selector drops mmproj.
|
||||
d = tmp_path / "model"
|
||||
_touch(d / "mmproj-F16.gguf")
|
||||
assert models_route._dir_model_format(d) is None
|
||||
|
||||
|
||||
def test_dir_model_format_mmproj_beside_weights_is_still_gguf(tmp_path):
|
||||
d = tmp_path / "model"
|
||||
_touch(d / "mmproj-F16.gguf")
|
||||
_touch(d / "model-Q4_K_M.gguf")
|
||||
assert models_route._dir_model_format(d) == "gguf"
|
||||
|
||||
|
||||
def test_dir_model_format_recursive_sees_split_quant_subdirs(tmp_path):
|
||||
# HF cache snapshots keep split quants in per-quant subdirs. A flat glob reports
|
||||
# no GGUF there, which would hide every sharded repo from the GGUF pickers.
|
||||
d = tmp_path / "snapshot"
|
||||
_touch(d / "UD-Q4_K_XL" / "model-00001-of-00002.gguf")
|
||||
assert models_route._dir_model_format(d) is None
|
||||
assert models_route._dir_model_format(d, recursive = True) == "gguf"
|
||||
|
||||
|
||||
def test_dir_model_format_recursive_ignores_mmproj_only_subdirs(tmp_path):
|
||||
d = tmp_path / "snapshot"
|
||||
_touch(d / "mmproj" / "mmproj-F16.gguf")
|
||||
assert models_route._dir_model_format(d, recursive = True) is None
|
||||
|
||||
|
||||
def test_scan_models_dir_mmproj_only_folder_is_not_gguf(tmp_path):
|
||||
# Same rule as _dir_model_format, applied by the parallel ./models scanner.
|
||||
_touch(tmp_path / "vision" / "mmproj-F16.gguf")
|
||||
_touch(tmp_path / "real" / "model-Q4_K_M.gguf")
|
||||
formats = {m.display_name: m.model_format for m in models_route._scan_models_dir(tmp_path)}
|
||||
assert formats["vision"] is None
|
||||
assert formats["real"] == "gguf"
|
||||
|
||||
|
||||
def test_scan_models_dir_skips_standalone_mmproj_file(tmp_path):
|
||||
# A loose mmproj-*.gguf is a vision adapter with no weights to serve, so it must
|
||||
# not be offered as a model the way a loose primary GGUF is.
|
||||
_touch(tmp_path / "mmproj-F16.gguf")
|
||||
_touch(tmp_path / "model-Q4_K_M.gguf")
|
||||
names = {m.display_name for m in models_route._scan_models_dir(tmp_path)}
|
||||
assert names == {"model-Q4_K_M"}
|
||||
|
||||
|
||||
def test_scan_lmstudio_dir_skips_standalone_mmproj_file(tmp_path):
|
||||
_touch(tmp_path / "mmproj-F16.gguf")
|
||||
_touch(tmp_path / "model-Q4_K_M.gguf")
|
||||
names = {m.display_name for m in models_route._scan_lmstudio_dir(tmp_path)}
|
||||
assert names == {"model-Q4_K_M"}
|
||||
|
||||
|
||||
def test_scan_lmstudio_dir_skips_mmproj_under_publisher(tmp_path):
|
||||
# LM Studio's publisher/model.gguf layout classifies on a separate branch.
|
||||
_touch(tmp_path / "Publisher" / "mmproj-F16.gguf")
|
||||
_touch(tmp_path / "Publisher" / "model-Q4_K_M.gguf")
|
||||
names = {m.display_name for m in models_route._scan_lmstudio_dir(tmp_path)}
|
||||
assert names == {"model-Q4_K_M"}
|
||||
|
||||
|
||||
def test_dir_model_format_gguf_with_config_is_still_gguf(tmp_path):
|
||||
# A config.json alongside the .gguf must not flip it to non-GGUF.
|
||||
d = tmp_path / "model"
|
||||
|
|
|
|||
|
|
@ -1108,7 +1108,7 @@ def test_build_index_covers_legacy_default_lmstudio_and_custom_roots(monkeypatch
|
|||
monkeypatch.setattr(
|
||||
models_route,
|
||||
"_scan_hf_cache",
|
||||
lambda d: scanned.append(("hf", str(Path(d).resolve()))) or [],
|
||||
lambda d, **_: scanned.append(("hf", str(Path(d).resolve()))) or [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
models_route,
|
||||
|
|
@ -1337,6 +1337,56 @@ def test_hf_cache_entry_loads_from_local_snapshot_path(tmp_path):
|
|||
# ── review round 5: concurrent-swap, repo-id identity, /v1/models id, gate, 503 ──
|
||||
|
||||
|
||||
def _revision_pair(root, complete: bool):
|
||||
"""Two revisions of one cache repo; the newer one is optionally half-downloaded."""
|
||||
snaps = root / "models--org--Repo" / "snapshots"
|
||||
old, new = snaps / "rev-old", snaps / "rev-new"
|
||||
for path in (old, new):
|
||||
path.mkdir(parents = True)
|
||||
(old / "model-Q8_0.gguf").write_bytes(b"GGUF stub")
|
||||
name = "model-Q4_K_M.gguf" if complete else "model-Q4_K_M-00001-of-00003.gguf"
|
||||
(new / name).write_bytes(b"GGUF stub")
|
||||
return old, new
|
||||
|
||||
|
||||
def test_sibling_revision_resolves_to_its_own_weights(tmp_path):
|
||||
# /v1/models advertises only the snapshot dir name, so a durable pin holds one
|
||||
# revision hash. A newer snapshot must not strand it, and the old revision must
|
||||
# resolve to ITS OWN directory rather than be redirected onto the newest.
|
||||
old, new = _revision_pair(tmp_path, complete = True)
|
||||
|
||||
found = dict(resolver._sibling_revision_entries(str(new), "org/Repo"))
|
||||
|
||||
assert "rev-old" in found
|
||||
assert found["rev-old"].load_path == str(old)
|
||||
|
||||
|
||||
def test_incomplete_sibling_revision_is_not_indexed(tmp_path):
|
||||
# A half-downloaded revision cannot load, so naming it must not resolve to it.
|
||||
old, _new = _revision_pair(tmp_path, complete = False)
|
||||
# Point the scan at the complete one; the partial sibling is the candidate here.
|
||||
found = dict(resolver._sibling_revision_entries(str(old), "org/Repo"))
|
||||
|
||||
assert "rev-new" not in found
|
||||
|
||||
|
||||
def test_sibling_revisions_ignore_a_scan_folder_named_snapshots(tmp_path):
|
||||
# A user scan folder called "snapshots" holds unrelated models, not revisions of
|
||||
# one repo; treating them as revisions would silently serve model-a as model-b.
|
||||
snaps = tmp_path / "snapshots"
|
||||
for name in ("model-a", "model-b"):
|
||||
(snaps / name).mkdir(parents = True)
|
||||
(snaps / name / "model-Q4_K_M.gguf").write_bytes(b"GGUF stub")
|
||||
|
||||
found = dict(resolver._sibling_revision_entries(str(snaps / "model-a"), "model-a"))
|
||||
|
||||
assert found == {}
|
||||
|
||||
|
||||
def test_sibling_revisions_skip_plain_repo_ids():
|
||||
assert dict(resolver._sibling_revision_entries("org/Repo-GGUF", "org/Repo-GGUF")) == {}
|
||||
|
||||
|
||||
def test_already_loaded_by_repo_id_is_not_reswapped(monkeypatch):
|
||||
# A model loaded normally has model_identifier == repo id, but the resolver
|
||||
# returns the concrete load path. A request for that repo must count as already
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -120,10 +120,7 @@ class TestParser:
|
|||
|
||||
# Only the wrapping newline is trimmed; code-argument indentation survives.
|
||||
text = (
|
||||
"<function=python><parameter=code>\n"
|
||||
" indented = 1\n"
|
||||
" more\n"
|
||||
"</parameter></function>"
|
||||
"<function=python><parameter=code>\n indented = 1\n more\n</parameter></function>"
|
||||
)
|
||||
result = parse_tool_calls_from_text(text)
|
||||
assert len(result) == 1
|
||||
|
|
@ -157,10 +154,7 @@ class TestParser:
|
|||
def test_xml_param_preserves_leading_indentation(self):
|
||||
# Only the wrapping newline is trimmed, so code-argument indentation survives (str.strip() destroyed it).
|
||||
text = (
|
||||
"<function=python><parameter=code>\n"
|
||||
" indented = 1\n"
|
||||
" more\n"
|
||||
"</parameter></function>"
|
||||
"<function=python><parameter=code>\n indented = 1\n more\n</parameter></function>"
|
||||
)
|
||||
result = parse_tool_calls_from_text(text)
|
||||
assert len(result) == 1
|
||||
|
|
@ -310,20 +304,18 @@ class TestParser:
|
|||
tag has not arrived yet, so the strip regex has to accept
|
||||
end-of-string as a terminator. Regression for the Gemini
|
||||
high-severity flag on this PR."""
|
||||
text = (
|
||||
"<think>I should call web_search[ARGS]" '{"query":"weather"} next to find the answer.'
|
||||
)
|
||||
text = '<think>I should call web_search[ARGS]{"query":"weather"} next to find the answer.'
|
||||
result = parse_tool_calls_from_text(text)
|
||||
# Inside an unclosed think block no calls are yielded.
|
||||
assert result == []
|
||||
|
||||
def test_rehearsal_inside_unclosed_bracket_think_is_ignored(self):
|
||||
text = "[THINK]planning to use python[ARGS]" '{"code":"print(1)"} but not yet.'
|
||||
text = '[THINK]planning to use python[ARGS]{"code":"print(1)"} but not yet.'
|
||||
result = parse_tool_calls_from_text(text)
|
||||
assert result == []
|
||||
|
||||
def test_rehearsal_after_closed_think_still_parsed(self):
|
||||
text = "<think>planning</think>" 'python[ARGS]{"code":"print(1)"}'
|
||||
text = '<think>planning</think>python[ARGS]{"code":"print(1)"}'
|
||||
result = parse_tool_calls_from_text(text)
|
||||
assert len(result) == 1
|
||||
assert result[0]["function"]["name"] == "python"
|
||||
|
|
@ -365,7 +357,7 @@ class TestParser:
|
|||
|
||||
def test_mistral_bracket_nested_json(self):
|
||||
# Brace-balance scan handles nested objects and braces inside string literals.
|
||||
text = "[TOOL_CALLS]web_search" '{"query":"a {nested} brace","opts":{"limit":5}}'
|
||||
text = '[TOOL_CALLS]web_search{"query":"a {nested} brace","opts":{"limit":5}}'
|
||||
result = parse_tool_calls_from_text(text)
|
||||
assert len(result) == 1
|
||||
import json as _json
|
||||
|
|
@ -376,11 +368,7 @@ class TestParser:
|
|||
|
||||
def test_mistral_bracket_with_prose(self):
|
||||
# Bracket-tag surrounded by prose is still recognised.
|
||||
text = (
|
||||
"Sure, I will look that up.\n"
|
||||
'[TOOL_CALLS]web_search{"query":"weather"}\n'
|
||||
"Calling now."
|
||||
)
|
||||
text = 'Sure, I will look that up.\n[TOOL_CALLS]web_search{"query":"weather"}\nCalling now.'
|
||||
result = parse_tool_calls_from_text(text)
|
||||
assert len(result) == 1
|
||||
assert result[0]["function"]["name"] == "web_search"
|
||||
|
|
@ -408,7 +396,7 @@ class TestParser:
|
|||
assert "print(1)" in result[0]["function"]["arguments"]
|
||||
|
||||
def test_rehearsal_with_prose(self):
|
||||
text = "I should call the python tool. Like this: " 'python[ARGS]{"code":"x = 1"}'
|
||||
text = 'I should call the python tool. Like this: python[ARGS]{"code":"x = 1"}'
|
||||
result = parse_tool_calls_from_text(text)
|
||||
assert len(result) == 1
|
||||
assert result[0]["function"]["name"] == "python"
|
||||
|
|
@ -489,16 +477,14 @@ class TestParser:
|
|||
assert result[0]["function"]["name"] == "web_search"
|
||||
|
||||
def test_think_block_stripped_before_bracket_tag(self):
|
||||
text = (
|
||||
"<think>Let me search for that.</think>\n" '[TOOL_CALLS]web_search{"query":"weather"}'
|
||||
)
|
||||
text = '<think>Let me search for that.</think>\n[TOOL_CALLS]web_search{"query":"weather"}'
|
||||
result = parse_tool_calls_from_text(text)
|
||||
assert len(result) == 1
|
||||
assert result[0]["function"]["name"] == "web_search"
|
||||
|
||||
def test_uppercase_think_tag_stripped(self):
|
||||
# Some templates use [THINK]...[/THINK] instead of <think>.
|
||||
text = "[THINK]planning my next call[/THINK]" '[TOOL_CALLS]python{"code":"print(1)"}'
|
||||
text = '[THINK]planning my next call[/THINK][TOOL_CALLS]python{"code":"print(1)"}'
|
||||
result = parse_tool_calls_from_text(text)
|
||||
assert len(result) == 1
|
||||
assert result[0]["function"]["name"] == "python"
|
||||
|
|
@ -544,8 +530,7 @@ class TestParser:
|
|||
def test_xml_wins_over_bracket(self):
|
||||
# When a model emits both forms in one message, the XML form is canonical and wins.
|
||||
text = (
|
||||
'<tool_call>{"name":"primary","arguments":{}}</tool_call>'
|
||||
'[TOOL_CALLS]secondary{"k":"v"}'
|
||||
'<tool_call>{"name":"primary","arguments":{}}</tool_call>[TOOL_CALLS]secondary{"k":"v"}'
|
||||
)
|
||||
result = parse_tool_calls_from_text(text)
|
||||
assert len(result) == 1
|
||||
|
|
@ -728,7 +713,7 @@ class TestParserMultiFormat:
|
|||
def test_llama3_python_tag_dot_call_multi_arg(self):
|
||||
import json
|
||||
|
||||
text = "<|python_tag|>get_weather.call(" 'location="Tokyo", units="celsius", days=5)'
|
||||
text = '<|python_tag|>get_weather.call(location="Tokyo", units="celsius", days=5)'
|
||||
result = parse_tool_calls_from_text(text)
|
||||
assert len(result) == 1
|
||||
args = json.loads(result[0]["function"]["arguments"])
|
||||
|
|
@ -1330,12 +1315,7 @@ class TestParserDeepSeek:
|
|||
def test_v3_1_strict_rejects_unclosed_envelope(self):
|
||||
# Envelope truncated mid-stream (no <|tool▁calls▁end|>): healed by
|
||||
# default, rejected with Auto-Heal off.
|
||||
text = (
|
||||
"<|tool▁calls▁begin|>"
|
||||
"<|tool▁call▁begin|>get_time"
|
||||
"<|tool▁sep|>"
|
||||
'{"city": "Tokyo"}'
|
||||
)
|
||||
text = '<|tool▁calls▁begin|><|tool▁call▁begin|>get_time<|tool▁sep|>{"city": "Tokyo"}'
|
||||
assert len(parse_tool_calls_from_text(text)) == 1
|
||||
assert parse_tool_calls_from_text(text, allow_incomplete = False) == []
|
||||
|
||||
|
|
@ -1765,9 +1745,9 @@ class TestParserCrossFormatRouting:
|
|||
for label, text, expected_name in cases:
|
||||
result = parse_tool_calls_from_text(text)
|
||||
assert len(result) == 1, f"{label}: parser missed the call"
|
||||
assert result[0]["function"]["name"] == expected_name, (
|
||||
f"{label}: got {result[0]['function']['name']!r}, " f"expected {expected_name!r}"
|
||||
)
|
||||
assert (
|
||||
result[0]["function"]["name"] == expected_name
|
||||
), f"{label}: got {result[0]['function']['name']!r}, expected {expected_name!r}"
|
||||
|
||||
def test_all_new_markers_in_tool_xml_signals(self):
|
||||
# The safetensors / MLX streaming buffer must wake on every supported emission marker --
|
||||
|
|
@ -2538,6 +2518,9 @@ class TestLoopBasic:
|
|||
tools = [{"type": "function", "function": {"name": "render_html"}}],
|
||||
execute_tool = exec_fn,
|
||||
confirm_tool_calls = True,
|
||||
# Unset defaults to "auto", which only gates render_html when it
|
||||
# reaches the network, so this static canvas would not prompt.
|
||||
permission_mode = "ask",
|
||||
session_id = "sess",
|
||||
max_tool_iterations = 3,
|
||||
)
|
||||
|
|
@ -3402,10 +3385,7 @@ class TestLoopRePrompt:
|
|||
loop, exec_fn = _make_loop(
|
||||
turns = [
|
||||
["Let me search for that."],
|
||||
[
|
||||
'<tool_call>{"name":"web_search","arguments":'
|
||||
'{"query":"sky color"}}</tool_call>'
|
||||
],
|
||||
['<tool_call>{"name":"web_search","arguments":{"query":"sky color"}}</tool_call>'],
|
||||
["The sky is blue."],
|
||||
],
|
||||
exec_results = ["Blue (Rayleigh scattering)"],
|
||||
|
|
@ -3513,7 +3493,7 @@ class TestLoopCanonicalHealKey:
|
|||
def test_python_bare_string_heals_to_code(self):
|
||||
loop, exec_fn = _make_loop(
|
||||
turns = [
|
||||
['<tool_call>{"name":"python","arguments":"print(1)"}' "</tool_call>"],
|
||||
['<tool_call>{"name":"python","arguments":"print(1)"}</tool_call>'],
|
||||
["done"],
|
||||
],
|
||||
exec_results = ["1\n"],
|
||||
|
|
@ -3526,7 +3506,7 @@ class TestLoopCanonicalHealKey:
|
|||
def test_terminal_bare_string_heals_to_command(self):
|
||||
loop, exec_fn = _make_loop(
|
||||
turns = [
|
||||
['<tool_call>{"name":"terminal","arguments":"ls -la"}' "</tool_call>"],
|
||||
['<tool_call>{"name":"terminal","arguments":"ls -la"}</tool_call>'],
|
||||
["done"],
|
||||
],
|
||||
exec_results = ["..."],
|
||||
|
|
@ -3537,7 +3517,7 @@ class TestLoopCanonicalHealKey:
|
|||
def test_unknown_tool_bare_string_heals_to_query(self):
|
||||
loop, exec_fn = _make_loop(
|
||||
turns = [
|
||||
['<tool_call>{"name":"web_search","arguments":"hello"}' "</tool_call>"],
|
||||
['<tool_call>{"name":"web_search","arguments":"hello"}</tool_call>'],
|
||||
["ok"],
|
||||
],
|
||||
exec_results = ["..."],
|
||||
|
|
@ -3927,6 +3907,8 @@ class TestGuardrails:
|
|||
turns = [['<tool_call>{"name":"python","arguments":{"code":"print(1)"}}</tool_call>']],
|
||||
exec_results = ["OK"],
|
||||
confirm_tool_calls = True,
|
||||
# Unset defaults to "auto", which would not prompt this safe call.
|
||||
permission_mode = "ask",
|
||||
session_id = "sess",
|
||||
max_tool_iterations = 1,
|
||||
)
|
||||
|
|
@ -3957,6 +3939,9 @@ class TestGuardrails:
|
|||
loop, exec_fn = _make_loop(
|
||||
turns = [["plain answer"]],
|
||||
confirm_tool_calls = True,
|
||||
# "ask" gates every call so autoinject waits; the companion test
|
||||
# below covers "auto", where the safe retrieval never gates.
|
||||
permission_mode = "ask",
|
||||
rag_scope = {"thread_id": "t1"},
|
||||
)
|
||||
events = _collect_events(loop)
|
||||
|
|
@ -4313,6 +4298,8 @@ class TestPlanWithoutActionReprompt:
|
|||
["SHOULD NOT APPEAR"],
|
||||
],
|
||||
confirm_tool_calls = True,
|
||||
# Only "ask" gates the always-safe web_search, so the deny path runs.
|
||||
permission_mode = "ask",
|
||||
session_id = "sess",
|
||||
nudge_tool_calls = True,
|
||||
)
|
||||
|
|
@ -4367,20 +4354,18 @@ class TestRoutesPythonTagStrip:
|
|||
def test_python_tag_multiline_with_less_than(self):
|
||||
# Combined: multi-line code AND literal ``<`` in code.
|
||||
text = (
|
||||
'<|python_tag|>python.call(code="for i in range(10):\n'
|
||||
" if i < 5:\n"
|
||||
' print(i)")'
|
||||
'<|python_tag|>python.call(code="for i in range(10):\n if i < 5:\n print(i)")'
|
||||
)
|
||||
assert self._strip(text) == ""
|
||||
|
||||
def test_python_tag_stops_at_eom_sentinel(self):
|
||||
# Strip stops at the next Llama-3 ``<|`` sentinel so any
|
||||
# trailing assistant content survives.
|
||||
text = '<|python_tag|>python.call(code="multi\nline")' "<|eom_id|>final answer text"
|
||||
text = '<|python_tag|>python.call(code="multi\nline")<|eom_id|>final answer text'
|
||||
assert self._strip(text) == "<|eom_id|>final answer text"
|
||||
|
||||
def test_python_tag_stops_at_eot_sentinel(self):
|
||||
text = '<|python_tag|>brave_search.call(query="x")' "<|eot_id|>after"
|
||||
text = '<|python_tag|>brave_search.call(query="x")<|eot_id|>after'
|
||||
assert self._strip(text) == "<|eot_id|>after"
|
||||
|
||||
def test_python_tag_json_form_multiline_stripped(self):
|
||||
|
|
@ -4410,7 +4395,7 @@ class TestParserRobustness:
|
|||
# too. Was extracting name only and silently dropping the args.
|
||||
import json
|
||||
|
||||
text = "<tool_call>\n" '{"name": "search", "parameters": {"q": "ramen"}}\n' "</tool_call>"
|
||||
text = '<tool_call>\n{"name": "search", "parameters": {"q": "ramen"}}\n</tool_call>'
|
||||
result = parse_tool_calls_from_text(text)
|
||||
assert len(result) == 1
|
||||
assert result[0]["function"]["name"] == "search"
|
||||
|
|
@ -4421,7 +4406,7 @@ class TestParserRobustness:
|
|||
# ``<function name="..."><param name="...">v</param></function>``.
|
||||
import json
|
||||
|
||||
text = '<function name="get_weather">' '<param name="city">Tokyo</param>' "</function>"
|
||||
text = '<function name="get_weather"><param name="city">Tokyo</param></function>'
|
||||
result = parse_tool_calls_from_text(text)
|
||||
assert len(result) == 1
|
||||
assert result[0]["function"]["name"] == "get_weather"
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ class TestUploadDenylist:
|
|||
)
|
||||
|
||||
def test_plain_post_json_not_blocked(self):
|
||||
_ok("import requests\n" 'requests.post("https://api.weather.gov/lookup", json={"k": "v"})')
|
||||
_ok('import requests\nrequests.post("https://api.weather.gov/lookup", json={"k": "v"})')
|
||||
|
||||
|
||||
class TestSandboxEnvIsolation:
|
||||
|
|
@ -693,6 +693,51 @@ class TestBashBlocklistPosition:
|
|||
def test_while_do_blocked(self):
|
||||
assert "curl" in self._find()("while true; do curl --version; break; done")
|
||||
|
||||
# ---- `.` is the POSIX synonym for the blocked `source` builtin ----
|
||||
def test_dot_source_blocked(self):
|
||||
assert "." in self._find()(". ./script.sh")
|
||||
assert "." in self._find()("cat x && . ./payload")
|
||||
|
||||
def test_dot_in_argument_position_allowed(self):
|
||||
assert self._find()("find . -type f") == set()
|
||||
assert self._find()("ls .") == set()
|
||||
assert self._find()("cd .") == set()
|
||||
|
||||
# ---- ANSI-C quoting must not hide a blocked command name ----
|
||||
def test_ansi_c_quoted_command_blocked(self):
|
||||
assert "ssh" in self._find()("$'ssh' user@host")
|
||||
assert "source" in self._find()("$'source' ./payload")
|
||||
|
||||
def test_ansi_c_data_with_newline_is_not_a_command(self):
|
||||
# $'...' expands to a single word, so a newline inside it is data for
|
||||
# printf, not a separator that starts a second command.
|
||||
payload = "printf '%s' $'hello\\n" + "rm" + " -rf x\\n'"
|
||||
assert self._find()(payload) == set()
|
||||
|
||||
def test_command_position_glob_matches_blocked_name(self):
|
||||
# Bash expands the pattern to the blocked name after this scan runs.
|
||||
assert "rm" in self._find()("/bin/r[m] -rf /tmp/victim")
|
||||
assert "rm" in self._find()("/bin/r? -rf /tmp/victim")
|
||||
|
||||
def test_glob_without_literal_character_allowed(self):
|
||||
# A bracket expression in argument position is not a command word.
|
||||
assert self._find()("echo '[a]'") == set()
|
||||
|
||||
def test_attached_exec_flag_value_blocked(self):
|
||||
# fd accepts the command attached to the flag, so the value is what runs.
|
||||
assert "rm" in self._find()("fd victim . --exec=rm")
|
||||
assert "rm" in self._find()("fd victim . --exec-batch=rm")
|
||||
|
||||
def test_short_flag_neighbour_not_read_as_command(self):
|
||||
# Only the long spellings carry an attached command; -x belongs to too
|
||||
# many other utilities to read its neighbour as one.
|
||||
assert self._find()("grep -x rm file.txt") == set()
|
||||
|
||||
def test_alias_body_scanned_as_command(self):
|
||||
# `alias zap='rm -rf'` stores a command bash runs when zap is invoked.
|
||||
assert "rm" in self._find()("alias zap='rm -rf'")
|
||||
assert self._find()("alias ll='ls -la'") == set()
|
||||
|
||||
|
||||
class TestHfUploadImportGate:
|
||||
"""Upload-method blocking requires an HF import in scope, so paramiko /
|
||||
|
|
@ -737,15 +782,11 @@ class TestHfUploadImportGate:
|
|||
|
||||
def test_hf_bare_name_upload_folder_safe_allowed(self):
|
||||
_ok(
|
||||
"from huggingface_hub import upload_folder;"
|
||||
" upload_folder(folder_path='x', repo_id='r')"
|
||||
"from huggingface_hub import upload_folder; upload_folder(folder_path='x', repo_id='r')"
|
||||
)
|
||||
|
||||
def test_hf_bare_name_create_commit_safe_allowed(self):
|
||||
_ok(
|
||||
"from huggingface_hub import create_commit;"
|
||||
" create_commit(operations=[], repo_id='r')"
|
||||
)
|
||||
_ok("from huggingface_hub import create_commit; create_commit(operations=[], repo_id='r')")
|
||||
|
||||
def test_bare_name_upload_file_without_hf_import_allowed(self):
|
||||
# No HF import -- local helper named upload_file passes.
|
||||
|
|
|
|||
|
|
@ -94,6 +94,9 @@ def _drive(
|
|||
execute_tool = exec_fn,
|
||||
session_id = _SESSION,
|
||||
confirm_tool_calls = True,
|
||||
# The confirm-gate mechanics (allow/deny/reissue/dedup) need every call to
|
||||
# prompt; unset defaults to "auto", which only gates high-risk calls.
|
||||
permission_mode = "ask",
|
||||
)
|
||||
events = []
|
||||
for ev in gen:
|
||||
|
|
|
|||
9
studio/frontend/public/agent-logos/hermes.svg
Normal file
9
studio/frontend/public/agent-logos/hermes.svg
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<!-- Source: https://github.com/NousResearch/hermes-agent/blob/main/acp_registry/icon.svg -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none">
|
||||
<path d="M8 1.5v13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M8 3.25c-2.35-1.4-4.7-.95-6.25.35 1.85-.2 3.8.2 5.55 1.55" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 3.25c2.35-1.4 4.7-.95 6.25.35-1.85-.2-3.8.2-5.55 1.55" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 13.25c-2.3-1-3.05-2.65-1.35-4.15-2 .8-2.35 2.95-.35 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 13.25c2.3-1 3.05-2.65 1.35-4.15 2 .8 2.35 2.95.35 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="8" cy="1.8" r="1.1" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 976 B |
18
studio/frontend/public/agent-logos/openclaw.svg
Normal file
18
studio/frontend/public/agent-logos/openclaw.svg
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<!-- Source: https://github.com/openclaw/openclaw/blob/main/apps/linux/src-tauri/icons/icon.svg -->
|
||||
<svg viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="lobster-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ff4d4d"/>
|
||||
<stop offset="100%" stop-color="#991b1b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M60 10 C30 10 15 35 15 55 C15 75 30 95 45 100 L45 110 L55 110 L55 100 C55 100 60 102 65 100 L65 110 L75 110 L75 100 C90 95 105 75 105 55 C105 35 90 10 60 10Z" fill="url(#lobster-gradient)"/>
|
||||
<path d="M20 45 C5 40 0 50 5 60 C10 70 20 65 25 55 C28 48 25 45 20 45Z" fill="url(#lobster-gradient)"/>
|
||||
<path d="M100 45 C115 40 120 50 115 60 C110 70 100 65 95 55 C92 48 95 45 100 45Z" fill="url(#lobster-gradient)"/>
|
||||
<path d="M45 15 Q35 5 30 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M75 15 Q85 5 90 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round"/>
|
||||
<circle cx="45" cy="35" r="6" fill="#050810"/>
|
||||
<circle cx="75" cy="35" r="6" fill="#050810"/>
|
||||
<circle cx="46" cy="34" r="2.5" fill="#00e5cc"/>
|
||||
<circle cx="76" cy="34" r="2.5" fill="#00e5cc"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
19
studio/frontend/public/agent-logos/opencode-dark.svg
Normal file
19
studio/frontend/public/agent-logos/opencode-dark.svg
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<!-- Source: https://github.com/anomalyco/opencode/blob/dev/packages/console/app/src/asset/brand/opencode-logo-dark-square.svg -->
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(30, 0)">
|
||||
<g clip-path="url(#clip0)">
|
||||
<mask id="mask0" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
|
||||
<path d="M240 0H0V300H240V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0)">
|
||||
<path d="M180 240H60V120H180V240Z" fill="#4B4646"/>
|
||||
<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#F1ECEC"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="240" height="300" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 796 B |
19
studio/frontend/public/agent-logos/opencode-light.svg
Normal file
19
studio/frontend/public/agent-logos/opencode-light.svg
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<!-- Source: https://github.com/anomalyco/opencode/blob/dev/packages/console/app/src/asset/brand/opencode-logo-light-square.svg -->
|
||||
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(30, 0)">
|
||||
<g clip-path="url(#clip0)">
|
||||
<mask id="mask0" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
|
||||
<path d="M240 0H0V300H240V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0)">
|
||||
<path d="M180 240H60V120H180V240Z" fill="#CFCECD"/>
|
||||
<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#211E1E"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="240" height="300" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 797 B |
21
studio/frontend/public/agent-logos/pi.svg
Normal file
21
studio/frontend/public/agent-logos/pi.svg
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<!-- Source: https://pi.dev/favicon.svg (official Pi press-kit badge) -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800">
|
||||
<rect width="800" height="800" rx="120" fill="#09090b"/>
|
||||
<path fill="#fff" fill-rule="evenodd" d="
|
||||
M165.29 165.29
|
||||
H517.36
|
||||
V400
|
||||
H400
|
||||
V517.36
|
||||
H282.65
|
||||
V634.72
|
||||
H165.29
|
||||
Z
|
||||
M282.65 282.65
|
||||
V400
|
||||
H400
|
||||
V282.65
|
||||
Z
|
||||
"/>
|
||||
<path fill="#fff" d="M517.36 400 H634.72 V634.72 H517.36 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 475 B |
|
|
@ -40,7 +40,6 @@ interface ApiProviderLogoProps {
|
|||
title?: string;
|
||||
}
|
||||
|
||||
// Monochrome logos vanish on a dark background.
|
||||
const DARK_INVERT_LOGOS = new Set(["openai", "ollama", "openrouter"]);
|
||||
|
||||
/** Provider logo from `public/provider-logos/`; monochrome ones invert in dark mode. */
|
||||
|
|
|
|||
|
|
@ -3172,12 +3172,15 @@ export function createOpenAIStreamAdapter(
|
|||
// Permission level for local tool calls is sent for every local
|
||||
// chat, not only when a tool pill is on: a process policy
|
||||
// (unsloth run --enable-tools) can open the tool loop with no pill,
|
||||
// and the backend must still see the selected gate. ask/auto request
|
||||
// the confirm gate ("auto" only pauses calls flagged unsafe); off
|
||||
// and full never prompt, full also drops the sandbox.
|
||||
// and the backend must still see the selected gate. "auto" OMITS
|
||||
// confirm_tool_calls: an explicit true would make the backend treat
|
||||
// every auto request as needing a stream and defeat the safe-only
|
||||
// no-stream exception. "ask" sends true; off/full send false (full
|
||||
// also drops the sandbox).
|
||||
permission_mode: permissionMode,
|
||||
confirm_tool_calls:
|
||||
permissionMode === "ask" || permissionMode === "auto",
|
||||
...(permissionMode === "auto"
|
||||
? {}
|
||||
: { confirm_tool_calls: permissionMode === "ask" }),
|
||||
bypass_permissions: bypassPermissions,
|
||||
...(supportsTools &&
|
||||
(toolsEnabled ||
|
||||
|
|
|
|||
|
|
@ -352,6 +352,9 @@ export interface LocalModelInfo {
|
|||
// Backend-detected weights format ("gguf" when known), so the UI can
|
||||
// classify scanned folders whose name lacks a -GGUF suffix.
|
||||
model_format?: string | null;
|
||||
// Set when a cached snapshot holds an incomplete download, so consumers can skip
|
||||
// weights that cannot load yet.
|
||||
partial?: boolean;
|
||||
updated_at?: number | null;
|
||||
// HF pipeline task inferred from the GGUF architecture, so the Images picker
|
||||
// can filter local models to diffusion ("text-to-image"). Optional for
|
||||
|
|
|
|||
|
|
@ -11,9 +11,11 @@ export {
|
|||
fetchGgufStagedMetadata,
|
||||
getCachedModelPath,
|
||||
getInferenceStatus,
|
||||
listCachedGguf,
|
||||
listChatAttachments,
|
||||
listGgufVariants,
|
||||
listLocalModels,
|
||||
listModels,
|
||||
listRecommendedFolders,
|
||||
listScanFolders,
|
||||
loadModel,
|
||||
|
|
@ -28,7 +30,11 @@ export {
|
|||
type LocalModelInfo,
|
||||
type ScanFolderInfo,
|
||||
} from "./api/chat-api";
|
||||
export type { GgufVariantDetail } from "./types/api";
|
||||
export type {
|
||||
BackendModelDetails,
|
||||
GgufVariantDetail,
|
||||
InferenceStatusResponse,
|
||||
} from "./types/api";
|
||||
export {
|
||||
ChatSettingsPanel,
|
||||
ParamSlider,
|
||||
|
|
|
|||
|
|
@ -52,7 +52,8 @@ export const PERMISSION_MODE_OPTIONS: readonly {
|
|||
{
|
||||
value: "auto",
|
||||
label: "Approve for me",
|
||||
description: "Only ask for actions detected as potentially unsafe",
|
||||
description:
|
||||
"Run tool calls, but ask before high-risk actions like credential access, privilege escalation, or destructive commands",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
|
|
@ -76,6 +77,8 @@ export const FULL_ACCESS_WARNING =
|
|||
export function permissionModeOption(mode: PermissionMode) {
|
||||
return (
|
||||
PERMISSION_MODE_OPTIONS.find((option) => option.value === mode) ??
|
||||
// Unknown values fall back to the default ("Approve for me"), not row 0 ("Ask").
|
||||
PERMISSION_MODE_OPTIONS.find((option) => option.value === "auto") ??
|
||||
PERMISSION_MODE_OPTIONS[0]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,8 +51,8 @@ export const CHAT_PERMISSION_MODE_KEY = "unsloth_chat_permission_mode";
|
|||
/**
|
||||
* Permission level for local tool calls:
|
||||
* - "ask": always ask before every tool call runs.
|
||||
* - "auto" ("Approve for me"): only ask for calls the backend detects as
|
||||
* potentially unsafe; read-only calls run immediately. Sandbox stays on.
|
||||
* - "auto" ("Approve for me", the default): only ask for calls the backend
|
||||
* detects as high risk; ordinary dev commands run immediately. Sandbox stays on.
|
||||
* - "off": never ask; tool calls run automatically inside the sandbox
|
||||
* (the original default before permission levels existed).
|
||||
* - "full" ("Full access"): no confirmations and the python/terminal sandbox
|
||||
|
|
|
|||
|
|
@ -115,6 +115,8 @@ export interface GgufVariantDetail {
|
|||
download_size_bytes?: number;
|
||||
downloaded?: boolean;
|
||||
update_available?: boolean;
|
||||
/** An interrupted download: some shards are missing, so it cannot load yet. */
|
||||
partial?: boolean;
|
||||
}
|
||||
|
||||
export interface GgufVariantsResponse {
|
||||
|
|
@ -169,7 +171,10 @@ export interface LoadModelResponse {
|
|||
max_context_length?: number | null;
|
||||
native_context_length?: number | null;
|
||||
supports_reasoning?: boolean;
|
||||
reasoning_style?: "enable_thinking" | "reasoning_effort" | "enable_thinking_effort";
|
||||
reasoning_style?:
|
||||
| "enable_thinking"
|
||||
| "reasoning_effort"
|
||||
| "enable_thinking_effort";
|
||||
reasoning_effort_levels?: string[];
|
||||
reasoning_always_on?: boolean;
|
||||
supports_preserve_thinking?: boolean;
|
||||
|
|
@ -220,7 +225,10 @@ export interface InferenceStatusResponse {
|
|||
} | null;
|
||||
requires_trust_remote_code?: boolean;
|
||||
supports_reasoning?: boolean;
|
||||
reasoning_style?: "enable_thinking" | "reasoning_effort" | "enable_thinking_effort";
|
||||
reasoning_style?:
|
||||
| "enable_thinking"
|
||||
| "reasoning_effort"
|
||||
| "enable_thinking_effort";
|
||||
reasoning_effort_levels?: string[];
|
||||
reasoning_always_on?: boolean;
|
||||
supports_preserve_thinking?: boolean;
|
||||
|
|
@ -389,7 +397,7 @@ export interface OpenAIChatCompletionsRequest {
|
|||
| "xhigh"
|
||||
| null;
|
||||
preserve_thinking?: boolean | null;
|
||||
thinking?: {type: "disabled" | "enabled";} | null;
|
||||
thinking?: { type: "disabled" | "enabled" } | null;
|
||||
enable_tools?: boolean | null;
|
||||
enabled_tools?: string[];
|
||||
/** Local models + enable_tools only. */
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
{
|
||||
"column_type": "llm-structured",
|
||||
"name": "llm_structured_1",
|
||||
"drop": false,
|
||||
"drop": true,
|
||||
"model_alias": "provider_column",
|
||||
"prompt": "Given ONLY this chunk: {{ chunk_text }} generate one answerable question, answer, and exact supporting quote from chunk. If not answerable, skip.",
|
||||
"with_trace": "none",
|
||||
|
|
@ -43,11 +43,7 @@
|
|||
"output_format": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"question",
|
||||
"answer",
|
||||
"evidence_quote"
|
||||
],
|
||||
"required": ["question", "answer", "evidence_quote"],
|
||||
"properties": {
|
||||
"question": {
|
||||
"type": "string"
|
||||
|
|
@ -60,16 +56,41 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"column_type": "expression",
|
||||
"name": "instruction",
|
||||
"drop": false,
|
||||
"expr": "{{ llm_structured_1.question }}",
|
||||
"dtype": "str"
|
||||
},
|
||||
{
|
||||
"column_type": "expression",
|
||||
"name": "output",
|
||||
"drop": false,
|
||||
"expr": "{{ llm_structured_1.answer }}",
|
||||
"dtype": "str"
|
||||
},
|
||||
{
|
||||
"column_type": "expression",
|
||||
"name": "input",
|
||||
"drop": false,
|
||||
"expr": "Evidence quote: {{ llm_structured_1.evidence_quote }}\n\nSource context: {{ chunk_text }}",
|
||||
"dtype": "str"
|
||||
}
|
||||
],
|
||||
"processors": []
|
||||
"processors": [
|
||||
{
|
||||
"processor_type": "drop_columns",
|
||||
"name": "drop_seed_columns",
|
||||
"column_names": ["chunk_text", "source_file"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"run": {
|
||||
"rows": 5,
|
||||
"preview": true,
|
||||
"output_formats": [
|
||||
"jsonl"
|
||||
]
|
||||
"output_formats": ["jsonl"]
|
||||
},
|
||||
"ui": {
|
||||
"nodes": [
|
||||
|
|
@ -102,7 +123,7 @@
|
|||
"width": 400,
|
||||
"node_type": "markdown_note",
|
||||
"name": "note_3",
|
||||
"markdown": "- LLM prompt: `{{ chunk_text }}`\n- Expression block: combine/format values using `{{ chunk_text }}`\n- Processor templates: use `{{ chunk_text }}` during transforms\n\nTip:\n- Start with medium chunk size + small overlap.\n- Increase overlap only if answers lose context between chunks.",
|
||||
"markdown": "The structured LLM block generates a question, answer, and evidence quote from `{{ chunk_text }}`.\n\nExpression blocks then project the result into a training-ready Alpaca row:\n\n- `instruction`: generated question\n- `input`: evidence quote and source context\n- `output`: generated answer\n\nThe source chunk, source-file field, and nested structured intermediate are dropped only after these fields are created.",
|
||||
"note_color": "#F3E8FF",
|
||||
"note_opacity": "35"
|
||||
},
|
||||
|
|
@ -129,6 +150,24 @@
|
|||
"x": 960,
|
||||
"y": 1077,
|
||||
"width": 400
|
||||
},
|
||||
{
|
||||
"id": "instruction",
|
||||
"x": 1440,
|
||||
"y": 895,
|
||||
"width": 400
|
||||
},
|
||||
{
|
||||
"id": "output",
|
||||
"x": 1440,
|
||||
"y": 1077,
|
||||
"width": 400
|
||||
},
|
||||
{
|
||||
"id": "input",
|
||||
"x": 1440,
|
||||
"y": 1259,
|
||||
"width": 400
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
|
|
@ -147,11 +186,39 @@
|
|||
"target_handle": "data-in-top"
|
||||
},
|
||||
{
|
||||
"from": "llm_structured_1",
|
||||
"to": "seed",
|
||||
"from": "seed",
|
||||
"to": "llm_structured_1",
|
||||
"type": "canvas",
|
||||
"source_handle": "data-out-left",
|
||||
"target_handle": "data-in-right"
|
||||
"source_handle": "data-out",
|
||||
"target_handle": "data-in"
|
||||
},
|
||||
{
|
||||
"from": "llm_structured_1",
|
||||
"to": "instruction",
|
||||
"type": "canvas",
|
||||
"source_handle": "data-out",
|
||||
"target_handle": "data-in"
|
||||
},
|
||||
{
|
||||
"from": "llm_structured_1",
|
||||
"to": "output",
|
||||
"type": "canvas",
|
||||
"source_handle": "data-out",
|
||||
"target_handle": "data-in"
|
||||
},
|
||||
{
|
||||
"from": "llm_structured_1",
|
||||
"to": "input",
|
||||
"type": "canvas",
|
||||
"source_handle": "data-out",
|
||||
"target_handle": "data-in"
|
||||
},
|
||||
{
|
||||
"from": "seed",
|
||||
"to": "input",
|
||||
"type": "canvas",
|
||||
"source_handle": "data-out",
|
||||
"target_handle": "data-in"
|
||||
}
|
||||
],
|
||||
"layout_direction": "LR",
|
||||
|
|
@ -164,4 +231,4 @@
|
|||
"unstructured_chunk_size": "1200",
|
||||
"unstructured_chunk_overlap": "200"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -404,6 +404,10 @@ export function importRecipePayload(
|
|||
uiSeedSourceTypeRaw === "unstructured"
|
||||
? uiSeedSourceTypeRaw
|
||||
: undefined;
|
||||
const payloadSeedSourceIsUnstructured =
|
||||
isRecord(recipe.seed_config) &&
|
||||
isRecord(recipe.seed_config.source) &&
|
||||
recipe.seed_config.source.seed_type === "unstructured";
|
||||
const uiSeedColumns = Array.isArray(ui?.seed_columns)
|
||||
? ui.seed_columns
|
||||
.map((value) => (typeof value === "string" ? value.trim() : ""))
|
||||
|
|
@ -478,7 +482,17 @@ export function importRecipePayload(
|
|||
nextId += 1;
|
||||
const seedConfig = parseSeedConfig(recipe.seed_config, id, {
|
||||
preferredSourceType: uiSeedSourceType,
|
||||
seed_columns: uiSeedColumns,
|
||||
drop:
|
||||
payloadSeedSourceIsUnstructured && payloadSeedDropColumns.length > 0,
|
||||
// Payload-only unstructured recipes have no preview metadata, but their
|
||||
// generated rows always expose these fields. Keep the imported drop
|
||||
// processor usable until a real preview replaces this fallback.
|
||||
seed_columns:
|
||||
(uiSeedColumns?.length ?? 0) > 0
|
||||
? uiSeedColumns
|
||||
: uiSeedSourceType === "unstructured" || payloadSeedSourceIsUnstructured
|
||||
? ["chunk_text", "source_file"]
|
||||
: uiSeedColumns,
|
||||
seed_drop_columns:
|
||||
uiSeedDropColumns && uiSeedDropColumns.length > 0
|
||||
? uiSeedDropColumns
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ export function parseSeedConfig(
|
|||
id: string,
|
||||
options?: {
|
||||
preferredSourceType?: SeedSourceType;
|
||||
drop?: boolean;
|
||||
seed_columns?: string[];
|
||||
seed_drop_columns?: string[];
|
||||
seed_preview_rows?: Record<string, unknown>[];
|
||||
|
|
@ -229,6 +230,7 @@ export function parseSeedConfig(
|
|||
...makeDefaultSeedConfig(id),
|
||||
...parsed, // payload-only fields override ui defaults
|
||||
seed_source_type: sourceType,
|
||||
...(options?.drop !== undefined ? { drop: options.drop } : {}),
|
||||
...(options?.seed_columns ? { seed_columns: options.seed_columns } : {}),
|
||||
...(options?.seed_drop_columns
|
||||
? { seed_drop_columns: options.seed_drop_columns }
|
||||
|
|
|
|||
|
|
@ -164,17 +164,24 @@ export function buildSeedDropProcessor(
|
|||
): Record<string, unknown> | null {
|
||||
const seedSourceType = config.seed_source_type ?? "hf";
|
||||
const loadedCols = (config.seed_columns ?? []).map((c) => c.trim()).filter(Boolean);
|
||||
const selectedDropColumns = (config.seed_drop_columns ?? [])
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
let cols: string[] = [];
|
||||
|
||||
if (seedSourceType === "unstructured") {
|
||||
if (!config.drop) {
|
||||
return null;
|
||||
}
|
||||
cols = loadedCols;
|
||||
cols =
|
||||
selectedDropColumns.length > 0
|
||||
? loadedCols.length > 0
|
||||
? selectedDropColumns.filter((col) => loadedCols.includes(col))
|
||||
: selectedDropColumns
|
||||
: loadedCols.length > 0
|
||||
? loadedCols
|
||||
: ["chunk_text", "source_file"];
|
||||
} else {
|
||||
const selectedDropColumns = (config.seed_drop_columns ?? [])
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
if (selectedDropColumns.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,8 +141,9 @@ const AGENT_LABELS: Record<string, string> = {
|
|||
};
|
||||
|
||||
const j = (s: string): string => JSON.stringify(s);
|
||||
const shSingle = (s: string): string => s.replace(/'/g, "'\\''");
|
||||
const psSingle = (s: string): string => s.replace(/'/g, "''");
|
||||
// Inner escaping for a single-quoted argument (POSIX '\'' , PowerShell '').
|
||||
export const shSingle = (s: string): string => s.replace(/'/g, "'\\''");
|
||||
export const psSingle = (s: string): string => s.replace(/'/g, "''");
|
||||
const toolsJson = TOOLS.map(j).join(", ");
|
||||
|
||||
function bodyExtraLines(variant: Variant, indent: string): string[] {
|
||||
|
|
|
|||
|
|
@ -105,13 +105,15 @@ export const SETTINGS_SEARCH_INDEX: Record<SettingsTab, TranslationKey[]> = {
|
|||
"settings.apiKeys.accessTokens",
|
||||
],
|
||||
agents: [
|
||||
// Heading and intro carry the searched terms ("unsloth start", agent names); titles do not.
|
||||
// Every key needs a rendered data-settings-label, or a hit has nothing to scroll to.
|
||||
"settings.agents.title",
|
||||
"settings.agents.description",
|
||||
"settings.agents.intro",
|
||||
"settings.agents.quickstart.title",
|
||||
"settings.agents.supportedAgents.title",
|
||||
"settings.agents.models.title",
|
||||
"settings.agents.agent",
|
||||
"settings.agents.model",
|
||||
"settings.agents.quantization",
|
||||
// subagent.title is deliberately absent: its label only mounts for the agents
|
||||
// that support subagents, so a hit would have nothing to scroll to otherwise.
|
||||
"settings.agents.options.title",
|
||||
"settings.agents.remote.title",
|
||||
"settings.agents.passthrough.title",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -169,7 +169,8 @@ export const en = {
|
|||
},
|
||||
dictionary: {
|
||||
sectionTitle: "Dictation dictionary",
|
||||
sectionDescription: "Set how dictation spells specific words or phrases",
|
||||
sectionDescription:
|
||||
"Set how dictation spells specific words or phrases",
|
||||
manageLabel: "Custom spellings",
|
||||
manage: "Manage",
|
||||
backToVoice: "Back to Voice",
|
||||
|
|
@ -473,7 +474,8 @@ export const en = {
|
|||
"Unsupported file type. Use .woff2, .woff, .ttf, or .otf.",
|
||||
errorTooLarge: "Font file is too large (max 1.5 MB).",
|
||||
errorLimit: "You can import up to 3 fonts.",
|
||||
errorStorageFull: "Not enough local storage for this font. Remove an imported font first.",
|
||||
errorStorageFull:
|
||||
"Not enough local storage for this font. Remove an imported font first.",
|
||||
errorFailed: "Could not load this font file.",
|
||||
},
|
||||
uiFontSize: {
|
||||
|
|
@ -596,16 +598,47 @@ export const en = {
|
|||
},
|
||||
},
|
||||
agents: {
|
||||
title: "Agents (unsloth start)",
|
||||
title: "Agents",
|
||||
description:
|
||||
"Connect coding agents like Claude Code and Codex to a model running locally in Unsloth.",
|
||||
"Connect coding agents like Claude Code and Codex to a model running locally in Unsloth with unsloth start.",
|
||||
intro:
|
||||
"connects Claude Code, Codex, Hermes, OpenClaw, OpenCode, Pi and other agents to a model served locally by Unsloth, fully offline on your own hardware. It runs a OpenAI-compatible server for the agent and never touches your agent's config files.",
|
||||
"connects Claude Code, Codex, Hermes, OpenClaw, OpenCode, Pi and other agents to a model served locally by Unsloth, fully offline on your own hardware. It runs an OpenAI-compatible server for the agent and never touches your agent's config files.",
|
||||
readDocs: "Read the docs",
|
||||
copy: "Copy",
|
||||
copied: "Copied",
|
||||
commandBuilder: "Command builder",
|
||||
agent: "Coding agent",
|
||||
model: "Model",
|
||||
searchModels: "Search GGUF models...",
|
||||
noModels: "No matching GGUF models.",
|
||||
showingModels:
|
||||
"Showing {shown} of {total} matches. Keep typing to narrow the list.",
|
||||
quantization: "Quantization",
|
||||
loadingQuantizations: "Loading quantizations...",
|
||||
noQuantizations: "No separate quantization",
|
||||
recommended: "Recommended",
|
||||
downloaded: "Downloaded",
|
||||
quantizationLoadError:
|
||||
"Couldn't load all quantizations. The command will use the available model value.",
|
||||
generatedCommand: "Generated command",
|
||||
docs: "Docs",
|
||||
agentDocs: "Open {agent} setup docs",
|
||||
copyGeneratedCommand: "Copy generated command",
|
||||
modelNote:
|
||||
"Codex requires a GGUF model served by llama-server. Other agents can also use transformer-backed models; remove --model to use the model already loaded in Unsloth Studio.",
|
||||
subagent: {
|
||||
title: "Use a local model as a subagent",
|
||||
description:
|
||||
"Keep {agent} on its current model and delegate selected tasks to this local Unsloth model.",
|
||||
setupCommand: "Setup command",
|
||||
copySetupCommand: "Copy subagent setup command",
|
||||
usagePrompt: "Then in {agent}, type:",
|
||||
copyUsagePrompt: "Copy subagent usage prompt",
|
||||
defaultPrompt: "Spawn a local agent to implement this function.",
|
||||
opencodePrompt: "@unsloth find the cause of this test failure",
|
||||
},
|
||||
quickstart: {
|
||||
title: "Quickstart",
|
||||
title: "Build a command",
|
||||
description:
|
||||
"Launch an agent against the model currently loaded in Studio. Load a model first, then swap claude for any supported agent below.",
|
||||
noneDetected: "No supported agent CLIs were found on your PATH.",
|
||||
|
|
@ -637,6 +670,8 @@ export const en = {
|
|||
serve: "Enable or disable the automatic local server.",
|
||||
launch: "Launch the agent, or just print the command and environment.",
|
||||
persist: "Keep Unsloth-managed agent storage between runs.",
|
||||
asSubagent:
|
||||
"Keep the parent on its current model and register Unsloth as a local subagent (Claude Code, Codex, OpenCode, and Pi).",
|
||||
apiKey: "Provide your Unsloth API key (or set UNSLOTH_API_KEY).",
|
||||
yolo: "Skip approval prompts. Use only in trusted environments.",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -22,6 +22,20 @@ def apply() -> None:
|
|||
if getattr(torch.cuda, "_unsloth_consolidated_spoof", False):
|
||||
return
|
||||
|
||||
# Settle bitsandbytes against the real torch first. Its __init__ does
|
||||
# `if torch.cuda.is_available(): from .backends.cuda import ops`, and that
|
||||
# module reads torch._C._cuda_getCurrentRawStream at import. On a CPU-only
|
||||
# wheel that attribute is absent, so a bitsandbytes imported AFTER this
|
||||
# spoof raises AttributeError (or OSError hunting libhipblas for the ROCm
|
||||
# spoof) rather than ImportError, which slips past the `except ImportError`
|
||||
# guards its importers use. Importing it here, while is_available() is
|
||||
# still False, caches the CPU path in sys.modules for everything that
|
||||
# follows.
|
||||
try:
|
||||
import bitsandbytes # noqa: F401
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Device probes (cheap, value-returning)
|
||||
torch.cuda.is_available = lambda: True
|
||||
torch.cuda.device_count = lambda: 1
|
||||
|
|
|
|||
244
tests/studio/test_pdf_qa_recipe_contract.py
Normal file
244
tests/studio/test_pdf_qa_recipe_contract.py
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
"""Contracts and opt-in runtime coverage for the PDF grounded QA recipe."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
RECIPE_PATH = (
|
||||
REPO / "studio/frontend/src/features/data-recipes/learning-recipes/pdf-grounded-qa.json"
|
||||
)
|
||||
TRAINING_ACTIONS_PATH = REPO / "studio/frontend/src/features/training/hooks/use-training-actions.ts"
|
||||
SEED_BUILDER_PATH = (
|
||||
REPO / "studio/frontend/src/features/recipe-studio/utils/payload/builders-seed.ts"
|
||||
)
|
||||
RECIPE_IMPORTER_PATH = REPO / "studio/frontend/src/features/recipe-studio/utils/import/importer.ts"
|
||||
SEED_PARSER_PATH = (
|
||||
REPO / "studio/frontend/src/features/recipe-studio/utils/import/parsers/seed-config-parser.ts"
|
||||
)
|
||||
FORMAT_DETECTION_PATH = REPO / "studio/backend/utils/datasets/format_detection.py"
|
||||
|
||||
|
||||
def _load_payload() -> dict:
|
||||
return json.loads(RECIPE_PATH.read_text(encoding = "utf-8"))
|
||||
|
||||
|
||||
def _render_expression(template: str, row: dict) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
value = row
|
||||
for part in match.group(1).strip().split("."):
|
||||
value = value[part]
|
||||
return str(value)
|
||||
|
||||
return re.sub(r"\{\{\s*([^}]+?)\s*\}\}", replace, template)
|
||||
|
||||
|
||||
def test_pdf_qa_recipe_projects_and_cleans_training_columns():
|
||||
recipe = _load_payload()["recipe"]
|
||||
columns = {column["name"]: column for column in recipe["columns"]}
|
||||
|
||||
assert list(columns) == ["llm_structured_1", "instruction", "output", "input"]
|
||||
assert columns["llm_structured_1"]["drop"] is True
|
||||
assert columns["instruction"]["expr"] == "{{ llm_structured_1.question }}"
|
||||
assert columns["output"]["expr"] == "{{ llm_structured_1.answer }}"
|
||||
assert "llm_structured_1.evidence_quote" in columns["input"]["expr"]
|
||||
assert "chunk_text" in columns["input"]["expr"]
|
||||
assert recipe["processors"] == [
|
||||
{
|
||||
"processor_type": "drop_columns",
|
||||
"name": "drop_seed_columns",
|
||||
"column_names": ["chunk_text", "source_file"],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_pdf_qa_recipe_sample_row_is_qlora_ready():
|
||||
recipe = _load_payload()["recipe"]
|
||||
row = {
|
||||
"chunk_text": "Paris is the capital of France.",
|
||||
"source_file": "facts.pdf",
|
||||
"llm_structured_1": {
|
||||
"question": "What is the capital of France?",
|
||||
"answer": "Paris.",
|
||||
"evidence_quote": "Paris is the capital of France.",
|
||||
},
|
||||
}
|
||||
|
||||
for column in recipe["columns"]:
|
||||
if column["column_type"] == "expression":
|
||||
row[column["name"]] = _render_expression(column["expr"], row)
|
||||
for column in recipe["columns"]:
|
||||
if column.get("drop"):
|
||||
row.pop(column["name"], None)
|
||||
for processor in recipe["processors"]:
|
||||
for name in processor["column_names"]:
|
||||
row.pop(name, None)
|
||||
|
||||
assert row == {
|
||||
"instruction": "What is the capital of France?",
|
||||
"output": "Paris.",
|
||||
"input": (
|
||||
"Evidence quote: Paris is the capital of France.\n\n"
|
||||
"Source context: Paris is the capital of France."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def test_pdf_qa_canvas_edges_cover_expression_dependencies():
|
||||
payload = _load_payload()
|
||||
recipe = payload["recipe"]
|
||||
node_ids = {node["id"] for node in payload["ui"]["nodes"]}
|
||||
edges = {(edge["from"], edge["to"]) for edge in payload["ui"]["edges"]}
|
||||
|
||||
assert all(source in node_ids and target in node_ids for source, target in edges)
|
||||
assert ("seed", "llm_structured_1") in edges
|
||||
assert ("llm_structured_1", "instruction") in edges
|
||||
assert ("llm_structured_1", "output") in edges
|
||||
assert ("llm_structured_1", "input") in edges
|
||||
assert ("seed", "input") in edges
|
||||
|
||||
column_names = {column["name"] for column in recipe["columns"]}
|
||||
assert {"instruction", "output"} <= column_names
|
||||
|
||||
|
||||
def test_pdf_qa_fields_match_studio_alpaca_mapping():
|
||||
source = TRAINING_ACTIONS_PATH.read_text(encoding = "utf-8")
|
||||
assert 'alpaca: { user: "instruction", system: "input", assistant: "output" }' in source
|
||||
assert 'if (fmt === "alpaca") return roles.has("instruction") && roles.has("output");' in source
|
||||
|
||||
|
||||
def test_pdf_qa_fields_are_detected_as_alpaca():
|
||||
spec = importlib.util.spec_from_file_location("_pdf_qa_format_detection", FORMAT_DETECTION_PATH)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
detected = module.detect_dataset_format(
|
||||
[{"instruction": "What is the capital?", "input": "source", "output": "Paris."}]
|
||||
)
|
||||
assert detected["format"] == "alpaca"
|
||||
assert detected["needs_standardization"] is False
|
||||
|
||||
|
||||
def test_unstructured_seed_drop_toggle_round_trip_contract():
|
||||
builder = SEED_BUILDER_PATH.read_text(encoding = "utf-8")
|
||||
importer = RECIPE_IMPORTER_PATH.read_text(encoding = "utf-8")
|
||||
parser = SEED_PARSER_PATH.read_text(encoding = "utf-8")
|
||||
|
||||
assert 'if (seedSourceType === "unstructured")' in builder
|
||||
assert "if (!config.drop)" in builder
|
||||
assert "selectedDropColumns.length > 0" in builder
|
||||
assert ': ["chunk_text", "source_file"];' in builder
|
||||
assert "payloadSeedSourceIsUnstructured && payloadSeedDropColumns.length > 0" in importer
|
||||
assert "payloadSeedSourceIsUnstructured" in importer
|
||||
assert '? ["chunk_text", "source_file"]' in importer
|
||||
assert "drop?: boolean;" in parser
|
||||
assert "...(options?.drop !== undefined ? { drop: options.drop } : {})" in parser
|
||||
|
||||
|
||||
class _MockOpenAIHandler(BaseHTTPRequestHandler):
|
||||
requests: list[dict] = []
|
||||
|
||||
def log_message(self, format: str, *args) -> None:
|
||||
return
|
||||
|
||||
def do_POST(self) -> None:
|
||||
raw = self.rfile.read(int(self.headers.get("Content-Length", "0")))
|
||||
self.requests.append(json.loads(raw or b"{}"))
|
||||
structured = {
|
||||
"question": "What is the capital of France?",
|
||||
"answer": "Paris.",
|
||||
"evidence_quote": "Paris is the capital of France.",
|
||||
}
|
||||
body = json.dumps(
|
||||
{
|
||||
"id": "chatcmpl-pdf-qa-test",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "mock-model",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"finish_reason": "stop",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": f"```json\n{json.dumps(structured)}\n```",
|
||||
},
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 20,
|
||||
"total_tokens": 30,
|
||||
},
|
||||
}
|
||||
).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
def test_pdf_qa_recipe_runs_with_pinned_data_designer(tmp_path, monkeypatch):
|
||||
if os.environ.get("UNSLOTH_PDF_QA_MANAGED_INTEGRATION") != "1":
|
||||
pytest.skip("set UNSLOTH_PDF_QA_MANAGED_INTEGRATION=1 to run this integration")
|
||||
|
||||
backend = REPO / "studio/backend"
|
||||
sys.path.insert(0, str(backend))
|
||||
pytest.importorskip("data_designer")
|
||||
pytest.importorskip("data_designer_unstructured_seed")
|
||||
from core.data_recipe import service
|
||||
|
||||
source_path = tmp_path / "facts.txt"
|
||||
source_path.write_text("Paris is the capital of France.", encoding = "utf-8")
|
||||
monkeypatch.setattr(service, "recipe_datasets_root", lambda: tmp_path / "artifacts")
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _MockOpenAIHandler)
|
||||
thread = threading.Thread(target = server.serve_forever, daemon = True)
|
||||
thread.start()
|
||||
try:
|
||||
recipe = copy.deepcopy(_load_payload()["recipe"])
|
||||
recipe["seed_config"]["source"] = {
|
||||
"seed_type": "unstructured",
|
||||
"paths": [str(source_path)],
|
||||
"chunk_size": 1200,
|
||||
"chunk_overlap": 200,
|
||||
}
|
||||
recipe["model_providers"][0].update(
|
||||
{
|
||||
"endpoint": f"http://127.0.0.1:{server.server_port}/v1",
|
||||
"api_key": "test-only",
|
||||
}
|
||||
)
|
||||
recipe["model_configs"][0].update({"model": "mock-model", "skip_health_check": True})
|
||||
dataset, _, _ = service.preview_recipe(recipe, 1)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout = 5)
|
||||
|
||||
assert dataset == [
|
||||
{
|
||||
"instruction": "What is the capital of France?",
|
||||
"output": "Paris.",
|
||||
"input": (
|
||||
"Evidence quote: Paris is the capital of France.\n\n"
|
||||
"Source context: Paris is the capital of France."
|
||||
),
|
||||
}
|
||||
]
|
||||
assert _MockOpenAIHandler.requests
|
||||
|
|
@ -559,11 +559,18 @@ def _subagent_model_id(
|
|||
)
|
||||
if status.get("is_gguf"):
|
||||
variant = status.get("gguf_variant")
|
||||
return (
|
||||
_display_model_spec(model_id, str(variant))
|
||||
if variant and _is_hub_model_id(model_id)
|
||||
else model_id
|
||||
)
|
||||
if variant and _is_hub_model_id(model_id):
|
||||
return _display_model_spec(model_id, str(variant))
|
||||
if variant:
|
||||
# A path load is advertised as a bare basename with no ":variant" channel,
|
||||
# so the quant cannot be recorded and a later reload picks for itself.
|
||||
typer.echo(
|
||||
f"Warning: {model_id} loaded from a path, so the subagent config cannot "
|
||||
f"pin the {variant} quant; a reload may choose a different one. Load the "
|
||||
"model by repository id to pin it.",
|
||||
err = True,
|
||||
)
|
||||
return model_id
|
||||
|
||||
|
||||
def _fail(message: str) -> NoReturn:
|
||||
|
|
@ -572,9 +579,8 @@ def _fail(message: str) -> NoReturn:
|
|||
|
||||
|
||||
def _reject_as_subagent(agent: str, args: list) -> None:
|
||||
# Reject early; otherwise the flag reaches the agent binary and fails after
|
||||
# Studio has already loaded the model.
|
||||
if "--as-subagent" in args:
|
||||
# Reject early, or the flag reaches the agent binary after Studio loaded the model.
|
||||
if any(arg == "--as-subagent" or arg.startswith("--as-subagent=") for arg in args):
|
||||
_fail(f"--as-subagent is not supported for {agent}.")
|
||||
|
||||
|
||||
|
|
@ -1386,6 +1392,37 @@ def _is_hub_model_id(value: object) -> bool:
|
|||
return True
|
||||
|
||||
|
||||
def _is_model_path(value: str) -> bool:
|
||||
"""Mirrors core.inference.model_ids._looks_like_path: a repo id is exactly
|
||||
``org/model``; anything else with a separator, drive, prefix or .gguf is a path.
|
||||
|
||||
Deliberately not named _looks_like_path: that name is taken further down by the
|
||||
WSLENV classifier, which only matches absolute paths and would shadow this one.
|
||||
"""
|
||||
if value.lower().endswith(".gguf"):
|
||||
return True
|
||||
if value.startswith(("/", "\\", "./", "../", ".\\", "..\\", "~")):
|
||||
return True
|
||||
if len(value) >= 2 and value[1] == ":":
|
||||
return True
|
||||
return value.count("/") >= 2 or "\\" in value
|
||||
|
||||
|
||||
def _public_model_id(value: Optional[str]) -> Optional[str]:
|
||||
"""The id Unsloth advertises for a model loaded by path.
|
||||
|
||||
/v1/models never echoes a host path: it reports the file or directory name
|
||||
with any .gguf suffix stripped (core.inference.model_ids.public_model_id), so
|
||||
a path we asked to load has to be matched by that name too.
|
||||
"""
|
||||
if not value or not _is_model_path(value):
|
||||
return None
|
||||
name = os.path.basename(value.replace("\\", "/").rstrip("/"))
|
||||
if name.lower().endswith(".gguf"):
|
||||
name = name[: -len(".gguf")]
|
||||
return name or None
|
||||
|
||||
|
||||
def _model_id_matches(
|
||||
actual: object,
|
||||
requested: object,
|
||||
|
|
@ -1486,7 +1523,7 @@ def _resolve_model(
|
|||
# casing) that /v1/models echoes but which may differ from the path we
|
||||
# passed; match on the id the load reports so we don't silently fall
|
||||
# through to models[0] and connect to a different loaded model.
|
||||
wanted = {requested}
|
||||
wanted = {requested, _public_model_id(requested)} - {None}
|
||||
if isinstance(loaded, dict):
|
||||
wanted |= {loaded.get("model"), loaded.get("display_name")} - {None}
|
||||
models = _loaded_models(base, key)
|
||||
|
|
@ -1954,7 +1991,15 @@ def _opencode_subagent_inline_config(path: Path, permission: dict) -> dict:
|
|||
def merge_provider_filters(effective_config: dict) -> None:
|
||||
enabled = effective_config.get("enabled_providers")
|
||||
if isinstance(enabled, list):
|
||||
inline["enabled_providers"] = list(dict.fromkeys([*enabled, _OPENCODE_PROVIDER]))
|
||||
inherited_enabled = inline.get("enabled_providers")
|
||||
if not isinstance(inherited_enabled, list):
|
||||
inherited_enabled = []
|
||||
providers = [
|
||||
provider
|
||||
for provider in [*inherited_enabled, *enabled]
|
||||
if provider != _OPENCODE_PROVIDER
|
||||
]
|
||||
inline["enabled_providers"] = list(dict.fromkeys([*providers, _OPENCODE_PROVIDER]))
|
||||
disabled = effective_config.get("disabled_providers")
|
||||
if isinstance(disabled, list) and _OPENCODE_PROVIDER in disabled:
|
||||
inline["disabled_providers"] = [
|
||||
|
|
@ -2871,7 +2916,13 @@ def write_pi_config(base: str, key: str, model: dict, path: Path) -> None:
|
|||
typer.echo(f"Updated {path}")
|
||||
|
||||
|
||||
def write_pi_subagent_config(base: str, key: str, model: dict, path: Path) -> None:
|
||||
def write_pi_subagent_config(
|
||||
base: str,
|
||||
key: str,
|
||||
model: dict,
|
||||
path: Path,
|
||||
approve: bool = False,
|
||||
) -> None:
|
||||
"""Write private bootstrap data for the bundled Pi extension."""
|
||||
window = model.get("context_length") or model.get("max_context_length")
|
||||
window = int(window) if window else 32768
|
||||
|
|
@ -2883,6 +2934,7 @@ def write_pi_subagent_config(base: str, key: str, model: dict, path: Path) -> No
|
|||
"model": model["id"],
|
||||
"contextWindow": window,
|
||||
"maxTokens": min(window // 4, 8192),
|
||||
"approve": approve,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -3452,7 +3504,13 @@ def pi(
|
|||
extension = _agent_config_path(_PI_SUBAGENT_EXTENSION, ["pi"])
|
||||
with _session_config("pi-subagent", launch, persist = persist) as config:
|
||||
config_path = config / "subagent.json"
|
||||
write_pi_subagent_config(base, key, subagent_model, config_path)
|
||||
write_pi_subagent_config(
|
||||
base,
|
||||
key,
|
||||
subagent_model,
|
||||
config_path,
|
||||
approve = yolo,
|
||||
)
|
||||
command = [
|
||||
"pi",
|
||||
"--extension",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import { fileURLToPath } from "node:url";
|
|||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
|
||||
const provider = "unsloth";
|
||||
// Distinct from the normal `unsloth` provider: subagent mode preserves the user's Pi config.
|
||||
const provider = "unsloth-studio-subagent";
|
||||
const maxResultCharacters = 100_000;
|
||||
const maxParallelAgents = 4;
|
||||
const cancelGraceMilliseconds = 2_000;
|
||||
|
|
@ -26,6 +27,7 @@ if (configPath) {
|
|||
const model = typeof config.model === "string" ? config.model : "";
|
||||
const baseUrl = typeof config.baseUrl === "string" ? config.baseUrl : "";
|
||||
const apiKey = typeof config.apiKey === "string" ? config.apiKey : "";
|
||||
const approve = config.approve === true;
|
||||
const contextWindow = positiveInt(config.contextWindow, 32768);
|
||||
const maxTokens = positiveInt(config.maxTokens, Math.min(Math.floor(contextWindow / 4), 8192));
|
||||
let activeAgents = 0;
|
||||
|
|
@ -168,6 +170,7 @@ async function runLocalAgent(
|
|||
"json",
|
||||
"--print",
|
||||
"--no-session",
|
||||
...(approve ? ["--approve"] : []),
|
||||
"--provider",
|
||||
provider,
|
||||
"--model",
|
||||
|
|
|
|||
|
|
@ -885,8 +885,9 @@ def test_subagent_model_id_warns_when_status_unavailable(monkeypatch, capsys):
|
|||
|
||||
|
||||
@pytest.mark.parametrize("agent", ["openclaw", "hermes"])
|
||||
def test_unsupported_agents_reject_as_subagent(agent):
|
||||
result = CliRunner().invoke(start.start_app, [agent, "--as-subagent"])
|
||||
@pytest.mark.parametrize("flag", ["--as-subagent", "--as-subagent=true", "--as-subagent=false"])
|
||||
def test_unsupported_agents_reject_as_subagent(agent, flag):
|
||||
result = CliRunner().invoke(start.start_app, [agent, flag])
|
||||
assert result.exit_code == 1
|
||||
assert f"--as-subagent is not supported for {agent}." in result.output
|
||||
|
||||
|
|
@ -1296,6 +1297,66 @@ def test_resolve_model_matches_loaded_canonical_case_after_load(monkeypatch, cap
|
|||
assert "please wait" not in output
|
||||
|
||||
|
||||
def test_resolve_model_matches_snapshot_path_by_public_id(monkeypatch):
|
||||
"""A GGUF loaded by snapshot path is advertised by its basename, not the path."""
|
||||
snapshot = "/home/u/.cache/legacy/models--Org--Model/snapshots/abc123"
|
||||
state = {"loaded": False}
|
||||
|
||||
def http_json(
|
||||
method,
|
||||
url,
|
||||
token,
|
||||
payload = None,
|
||||
timeout = 30,
|
||||
error = None,
|
||||
):
|
||||
if url.endswith("/v1/models"):
|
||||
return {"data": [{"id": "abc123"}] if state["loaded"] else []}
|
||||
if url.endswith("/api/inference/load"):
|
||||
state["loaded"] = True
|
||||
# The load echoes the path it was given, which /v1/models never lists.
|
||||
return {"model": snapshot, "display_name": snapshot}
|
||||
raise AssertionError(f"unexpected request: {method} {url}")
|
||||
|
||||
monkeypatch.setattr(start, "_http_json", http_json)
|
||||
|
||||
entry = start._resolve_model(BASE, "sk-test", snapshot, start.LoadOptions())
|
||||
|
||||
assert entry["id"] == "abc123"
|
||||
|
||||
|
||||
def test_subagent_model_id_warns_when_a_path_load_cannot_pin_the_quant(capsys):
|
||||
"""A path is advertised as a bare basename, so the quant cannot be recorded."""
|
||||
model_id = start._subagent_model_id(BASE, "sk-test", {"id": "abc123"}, None, "UD-Q4_K_XL")
|
||||
|
||||
assert model_id == "abc123"
|
||||
assert "cannot pin the UD-Q4_K_XL quant" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_subagent_model_id_pins_the_quant_for_repo_ids(capsys):
|
||||
model_id = start._subagent_model_id(
|
||||
BASE, "sk-test", {"id": "unsloth/gemma-4-E4B-it-GGUF"}, None, "UD-Q4_K_XL"
|
||||
)
|
||||
|
||||
assert model_id == "unsloth/gemma-4-E4B-it-GGUF:UD-Q4_K_XL"
|
||||
assert capsys.readouterr().err == ""
|
||||
|
||||
|
||||
def test_public_model_id_leaves_repo_ids_alone():
|
||||
"""Only a path gets reduced; a repo id must not match some unrelated model.
|
||||
|
||||
Relative and multi-segment paths are covered too: _looks_like_path is defined
|
||||
twice in this module (the WSLENV one wins), so this must use its own classifier.
|
||||
"""
|
||||
assert start._public_model_id("unsloth/gemma-4-E4B-it-GGUF") is None
|
||||
assert start._public_model_id("org/model") is None
|
||||
assert start._public_model_id("/srv/models/Qwen3-Q4_K_M.gguf") == "Qwen3-Q4_K_M"
|
||||
assert start._public_model_id("/a/b/snapshots/rev1") == "rev1"
|
||||
assert start._public_model_id("./models/foo") == "foo"
|
||||
assert start._public_model_id("cache/snapshots/rev") == "rev"
|
||||
assert start._public_model_id("a/b/c") == "c"
|
||||
|
||||
|
||||
def test_resolve_model_loads_when_catalog_hit_is_not_loaded(monkeypatch):
|
||||
# A cached-but-unloaded catalog entry (loaded == False) that only case-differs must
|
||||
# not be treated as ready; the load endpoint must still be called so the requested
|
||||
|
|
@ -3296,9 +3357,13 @@ def test_write_opencode_config_as_subagent_preserves_parent_model(tmp_path):
|
|||
|
||||
def test_opencode_subagent_inline_keeps_parent_provider_filters(monkeypatch, tmp_path):
|
||||
config_path = tmp_path / "opencode.json"
|
||||
inherited = {"theme": "tokyonight"}
|
||||
inherited = {
|
||||
"theme": "tokyonight",
|
||||
"enabled_providers": ["anthropic"],
|
||||
}
|
||||
monkeypatch.setenv("OPENCODE_CONFIG_CONTENT", json.dumps(inherited))
|
||||
monkeypatch.setattr(start, "_which_with_install_dirs", lambda _: "/usr/bin/opencode")
|
||||
monkeypatch.setattr(start, "_wsl_windows_executable", lambda _: None)
|
||||
captured = {}
|
||||
|
||||
def run(command, **kwargs):
|
||||
|
|
@ -3324,7 +3389,11 @@ def test_opencode_subagent_inline_keeps_parent_provider_filters(monkeypatch, tmp
|
|||
assert captured["env"]["OPENCODE_CONFIG"] == str(config_path)
|
||||
assert inline == {
|
||||
"theme": "tokyonight",
|
||||
"enabled_providers": ["opencode-go", start._OPENCODE_PROVIDER],
|
||||
"enabled_providers": [
|
||||
"anthropic",
|
||||
"opencode-go",
|
||||
start._OPENCODE_PROVIDER,
|
||||
],
|
||||
"disabled_providers": ["ollama"],
|
||||
"subagent_depth": 1,
|
||||
"permission": permission,
|
||||
|
|
@ -3721,21 +3790,26 @@ def test_connect_pi_no_launch(fake_studio, tmp_path):
|
|||
assert not any(c[1].endswith("/api/inference/status") for c in fake_studio)
|
||||
|
||||
|
||||
def test_connect_pi_as_subagent_preserves_cloud_parent(fake_studio, tmp_path):
|
||||
@pytest.mark.parametrize("yolo", [False, True])
|
||||
def test_connect_pi_as_subagent_preserves_cloud_parent(fake_studio, tmp_path, yolo):
|
||||
args = [
|
||||
"pi",
|
||||
"--as-subagent",
|
||||
"--no-launch",
|
||||
"--model",
|
||||
MODEL["id"] + ":UD-Q4_K_XL",
|
||||
]
|
||||
if yolo:
|
||||
args.insert(2, "--yolo")
|
||||
result = CliRunner().invoke(
|
||||
start.start_app,
|
||||
[
|
||||
"pi",
|
||||
"--as-subagent",
|
||||
"--no-launch",
|
||||
"--model",
|
||||
MODEL["id"] + ":UD-Q4_K_XL",
|
||||
],
|
||||
args,
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
command = _launch_command(result.output)
|
||||
assert command[:2] == ["pi", "--extension"]
|
||||
assert command[2].endswith("unsloth_cli/pi_subagent.ts")
|
||||
assert ("--approve" in command) is yolo
|
||||
assert "--provider" not in command
|
||||
assert "--model" not in command
|
||||
assert "PI_CODING_AGENT_DIR" not in result.output
|
||||
|
|
@ -3750,6 +3824,7 @@ def test_connect_pi_as_subagent_preserves_cloud_parent(fake_studio, tmp_path):
|
|||
"model": MODEL["id"] + ":UD-Q4_K_XL",
|
||||
"contextWindow": 4096,
|
||||
"maxTokens": 1024,
|
||||
"approve": yolo,
|
||||
}
|
||||
assert "Ask Pi to spawn an Unsloth or local agent." in result.output
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue