* Fix Windows Codex temporary home path * Fix Codex ephemeral session cleanup * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Harden Codex temp home reclamation --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
3864 lines
162 KiB
Python
3864 lines
162 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""`unsloth start` — launch a coding agent against a running Unsloth server."""
|
|
|
|
import atexit
|
|
import base64
|
|
import contextlib
|
|
import errno
|
|
import json
|
|
import os
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Literal, NamedTuple, NoReturn, Optional
|
|
from urllib.parse import urlencode, urlparse
|
|
|
|
import click
|
|
import typer
|
|
from typer.core import TyperCommand
|
|
|
|
from unsloth_cli._inference import (
|
|
_USER_AGENT,
|
|
_studio_token,
|
|
ensure_studio_backend_path,
|
|
find_studio_server,
|
|
is_loopback_url,
|
|
urlopen_no_redirect,
|
|
verify_studio_identity,
|
|
)
|
|
|
|
start_app = typer.Typer(
|
|
help = "Start a coding agent against a running Unsloth server.",
|
|
no_args_is_help = True,
|
|
context_settings = {"help_option_names": ["-h", "--help"]},
|
|
)
|
|
|
|
_CODEX_PROFILE = "unsloth_api"
|
|
_CODEX_ENV_KEY = "UNSLOTH_STUDIO_AUTH_TOKEN"
|
|
_HERMES_ENV_KEY = "UNSLOTH_API_KEY"
|
|
_HERMES_PROVIDER = "unsloth"
|
|
# Skip the installer's interactive setup wizard: `unsloth start hermes` runs
|
|
# this hint unattended and then writes its own session-scoped Hermes config, so
|
|
# the wizard's global API-key/model prompts would block the launch and point the
|
|
# user at a different (global) provider than the one Unsloth just configured.
|
|
# Both installers expose a skip flag: `-SkipSetup` (PowerShell) and
|
|
# `--skip-setup` (POSIX; passed to the piped script via `bash -s --`). Pin both
|
|
# the fetched script and the repository checkout it performs to the same full
|
|
# commit so a later change to either upstream branch cannot silently replace
|
|
# code that Unsloth executes with the user's privileges.
|
|
_HERMES_INSTALL_COMMIT = "f1af945f6c576eccb126fa955edc9be258b33020"
|
|
_HERMES_INSTALL_BASE = (
|
|
"https://raw.githubusercontent.com/NousResearch/hermes-agent/"
|
|
f"{_HERMES_INSTALL_COMMIT}/scripts"
|
|
)
|
|
_HERMES_WINDOWS_INSTALL_HINT = (
|
|
f"& ([scriptblock]::Create((irm {_HERMES_INSTALL_BASE}/install.ps1)))"
|
|
f" -SkipSetup -Commit {_HERMES_INSTALL_COMMIT}"
|
|
)
|
|
_HERMES_POSIX_INSTALL_HINT = (
|
|
f"curl -fsSL {_HERMES_INSTALL_BASE}/install.sh | bash -s --"
|
|
f" --skip-setup --commit {_HERMES_INSTALL_COMMIT}"
|
|
)
|
|
# Hermes refuses to initialize when the model window is under 64,000 tokens; its
|
|
# error message points at the model.context_length / auxiliary.compression
|
|
# overrides in config.yaml. write_hermes_config claims this value for smaller
|
|
# windows and scales the compaction threshold back down to the real window.
|
|
_HERMES_MIN_CONTEXT = 65536
|
|
_PI_PROVIDER = "unsloth"
|
|
_SUBAGENT_NAME = "unsloth"
|
|
_SUBAGENT_DESCRIPTION = (
|
|
"Local coding subagent powered by Unsloth for debugging, implementation, and codebase "
|
|
"research. Use when the user asks to spawn an Unsloth or local agent."
|
|
)
|
|
_SUBAGENT_INSTRUCTIONS = (
|
|
"You are a local coding subagent powered by Unsloth. Complete the assigned task directly, "
|
|
"use the available tools when useful, verify your work, and return a concise result to the "
|
|
"parent agent."
|
|
)
|
|
_SUBAGENT_PLAN_DESCRIPTION = (
|
|
"Read-only local coding subagent powered by Unsloth for planning and codebase research. "
|
|
"Use this local agent when Claude is in plan mode."
|
|
)
|
|
_SUBAGENT_PLAN_INSTRUCTIONS = (
|
|
"You are a read-only local coding subagent powered by Unsloth. Investigate the assigned "
|
|
"task with read-only tools, produce a concrete plan or answer, and return a concise result "
|
|
"to the parent agent. Do not modify files."
|
|
)
|
|
_CLAUDE_SUBAGENT_MCP_MODULE = "unsloth_cli.claude_subagent_mcp"
|
|
_CLAUDE_SUBAGENT_TOOL = "mcp__plugin_unsloth-local-agent_unsloth__unsloth_agent"
|
|
_CLAUDE_SUBAGENT_PLAN_TOOL = "mcp__plugin_unsloth-local-agent_unsloth__unsloth_plan_agent"
|
|
_CODEX_SUBAGENT_MCP_MODULE = "unsloth_cli.codex_subagent_mcp"
|
|
_CODEX_SUBAGENT_MCP_SERVER = "unsloth_local_agent"
|
|
_CODEX_SUBAGENT_MCP_TOOL = "spawn_local_agent"
|
|
_CODEX_SUBAGENT_CONFIG_ENV = "UNSLOTH_CODEX_SUBAGENT_CONFIG"
|
|
_CODEX_PARENT_OVERLAY_MANIFEST = ".unsloth-parent-overlay.json"
|
|
_CODEX_EPHEMERAL_STALE_SECONDS = 24 * 60 * 60
|
|
_CODEX_EPHEMERAL_HEARTBEAT_SECONDS = 60
|
|
_CODEX_SUBAGENT_TOOL_DESCRIPTION = (
|
|
f"{_SUBAGENT_DESCRIPTION} Use this tool instead of the built-in spawn_agent tool for those "
|
|
"requests. Other subagent requests may use the built-in tools normally."
|
|
)
|
|
_CODEX_SUBAGENT_ROUTING_INSTRUCTIONS = (
|
|
"When the user asks to spawn an Unsloth agent or local agent, you must call the "
|
|
"spawn_local_agent MCP tool once with the complete task. Do not answer, simulate the "
|
|
"result, call wait, or use a built-in subagent before calling the tool. Use built-in "
|
|
"subagents for other delegation requests."
|
|
)
|
|
_PI_SUBAGENT_EXTENSION = Path(__file__).parent.parent / "pi_subagent.ts"
|
|
# OpenCode selects a model by "<providerID>/<modelID>". Use a dedicated id to avoid
|
|
# colliding with a user's providers; provider filters are set in the launch-time overlay.
|
|
_OPENCODE_PROVIDER = "unsloth-studio"
|
|
_PROVIDER_HEADER = f"[model_providers.{_CODEX_PROFILE}]"
|
|
_PASSTHROUGH = {"allow_extra_args": True, "ignore_unknown_options": True}
|
|
|
|
|
|
class _PassthroughCommand(TyperCommand):
|
|
"""Preserve the option separator when forwarding arguments to an agent."""
|
|
|
|
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
|
raw_args = list(args)
|
|
try:
|
|
separator = raw_args.index("--")
|
|
except ValueError:
|
|
return super().parse_args(ctx, args)
|
|
trailing_count = len(raw_args) - separator - 1
|
|
remaining = super().parse_args(ctx, args)
|
|
insert_at = max(0, len(remaining) - trailing_count)
|
|
if insert_at >= len(remaining) or remaining[insert_at] != "--":
|
|
remaining.insert(insert_at, "--")
|
|
ctx.args = remaining
|
|
return remaining
|
|
|
|
|
|
_CLAUDE_ENV_UNSET = ("ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN")
|
|
_CODEX_ENV_UNSET = ("OPENAI_API_KEY", "CODEX_API_KEY", "CODEX_ACCESS_TOKEN")
|
|
|
|
# Shared by every agent command; only the config/env/command differ.
|
|
# Help is grouped into rich panels so `--help` reads as Model / Server / Session
|
|
# instead of one long unaligned list.
|
|
_PANEL_MODEL = "Model"
|
|
_PANEL_SERVER = "Server"
|
|
_PANEL_SAMPLING = "Sampling"
|
|
_PANEL_SESSION = "Agent session"
|
|
|
|
_MODEL_OPTION = typer.Option(
|
|
None,
|
|
"--model",
|
|
"-m",
|
|
rich_help_panel = _PANEL_MODEL,
|
|
help = "Model for the agent, or a bare `org/name(:variant)` positional. "
|
|
"Defaults to the one loaded in Unsloth.",
|
|
)
|
|
_GGUF_VARIANT_OPTION = typer.Option(
|
|
None,
|
|
"--gguf-variant",
|
|
rich_help_panel = _PANEL_MODEL,
|
|
help = "GGUF quant variant to load (e.g. UD-Q4_K_XL). Defaults to UD-Q4_K_XL for "
|
|
"unsloth/* GGUF repos, else Q4_K_M.",
|
|
)
|
|
_CONTEXT_OPTION = typer.Option(
|
|
0,
|
|
"--max-seq-length",
|
|
"--context-length",
|
|
rich_help_panel = _PANEL_MODEL,
|
|
help = "Context length in tokens for the load (0 = model default).",
|
|
)
|
|
_LOAD_4BIT_OPTION = typer.Option(
|
|
True,
|
|
"--load-in-4bit/--no-load-in-4bit",
|
|
rich_help_panel = _PANEL_MODEL,
|
|
help = "Load hub models in 4-bit (ignored for GGUF).",
|
|
)
|
|
_TENSOR_PARALLEL_OPTION = typer.Option(
|
|
False,
|
|
"--tensor-parallel/--no-tensor-parallel",
|
|
rich_help_panel = _PANEL_MODEL,
|
|
help = "Split a GGUF across GPUs by tensor instead of by layer (multi-GPU only).",
|
|
)
|
|
_GPU_MEMORY_MODE_OPTION = typer.Option(
|
|
None,
|
|
"--gpu-memory-mode",
|
|
rich_help_panel = _PANEL_MODEL,
|
|
help = (
|
|
"GPU memory strategy for GGUF models loaded by this command. Auto lets "
|
|
"Unsloth manage placement. Manual with default layers and context delegates "
|
|
"placement and sizing to llama.cpp --fit. Omit when attaching to preserve "
|
|
"the running model's mode."
|
|
),
|
|
)
|
|
|
|
# Server knobs. Only used when `unsloth start` auto-starts the server (--serve);
|
|
# they have no effect when attaching to a server someone else already started.
|
|
_SERVE_OPTION = typer.Option(
|
|
True,
|
|
"--serve/--no-serve",
|
|
rich_help_panel = _PANEL_SERVER,
|
|
help = "If no Unsloth server is running, auto-start one for --model and keep it "
|
|
"available after the agent exits. --no-serve errors out instead.",
|
|
)
|
|
_ENABLE_TOOLS_OPTION = typer.Option(
|
|
False,
|
|
"--enable-tools/--disable-tools",
|
|
rich_help_panel = _PANEL_SERVER,
|
|
help = "Server-side tools (web search, code execution) for the auto-started server. "
|
|
"Default off so the agent's own tools are relayed unchanged.",
|
|
)
|
|
_TOOL_CALL_HEALING_OPTION = typer.Option(
|
|
None,
|
|
"--enable-tool-call-healing/--disable-tool-call-healing",
|
|
rich_help_panel = _PANEL_SERVER,
|
|
help = "Promote text-form tool calls from small GGUFs back into structured calls. On by "
|
|
"default; when the flag is omitted an inherited UNSLOTH_DISABLE_TOOL_CALL_HEALING is kept.",
|
|
)
|
|
_TOOL_CALL_NUDGING_OPTION = typer.Option(
|
|
None,
|
|
"--enable-tool-call-nudging/--disable-tool-call-nudging",
|
|
rich_help_panel = _PANEL_SERVER,
|
|
help = "Retry once with a nudge when a non-streaming passthrough tool call can't be healed. "
|
|
"On by default; when the flag is omitted an inherited UNSLOTH_TOOL_CALL_NUDGE is kept.",
|
|
)
|
|
_REASONING_OPTION = typer.Option(
|
|
None,
|
|
"--reasoning",
|
|
rich_help_panel = _PANEL_SERVER,
|
|
help = (
|
|
"llama-server reasoning mode for an auto-started coding-agent server. "
|
|
"Defaults to off so tool calls stay in the structured tool channel; use "
|
|
"'auto' or 'on' to opt back into model reasoning."
|
|
),
|
|
)
|
|
# Sampling overrides pin a value on the auto-started server (winning over the client and the
|
|
# per-model recommendation). Default unset -> the model's recommended sampling is used.
|
|
_TEMPERATURE_OPTION = typer.Option(
|
|
None,
|
|
"--temperature",
|
|
min = 0.0,
|
|
max = 2.0,
|
|
rich_help_panel = _PANEL_SAMPLING,
|
|
help = "Pin the sampling temperature. Default: unset (per-model recommendation).",
|
|
)
|
|
_TOP_P_OPTION = typer.Option(
|
|
None,
|
|
"--top-p",
|
|
min = 0.0,
|
|
max = 1.0,
|
|
rich_help_panel = _PANEL_SAMPLING,
|
|
help = "Pin top-p (nucleus) sampling. Default: unset (per-model recommendation).",
|
|
)
|
|
_TOP_K_OPTION = typer.Option(
|
|
None,
|
|
"--top-k",
|
|
min = -1,
|
|
max = 100,
|
|
rich_help_panel = _PANEL_SAMPLING,
|
|
help = "Pin top-k sampling. Default: unset (per-model recommendation).",
|
|
)
|
|
_MIN_P_OPTION = typer.Option(
|
|
None,
|
|
"--min-p",
|
|
min = 0.0,
|
|
max = 1.0,
|
|
rich_help_panel = _PANEL_SAMPLING,
|
|
help = "Pin min-p sampling threshold. Default: unset (per-model recommendation).",
|
|
)
|
|
_REPETITION_PENALTY_OPTION = typer.Option(
|
|
None,
|
|
"--repetition-penalty",
|
|
min = 1.0,
|
|
max = 2.0,
|
|
rich_help_panel = _PANEL_SAMPLING,
|
|
help = "Pin the repetition penalty. Default: unset (per-model recommendation).",
|
|
)
|
|
_PRESENCE_PENALTY_OPTION = typer.Option(
|
|
None,
|
|
"--presence-penalty",
|
|
min = 0.0,
|
|
max = 2.0,
|
|
rich_help_panel = _PANEL_SAMPLING,
|
|
help = "Pin the presence penalty. Default: unset (per-model recommendation).",
|
|
)
|
|
|
|
# Agent-session knobs.
|
|
_KEY_OPTION = typer.Option(
|
|
None,
|
|
"--api-key",
|
|
envvar = "UNSLOTH_API_KEY",
|
|
rich_help_panel = _PANEL_SESSION,
|
|
help = "Unsloth API key. For a local Unsloth it is minted automatically and "
|
|
"remembered per server. For a remote server, pass one with --api-key "
|
|
"(or UNSLOTH_API_KEY); it is remembered for next time.",
|
|
)
|
|
_LAUNCH_OPTION = typer.Option(
|
|
True,
|
|
"--launch/--no-launch",
|
|
rich_help_panel = _PANEL_SESSION,
|
|
help = "--no-launch prints the env and command instead (remote shells, WSL).",
|
|
)
|
|
# One normalized "run tools without prompting" switch. Each agent spells this
|
|
# differently and it's easy to forget which is which, so accept every spelling and
|
|
# route to the agent's own mechanism in _yolo_command_flags / the config writers.
|
|
_YOLO_OPTION = typer.Option(
|
|
False,
|
|
"--yolo",
|
|
"--dangerously-skip-permissions",
|
|
"--dangerously-bypass-approvals-and-sandbox",
|
|
rich_help_panel = _PANEL_SESSION,
|
|
help = "Auto-approve all tool actions for this session; routed to the agent's own "
|
|
"flag/config. Any of the three spellings works for any agent.",
|
|
)
|
|
_PERSIST_OPTION = typer.Option(
|
|
False,
|
|
"--persist/--no-persist",
|
|
rich_help_panel = _PANEL_SESSION,
|
|
help = (
|
|
"Keep this agent's Unsloth-managed session dir so you can resume it later. "
|
|
"codex/openclaw/hermes/pi have their whole home relocated into an Unsloth dir "
|
|
"that is a throwaway temp dir (wiped on exit) by default; with --persist it "
|
|
"lives under the Unsloth agents dir and survives, so their own resume can reopen "
|
|
"it. claude and opencode keep sessions in your own stores (~/.claude, "
|
|
"~/.local/share/opencode), so they already resume regardless. To reopen a "
|
|
"session, pass the agent's own resume command through, e.g. "
|
|
"`unsloth start codex --persist resume` or `claude --resume <id>`; those flow to "
|
|
"the agent unchanged."
|
|
),
|
|
)
|
|
_AS_SUBAGENT_OPTION = typer.Option(
|
|
False,
|
|
"--as-subagent",
|
|
rich_help_panel = _PANEL_SESSION,
|
|
help = "Keep the coding agent's current model and add Unsloth as a local subagent.",
|
|
)
|
|
|
|
# Per-agent CLI flag for "run tools without prompting". OpenCode (native --auto is
|
|
# command-scoped, handled below) and OpenClaw (config-only) are absent from this prefix map.
|
|
_YOLO_COMMAND_FLAGS = {
|
|
"claude": ["--dangerously-skip-permissions"],
|
|
"codex": ["--dangerously-bypass-approvals-and-sandbox"],
|
|
"hermes": ["--yolo"],
|
|
# Pi never prompts per tool call; its only approval gate is project trust, so -a
|
|
# (trust project resources) is the closest "don't ask me" equivalent.
|
|
"pi": ["--approve"],
|
|
}
|
|
|
|
|
|
def _yolo_command_flags(agent: str, yolo: bool) -> list:
|
|
# .get so a config-based agent (or a typo) yields no flag instead of a KeyError.
|
|
return _YOLO_COMMAND_FLAGS.get(agent, []) if yolo else []
|
|
|
|
|
|
# Subcommands that reject --auto (OpenCode exposes it only on the default TUI and `run`),
|
|
# so `opencode serve --auto` is never emitted. Includes console/generate, hidden from
|
|
# `opencode --help` but still registered. Unknown first positionals are TUI paths -> --auto.
|
|
_OPENCODE_NON_AUTO_SUBCOMMANDS = frozenset(
|
|
"completion acp mcp attach debug providers auth agent upgrade uninstall serve web "
|
|
"models stats export import github pr session plugin plug db console generate".split()
|
|
)
|
|
_OPENCODE_GLOBAL_BOOLEAN_OPTIONS = frozenset(
|
|
"-h --help -v --version --print-logs --pure --mdns".split()
|
|
)
|
|
_OPENCODE_GLOBAL_VALUE_OPTIONS = frozenset(
|
|
"--log-level --port --hostname --mdns-domain --cors".split()
|
|
)
|
|
_OPENCODE_NATIVE_AUTO_MIN_VERSION = (1, 17, 12)
|
|
|
|
|
|
def _opencode_supports_native_auto() -> bool:
|
|
executable = _which_with_install_dirs("opencode")
|
|
if executable is None:
|
|
# No local binary: a --no-launch recipe may run elsewhere, and _run installs the
|
|
# current release on launch -- either way assume native --auto is available.
|
|
return True
|
|
try:
|
|
output = subprocess.check_output(
|
|
[executable, "--version"],
|
|
text = True,
|
|
timeout = 10,
|
|
stderr = subprocess.DEVNULL,
|
|
)
|
|
except Exception:
|
|
return False
|
|
match = re.search(r"(\d+)\.(\d+)\.(\d+)", output)
|
|
return bool(match) and tuple(int(part) for part in match.groups()) >= (
|
|
_OPENCODE_NATIVE_AUTO_MIN_VERSION
|
|
)
|
|
|
|
|
|
def _opencode_subcommand(args: list[str]) -> Optional[str]:
|
|
"""Return an explicit OpenCode subcommand after supported global options."""
|
|
index = 0
|
|
while index < len(args):
|
|
arg = args[index]
|
|
if arg == "--":
|
|
return None
|
|
if arg in _OPENCODE_GLOBAL_BOOLEAN_OPTIONS:
|
|
index += 1
|
|
continue
|
|
if arg in _OPENCODE_GLOBAL_VALUE_OPTIONS:
|
|
index += 2
|
|
continue
|
|
if any(arg.startswith(f"{option}=") for option in _OPENCODE_GLOBAL_VALUE_OPTIONS):
|
|
index += 1
|
|
continue
|
|
# A non-global option (e.g. --session) is a TUI flag; stop before its value is
|
|
# mistaken for a subcommand.
|
|
if arg.startswith("-"):
|
|
return None
|
|
return arg
|
|
return None
|
|
|
|
|
|
def _opencode_native_auto_args(args: list[str], yolo: bool) -> tuple[list[str], bool]:
|
|
"""Add OpenCode's native --auto when the selected command supports it."""
|
|
routed = list(args)
|
|
if not yolo:
|
|
return routed, False
|
|
if _opencode_subcommand(routed) in _OPENCODE_NON_AUTO_SUBCOMMANDS:
|
|
return routed, False
|
|
separator = routed.index("--") if "--" in routed else len(routed)
|
|
# --mini's runMini TUI forces auto=false and never forwards --auto, so appending it is
|
|
# useless; fall back to the config permission block so --yolo still auto-approves.
|
|
if any(arg == "--mini" or arg.startswith("--mini=") for arg in routed[:separator]):
|
|
return routed, False
|
|
if "--auto" not in routed[:separator]:
|
|
routed.insert(separator, "--auto")
|
|
return routed, True
|
|
|
|
|
|
def _hermes_install_hint() -> str:
|
|
return _HERMES_WINDOWS_INSTALL_HINT if os.name == "nt" else _HERMES_POSIX_INSTALL_HINT
|
|
|
|
|
|
def _hermes_resume_oneshot_args(args: list[str]) -> list[str]:
|
|
"""Route resumed one-shot prompts through Hermes' session-aware chat command."""
|
|
has_resume = any(
|
|
arg in ("--resume", "-r", "--continue", "-c")
|
|
or arg.startswith(("--resume=", "--continue="))
|
|
or (len(arg) > 2 and arg.startswith(("-r", "-c")))
|
|
for arg in args
|
|
)
|
|
if not has_resume:
|
|
return args
|
|
|
|
rewritten = list(args)
|
|
for index, arg in enumerate(rewritten):
|
|
if arg in ("-z", "--oneshot"):
|
|
rewritten[index] = "-q"
|
|
elif len(arg) > 2 and arg.startswith("-z"):
|
|
# argparse accepts attached short-option values (`-zPROMPT` and
|
|
# `-z=PROMPT`); preserve the value byte-for-byte when switching to -q.
|
|
rewritten[index] = f"-q{arg[2:]}"
|
|
elif arg.startswith("--oneshot="):
|
|
rewritten[index] = f"--query={arg.partition('=')[2]}"
|
|
else:
|
|
continue
|
|
if any(item == "--usage-file" or item.startswith("--usage-file=") for item in args):
|
|
raise typer.BadParameter(
|
|
"Hermes cannot resume a one-shot session with --usage-file; remove that option."
|
|
)
|
|
prefix = ["chat", "-Q"]
|
|
if "--yolo" not in rewritten:
|
|
prefix.append("--yolo")
|
|
if "--accept-hooks" not in rewritten:
|
|
prefix.append("--accept-hooks")
|
|
rewritten = prefix + rewritten
|
|
return rewritten
|
|
return args
|
|
|
|
|
|
class LoadOptions(NamedTuple):
|
|
"""Model-load knobs forwarded to /api/inference/load when --model triggers a load."""
|
|
|
|
gguf_variant: Optional[str] = None
|
|
max_seq_length: int = 0
|
|
load_in_4bit: bool = True
|
|
tensor_parallel: bool = False
|
|
gpu_memory_mode: Optional[Literal["auto", "manual"]] = None
|
|
|
|
|
|
class ServerOptions(NamedTuple):
|
|
"""Tool-call knobs forwarded to an auto-started `unsloth run` server."""
|
|
|
|
enable_tools: bool = False
|
|
tool_call_healing: Optional[bool] = None
|
|
tool_call_nudging: Optional[bool] = None
|
|
reasoning: Optional[Literal["on", "off", "auto"]] = None
|
|
temperature: Optional[float] = None
|
|
top_p: Optional[float] = None
|
|
top_k: Optional[int] = None
|
|
min_p: Optional[float] = None
|
|
repetition_penalty: Optional[float] = None
|
|
presence_penalty: Optional[float] = None
|
|
|
|
|
|
def _split_repo_variant(model: str) -> tuple:
|
|
"""Split ``org/name:QUANT`` into ``(repo, variant)`` -> ``("org/name", "QUANT")``.
|
|
|
|
``unsloth run`` and llama.cpp accept ``--model org/name:QUANT`` as shorthand for
|
|
``--model org/name --gguf-variant QUANT``. Mirror that here so a ``:variant`` suffix
|
|
resolves against the already-loaded ``org/name`` (which /v1/models lists without the
|
|
suffix) instead of trying to load a repo id containing ``:`` -- which Hugging Face
|
|
rejects, and which would evict a model another session is using. Local paths, Windows
|
|
drive letters, and ids without a ``:`` pass through unchanged.
|
|
"""
|
|
s = (model or "").strip()
|
|
if not s or s.startswith(("/", "./", "../", "~")) or s == ".":
|
|
return s, None
|
|
if len(s) >= 2 and s[1] == ":" and s[0].isalpha(): # Windows drive, e.g. C:\models\x
|
|
return s, None
|
|
if ":" not in s:
|
|
return s, None
|
|
repo, _, variant = s.rpartition(":")
|
|
if not repo or not variant or "/" in variant:
|
|
return s, None
|
|
return repo, variant
|
|
|
|
|
|
def _looks_like_model(token: str) -> bool:
|
|
"""True for a bare `org/name(:variant)` hub id that is not a flag or a local path.
|
|
|
|
Reuses `_is_hub_model_id`, so a relative dir like `owner/repo` that actually exists
|
|
is left for the agent (e.g. OpenCode opens it as a project) instead of being taken
|
|
as a model; a non-existent `org/name` is treated as a hub id.
|
|
"""
|
|
if not token or token.startswith("-") or " " in token:
|
|
return False
|
|
repo, _ = _split_repo_variant(token)
|
|
return _is_hub_model_id(repo)
|
|
|
|
|
|
def _consume_positional_model(model: Optional[str], args: list) -> tuple:
|
|
"""Route a leading `org/name` positional to --model when --model was not given.
|
|
|
|
Only the FIRST token is considered so an option value like `--profile owner/repo`
|
|
is never stolen, and only when --model is absent so an explicit --model always wins.
|
|
Returns (model, remaining_args) with the consumed token removed from the passthrough.
|
|
"""
|
|
args = list(args)
|
|
if model or not args or not _looks_like_model(args[0]):
|
|
return model, args
|
|
return args[0], args[1:]
|
|
|
|
|
|
def _display_model_spec(model: str, variant: Optional[str]) -> str:
|
|
"""Return a user-facing model name that includes the selected GGUF variant."""
|
|
repo, inline_variant = _split_repo_variant(model)
|
|
selected_variant = variant or inline_variant
|
|
return f"{repo}:{selected_variant}" if selected_variant else model
|
|
|
|
|
|
def _subagent_model_id(
|
|
base: str,
|
|
key: str,
|
|
entry: dict,
|
|
requested_model: Optional[str],
|
|
requested_variant: Optional[str],
|
|
) -> str:
|
|
"""Return an API model id that preserves the selected GGUF variant.
|
|
|
|
Coding-agent model definitions outlive the initial load. If Unsloth later
|
|
unloads the model, a bare repository id may resolve to a different cached
|
|
quant. Include the explicit or currently loaded variant so an automatic
|
|
reload selects the same weights.
|
|
"""
|
|
model_id = str(entry["id"])
|
|
_, inline_variant = _split_repo_variant(requested_model or "")
|
|
variant = requested_variant or inline_variant
|
|
if not variant:
|
|
try:
|
|
status = _http_json("GET", f"{base}/api/inference/status", key)
|
|
except Exception:
|
|
status = {}
|
|
typer.echo(
|
|
"Warning: could not verify the loaded GGUF variant; a later reload "
|
|
"may pick a different cached quant. Pass :variant to pin it.",
|
|
err = True,
|
|
)
|
|
if status.get("is_gguf"):
|
|
variant = status.get("gguf_variant")
|
|
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:
|
|
typer.echo(message, err = True)
|
|
raise typer.Exit(code = 1)
|
|
|
|
|
|
def _reject_as_subagent(agent: str, args: list) -> None:
|
|
# 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}.")
|
|
|
|
|
|
def _http_error_detail(exc: urllib.error.HTTPError) -> str:
|
|
try:
|
|
body = json.loads(exc.read().decode())
|
|
return body.get("detail") or body["error"]["message"]
|
|
except Exception:
|
|
return str(exc)
|
|
|
|
|
|
def _http_json(
|
|
method: str,
|
|
url: str,
|
|
token: str,
|
|
payload = None,
|
|
timeout = 30,
|
|
error = None,
|
|
):
|
|
"""On HTTPError: raise if `error` is None, else fail with `error` plus the server's detail."""
|
|
request = urllib.request.Request(
|
|
url,
|
|
data = None if payload is None else json.dumps(payload).encode(),
|
|
headers = {
|
|
"Authorization": f"Bearer {token}",
|
|
"Content-Type": "application/json",
|
|
"User-Agent": _USER_AGENT,
|
|
},
|
|
method = method,
|
|
)
|
|
try:
|
|
# No redirects: a 3xx would leak this bearer token to an unvetted base.
|
|
with urlopen_no_redirect(request, timeout = timeout) as response:
|
|
return json.loads(response.read().decode() or "{}")
|
|
except urllib.error.HTTPError as exc:
|
|
if error is None:
|
|
raise
|
|
_fail(f"{error}: {_http_error_detail(exc)}")
|
|
except (urllib.error.URLError, TimeoutError) as exc:
|
|
if error is None:
|
|
raise
|
|
_fail(f"{error}: {getattr(exc, 'reason', None) or exc}")
|
|
|
|
|
|
# A server that WE auto-started (never one we merely found). Kept at module scope so
|
|
# failure paths and the atexit backstop can tear it down without threading a handle
|
|
# through all six agent commands. Only one agent runs per process, so one slot is enough.
|
|
_auto_served_server: Optional[subprocess.Popen] = None
|
|
# Model download + load can be slow; give the auto-started server room before giving up.
|
|
_SERVER_START_TIMEOUT_S = 900
|
|
_DOWNLOAD_POLL_INTERVAL_S = 1.0
|
|
_START_API_KEY_PREFIX = "UNSLOTH_START_API_KEY: "
|
|
_START_API_KEY_MARKER_ENV = "_UNSLOTH_START_API_KEY_MARKER"
|
|
|
|
|
|
def _format_download_bytes(value: int) -> str:
|
|
value = max(0, int(value))
|
|
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
if value < 1024 or unit == "TiB":
|
|
precision = 0 if unit in ("B", "KiB") else 1
|
|
return f"{value:.{precision}f} {unit}"
|
|
value /= 1024
|
|
return "0 B"
|
|
|
|
|
|
def _format_download_eta(seconds: float) -> str:
|
|
seconds = max(0, int(seconds))
|
|
if seconds < 60:
|
|
return f"{seconds}s"
|
|
minutes, seconds = divmod(seconds, 60)
|
|
if minutes < 60:
|
|
return f"{minutes}m {seconds:02d}s"
|
|
hours, minutes = divmod(minutes, 60)
|
|
return f"{hours}h {minutes:02d}m"
|
|
|
|
|
|
class _DownloadProgressDisplay:
|
|
"""Render download progress without making redirected output noisy."""
|
|
|
|
def __init__(self) -> None:
|
|
self._samples: list[tuple[float, int]] = []
|
|
self._shown = False
|
|
self._last_bucket = -1
|
|
self._last_line_length = 0
|
|
self._last_expected = 0
|
|
self._interactive = bool(getattr(sys.stdout, "isatty", lambda: False)())
|
|
|
|
def update(self, progress: dict) -> None:
|
|
downloaded = max(0, int(progress.get("downloaded_bytes") or 0))
|
|
completed = max(0, int(progress.get("completed_bytes") or 0))
|
|
expected = max(0, int(progress.get("expected_bytes") or 0))
|
|
self._last_expected = max(self._last_expected, expected)
|
|
fraction = float(progress.get("progress") or 0)
|
|
if downloaded <= 0:
|
|
return
|
|
# A fully cached snapshot can report 99% with no incomplete bytes; that is
|
|
# not a transfer, so don't show it as a download.
|
|
if completed >= downloaded > 0:
|
|
return
|
|
|
|
now = time.monotonic()
|
|
if self._samples and downloaded < self._samples[-1][1]:
|
|
self._samples.clear()
|
|
self._samples.append((now, downloaded))
|
|
cutoff = now - 15.0
|
|
while len(self._samples) > 2 and self._samples[0][0] < cutoff:
|
|
self._samples.pop(0)
|
|
|
|
rate = 0.0
|
|
if len(self._samples) >= 2:
|
|
elapsed = self._samples[-1][0] - self._samples[0][0]
|
|
delta = self._samples[-1][1] - self._samples[0][1]
|
|
if elapsed >= 1.0 and delta > 0:
|
|
rate = delta / elapsed
|
|
|
|
if expected > 0:
|
|
# The endpoint caps at 99% while bytes remain in an incomplete file; trust it.
|
|
fraction = min(1.0, max(0.0, fraction))
|
|
percent = min(100, max(0, int(fraction * 100)))
|
|
filled = min(24, int(fraction * 24))
|
|
bar = "=" * filled + ">" + "." * max(0, 23 - filled) if filled < 24 else "=" * 24
|
|
line = (
|
|
f"Downloading model [{bar}] {percent:3d}% "
|
|
f"{_format_download_bytes(downloaded)} / {_format_download_bytes(expected)}"
|
|
)
|
|
bucket = percent // 10
|
|
if rate > 0:
|
|
line += f" | {_format_download_bytes(rate)}/s"
|
|
if downloaded < expected:
|
|
line += f" | ETA {_format_download_eta((expected - downloaded) / rate)}"
|
|
else:
|
|
line = f"Downloading model: {_format_download_bytes(downloaded)}"
|
|
bucket = downloaded // (1024**3)
|
|
if rate > 0:
|
|
line += f" | {_format_download_bytes(rate)}/s"
|
|
|
|
if self._interactive:
|
|
padding = " " * max(0, self._last_line_length - len(line))
|
|
typer.echo(f"\r{line}{padding}", nl = False)
|
|
sys.stdout.flush()
|
|
self._last_line_length = len(line)
|
|
elif not self._shown or bucket > self._last_bucket:
|
|
typer.echo(line)
|
|
self._last_bucket = bucket
|
|
self._shown = True
|
|
|
|
def close(self) -> None:
|
|
if self._interactive and self._shown:
|
|
typer.echo()
|
|
self._last_line_length = 0
|
|
|
|
def complete(self) -> None:
|
|
"""Finish a displayed transfer after the model load confirms success."""
|
|
if not self._shown:
|
|
return
|
|
downloaded = self._samples[-1][1] if self._samples else 0
|
|
expected = max(downloaded, getattr(self, "_last_expected", 0))
|
|
self.update(
|
|
{
|
|
"downloaded_bytes": expected,
|
|
"expected_bytes": expected,
|
|
"progress": 1.0,
|
|
}
|
|
)
|
|
|
|
|
|
def _normalized_variant(value: object) -> str:
|
|
return re.sub(r"[^a-z0-9]", "", str(value or "").lower())
|
|
|
|
|
|
class _ModelDownloadProgress:
|
|
"""Best-effort polling of the model download endpoints."""
|
|
|
|
def __init__(self, base: str, key: str, model: str, variant: Optional[str]) -> None:
|
|
self._base = base
|
|
self._key = key
|
|
self._model = model
|
|
self._variant = variant or ""
|
|
self._expected_bytes = 0
|
|
self._display = _DownloadProgressDisplay()
|
|
self._configured = False
|
|
self._disabled = not _is_hub_model_id(model)
|
|
self._progress_prefix = "/api/hub"
|
|
|
|
def _configure(self) -> None:
|
|
self._configured = True
|
|
if self._disabled:
|
|
return
|
|
# GGUF repos need the selected quant's size; the repo endpoint totals every
|
|
# quant. Resolve the variant first, otherwise show bytes only.
|
|
if self._variant or "gguf" in self._model.lower():
|
|
try:
|
|
params = urlencode({"repo_id": self._model})
|
|
try:
|
|
info = _http_json(
|
|
"GET",
|
|
f"{self._base}/api/hub/gguf-variants?{params}",
|
|
self._key,
|
|
timeout = 10,
|
|
)
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code != 404:
|
|
raise
|
|
self._progress_prefix = "/api/models"
|
|
info = _http_json(
|
|
"GET",
|
|
f"{self._base}/api/models/gguf-variants?{params}",
|
|
self._key,
|
|
timeout = 10,
|
|
)
|
|
self._variant = self._variant or str(info.get("default_variant") or "")
|
|
wanted = _normalized_variant(self._variant)
|
|
for item in info.get("variants") or []:
|
|
quant = _normalized_variant(item.get("quant"))
|
|
filename = _normalized_variant(item.get("filename"))
|
|
if wanted and (wanted == quant or wanted in filename):
|
|
self._expected_bytes = int(
|
|
item.get("download_size_bytes") or item.get("size_bytes") or 0
|
|
)
|
|
break
|
|
except Exception:
|
|
# Older servers lack this endpoint; byte progress is still useful.
|
|
pass
|
|
|
|
def poll(self) -> None:
|
|
if not self._configured:
|
|
self._configure()
|
|
if self._disabled:
|
|
return
|
|
try:
|
|
if self._variant or "gguf" in self._model.lower():
|
|
params = urlencode(
|
|
{
|
|
"repo_id": self._model,
|
|
"variant": self._variant,
|
|
"expected_bytes": self._expected_bytes,
|
|
}
|
|
)
|
|
url = f"{self._base}{self._progress_prefix}/gguf-download-progress?{params}"
|
|
else:
|
|
url = (
|
|
f"{self._base}{self._progress_prefix}/download-progress?"
|
|
f"{urlencode({'repo_id': self._model})}"
|
|
)
|
|
try:
|
|
reading = _http_json("GET", url, self._key, timeout = 10)
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code != 404 or self._progress_prefix == "/api/models":
|
|
raise
|
|
self._progress_prefix = "/api/models"
|
|
self.poll()
|
|
return
|
|
self._display.update(reading)
|
|
except Exception:
|
|
# Progress is best-effort; never fail the load over a polling error.
|
|
self._disabled = True
|
|
|
|
def close(self) -> None:
|
|
self._display.close()
|
|
|
|
def complete(self) -> None:
|
|
self._display.complete()
|
|
|
|
|
|
def _load_model_with_progress(
|
|
base: str, key: str, model: str, load: LoadOptions, payload: dict
|
|
) -> dict:
|
|
"""Run the blocking load request while polling its download progress."""
|
|
result: list[tuple[bool, object]] = []
|
|
done = threading.Event()
|
|
|
|
def _load() -> None:
|
|
try:
|
|
value = _http_json(
|
|
"POST",
|
|
f"{base}/api/inference/load",
|
|
key,
|
|
payload,
|
|
timeout = 3600,
|
|
error = "Model load failed",
|
|
)
|
|
result.append((True, value))
|
|
except BaseException as exc:
|
|
result.append((False, exc))
|
|
finally:
|
|
done.set()
|
|
|
|
threading.Thread(target = _load, name = "unsloth-model-load", daemon = True).start()
|
|
progress = _ModelDownloadProgress(base, key, model, load.gguf_variant)
|
|
loading_announced = False
|
|
try:
|
|
while not done.wait(_DOWNLOAD_POLL_INTERVAL_S):
|
|
if not loading_announced:
|
|
typer.echo(f"Loading model: {_display_model_spec(model, load.gguf_variant)}")
|
|
loading_announced = True
|
|
progress.poll()
|
|
ok, value = result[0]
|
|
if not ok:
|
|
assert isinstance(value, BaseException)
|
|
raise value
|
|
progress.complete()
|
|
return value if isinstance(value, dict) else {}
|
|
finally:
|
|
progress.close()
|
|
|
|
|
|
def _studio_healthy(base: str, timeout: float = 3.0) -> bool:
|
|
request = urllib.request.Request(f"{base}/api/health", headers = {"User-Agent": _USER_AGENT})
|
|
try:
|
|
with urllib.request.urlopen(request, timeout = timeout) as response:
|
|
return json.loads(response.read(65536).decode() or "{}").get("status") == "healthy"
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _log_tail(path: Path, lines: int = 20) -> str:
|
|
try:
|
|
return "\n".join(path.read_text(encoding = "utf-8", errors = "replace").splitlines()[-lines:])
|
|
except OSError:
|
|
return "(no server log)"
|
|
|
|
|
|
def _redacted_log_tail(path: Path, lines: int = 20) -> str:
|
|
"""Tail with minted keys removed; only for tails shown on the terminal."""
|
|
return re.sub(r"sk-unsloth-\S+", "sk-unsloth-[redacted]", _log_tail(path, lines))
|
|
|
|
|
|
def _shutdown_server(server: Optional[subprocess.Popen]) -> None:
|
|
# Idempotent teardown of a server WE started, plus its own children (llama-server,
|
|
# cloudflared). A no-op once the process is already gone.
|
|
if server is None or server.poll() is not None:
|
|
return
|
|
if os.name == "nt":
|
|
# terminate()/kill() reach only the parent `unsloth run`; taskkill /T walks the
|
|
# whole tree so the llama-server child doesn't keep the port and GPU (matches the
|
|
# taskkill /T /F pattern already used in unsloth/dataprep/synthetic.py).
|
|
try:
|
|
subprocess.run(
|
|
["taskkill", "/PID", str(server.pid), "/T", "/F"],
|
|
capture_output = True,
|
|
timeout = 15,
|
|
check = False,
|
|
)
|
|
server.wait(timeout = 5)
|
|
except Exception:
|
|
with contextlib.suppress(Exception):
|
|
server.kill()
|
|
return
|
|
try:
|
|
os.killpg(os.getpgid(server.pid), signal.SIGTERM)
|
|
except OSError:
|
|
server.terminate()
|
|
try:
|
|
server.wait(timeout = 15)
|
|
except Exception:
|
|
try:
|
|
os.killpg(os.getpgid(server.pid), signal.SIGKILL)
|
|
except OSError:
|
|
server.kill()
|
|
|
|
|
|
def _shutdown_auto_served() -> None:
|
|
global _auto_served_server
|
|
server, _auto_served_server = _auto_served_server, None
|
|
if server is not None and server.poll() is None:
|
|
typer.echo("Stopping the auto-started Unsloth server…")
|
|
_shutdown_server(server)
|
|
|
|
|
|
def _keep_auto_served() -> bool:
|
|
"""Release ownership so a successfully started server survives this CLI."""
|
|
global _auto_served_server
|
|
server, _auto_served_server = _auto_served_server, None
|
|
atexit.unregister(_shutdown_auto_served)
|
|
return server is not None and server.poll() is None
|
|
|
|
|
|
def _start_studio_server(
|
|
base: str,
|
|
model: str,
|
|
load: LoadOptions,
|
|
server: ServerOptions = ServerOptions(),
|
|
) -> subprocess.Popen:
|
|
"""Spawn `unsloth run` for `model`, wait until it is fully ready, and return it."""
|
|
global _auto_served_server
|
|
unsloth = shutil.which("unsloth") or "unsloth"
|
|
parsed = urlparse(base)
|
|
# Tools default off = passthrough mode (relay the agent's own tools); --no-cloudflare =
|
|
# loopback only, no tunnel. Mirrors .github/scripts/serve-unsloth-run.sh. Healing/nudging
|
|
# travel via the child env below (version-agnostic) rather than new run flags that an
|
|
# older re-exec'd run could mistake for llama-server args.
|
|
command = [
|
|
unsloth,
|
|
"run",
|
|
"-H",
|
|
parsed.hostname or "127.0.0.1",
|
|
"-p",
|
|
str(parsed.port or 8888),
|
|
"--enable-tools" if server.enable_tools else "--disable-tools",
|
|
"--no-cloudflare",
|
|
"--model",
|
|
model,
|
|
]
|
|
if load.gguf_variant:
|
|
command += ["--gguf-variant", load.gguf_variant]
|
|
if load.max_seq_length:
|
|
command += ["--context-length", str(load.max_seq_length)]
|
|
if not load.load_in_4bit:
|
|
command += ["--no-load-in-4bit"]
|
|
if load.tensor_parallel:
|
|
command += ["--tensor-parallel"]
|
|
if load.gpu_memory_mode is not None:
|
|
command += ["--gpu-memory-mode", load.gpu_memory_mode]
|
|
|
|
log_path = Path(tempfile.gettempdir()) / f"unsloth-start-server-{os.getpid()}.log"
|
|
typer.echo("Starting Unsloth server")
|
|
typer.echo(f"Model: {_display_model_spec(model, load.gguf_variant)}")
|
|
typer.echo(f"Server log: {log_path}")
|
|
# 0600: the `unsloth run` banner in this log carries the minted sk-unsloth- key, and
|
|
# the tempdir is world-traversable. Unlink first so a stale looser-mode file (pid
|
|
# reuse) can't survive with its old permissions.
|
|
log_path.unlink(missing_ok = True)
|
|
log = os.fdopen(os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "wb")
|
|
# Own session/process group so a mid-session Ctrl+C (cancel a turn) doesn't reach the
|
|
# server. It survives a successful agent session; torn down on startup/launch failure.
|
|
child_env = os.environ.copy()
|
|
# Current llama-server versions read this documented env equivalent of --reasoning.
|
|
# Older managed versions ignore an unknown env variable instead of failing startup on
|
|
# an unknown passthrough CLI flag. An omitted start option still defaults to off.
|
|
child_env["LLAMA_ARG_REASONING"] = server.reasoning or "off"
|
|
# Pass the marker via env so an older launcher ignores it instead of treating an
|
|
# unknown CLI flag as a llama-server arg; new launchers preserve it across re-exec.
|
|
child_env[_START_API_KEY_MARKER_ENV] = "1"
|
|
# Convey healing/nudging through the env; `unsloth run` reads these when its own
|
|
# flags are omitted, so this works even if run re-execs into an older Studio venv.
|
|
# Only write when the operator set the flag explicitly; otherwise keep whatever they
|
|
# already exported (child_env is a copy of os.environ), falling back to the start
|
|
# defaults (healing on, nudging on) when nothing was inherited.
|
|
if server.tool_call_healing is not None:
|
|
child_env["UNSLOTH_DISABLE_TOOL_CALL_HEALING"] = "0" if server.tool_call_healing else "1"
|
|
elif "UNSLOTH_DISABLE_TOOL_CALL_HEALING" not in child_env:
|
|
child_env["UNSLOTH_DISABLE_TOOL_CALL_HEALING"] = "0"
|
|
if server.tool_call_nudging is not None:
|
|
child_env["UNSLOTH_TOOL_CALL_NUDGE"] = "1" if server.tool_call_nudging else "0"
|
|
elif "UNSLOTH_TOOL_CALL_NUDGE" not in child_env:
|
|
child_env["UNSLOTH_TOOL_CALL_NUDGE"] = "1"
|
|
# Forward any sampling pin via the env; `unsloth run` reads UNSLOTH_SAMPLING_* and the
|
|
# backend resolver applies it as a hard override. Only set fields the operator specified.
|
|
for _sampling_env, _sampling_value in (
|
|
("UNSLOTH_SAMPLING_TEMPERATURE", server.temperature),
|
|
("UNSLOTH_SAMPLING_TOP_P", server.top_p),
|
|
("UNSLOTH_SAMPLING_TOP_K", server.top_k),
|
|
("UNSLOTH_SAMPLING_MIN_P", server.min_p),
|
|
("UNSLOTH_SAMPLING_REPETITION_PENALTY", server.repetition_penalty),
|
|
("UNSLOTH_SAMPLING_PRESENCE_PENALTY", server.presence_penalty),
|
|
):
|
|
if _sampling_value is not None:
|
|
child_env[_sampling_env] = str(_sampling_value)
|
|
kwargs: dict = {
|
|
"stdout": log,
|
|
"stderr": subprocess.STDOUT,
|
|
"stdin": subprocess.DEVNULL,
|
|
"env": child_env,
|
|
}
|
|
if os.name == "nt":
|
|
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
else:
|
|
kwargs["start_new_session"] = True
|
|
try:
|
|
server = subprocess.Popen(command, **kwargs)
|
|
finally:
|
|
log.close() # Popen dup'd the fd; drop the parent's copy
|
|
_auto_served_server = server
|
|
atexit.register(_shutdown_auto_served)
|
|
|
|
deadline = time.monotonic() + _SERVER_START_TIMEOUT_S
|
|
progress: Optional[_ModelDownloadProgress] = None
|
|
early_key_seen = False
|
|
try:
|
|
while time.monotonic() < deadline:
|
|
if server.poll() is not None:
|
|
# The early key marker lands here before load finishes; redact it.
|
|
tail = _redacted_log_tail(log_path)
|
|
_shutdown_auto_served()
|
|
_fail(f"The Unsloth server stopped before it was ready. Last log lines:\n{tail}")
|
|
tail = _log_tail(log_path, lines = 400)
|
|
if progress is None:
|
|
marker = re.search(
|
|
rf"^{re.escape(_START_API_KEY_PREFIX)}(sk-unsloth-[^\s]+)$",
|
|
tail,
|
|
flags = re.MULTILINE,
|
|
)
|
|
if marker:
|
|
early_key_seen = True
|
|
progress = _ModelDownloadProgress(
|
|
base,
|
|
marker.group(1),
|
|
model,
|
|
load.gguf_variant,
|
|
)
|
|
if progress is not None:
|
|
progress.poll()
|
|
# New children emit an early key marker, so wait for the final model banner;
|
|
# older children only print the key after load, so fall back to that.
|
|
ready_signal = "Model loaded:" in tail if early_key_seen else "sk-unsloth-" in tail
|
|
if _studio_healthy(base) and ready_signal:
|
|
if progress is not None:
|
|
progress.complete()
|
|
progress.close()
|
|
progress = None
|
|
return server
|
|
time.sleep(2.0)
|
|
finally:
|
|
if progress is not None:
|
|
progress.close()
|
|
_shutdown_auto_served()
|
|
_fail(
|
|
f"The Unsloth server didn't become ready within {_SERVER_START_TIMEOUT_S}s. See {log_path}."
|
|
)
|
|
|
|
|
|
def _effective_base(base: str) -> str:
|
|
# `unsloth run` binds to `parsed.port or 8888` and serves at the root, so normalize
|
|
# UNSLOTH_STUDIO_URL to plain scheme://host:port. A portless http://127.0.0.1 would
|
|
# otherwise launch on 8888 but poll port 80, and a path like /studio would poll
|
|
# /studio/api/health (404) -- either way hitting the startup timeout. IPv6 literals
|
|
# stay bracketed.
|
|
parsed = urlparse(base)
|
|
host = parsed.hostname or "127.0.0.1"
|
|
if ":" in host: # bare IPv6 literal (urlparse strips the brackets)
|
|
host = f"[{host}]"
|
|
return f"{parsed.scheme or 'http'}://{host}:{parsed.port or 8888}"
|
|
|
|
|
|
def _require_studio(
|
|
model: Optional[str] = None,
|
|
load: Optional[LoadOptions] = None,
|
|
*,
|
|
serve: bool = False,
|
|
launch: bool = True,
|
|
server_options: ServerOptions = ServerOptions(),
|
|
) -> tuple:
|
|
"""Return (base, server). server is a Popen only when WE auto-started it."""
|
|
base = find_studio_server()
|
|
if base is not None:
|
|
# Attaching to a server someone else started: UNSLOTH_SAMPLING_* pins only reach the
|
|
# server process when WE launch it (via _start_studio_server), so a sampling flag on the
|
|
# attach path can't take effect. Warn instead of silently dropping it, so the operator is
|
|
# not misled into thinking generation now uses the pinned value.
|
|
_pinned = [
|
|
_flag
|
|
for _flag, _value in (
|
|
("--temperature", server_options.temperature),
|
|
("--top-p", server_options.top_p),
|
|
("--top-k", server_options.top_k),
|
|
("--min-p", server_options.min_p),
|
|
("--repetition-penalty", server_options.repetition_penalty),
|
|
("--presence-penalty", server_options.presence_penalty),
|
|
)
|
|
if _value is not None
|
|
]
|
|
if _pinned:
|
|
typer.echo(
|
|
f"Warning: an Unsloth server is already running at {base}; sampling pins "
|
|
f"({', '.join(_pinned)}) apply only when this command starts the server, so the "
|
|
"running server keeps its current sampling. Stop it with `unsloth studio stop` "
|
|
"and re-run to apply them.",
|
|
err = True,
|
|
)
|
|
if server_options.reasoning is not None:
|
|
typer.echo(
|
|
f"Warning: an Unsloth server is already running at {base}; "
|
|
f"--reasoning {server_options.reasoning} applies only when this command starts "
|
|
"the server, so the running server keeps its current reasoning mode. Stop it "
|
|
"with `unsloth studio stop` and re-run to apply the override.",
|
|
err = True,
|
|
)
|
|
return base, None
|
|
expected = os.environ.get("UNSLOTH_STUDIO_URL", "http://127.0.0.1:8888").rstrip("/")
|
|
# Auto-start a local server only for an interactive launch with a model to serve, and
|
|
# only for a plain-HTTP loopback target: never stand in for an explicit remote
|
|
# UNSLOTH_STUDIO_URL, and never for an https:// one -- `unsloth run` serves plain
|
|
# HTTP, so the health poll against https would spin until the startup timeout.
|
|
if (
|
|
serve
|
|
and launch
|
|
and model
|
|
and is_loopback_url(expected)
|
|
and urlparse(expected).scheme == "http"
|
|
):
|
|
# Normalize to the port unsloth run actually binds, so the health poll and the
|
|
# returned base hit the same server we launch (not a portless :80).
|
|
expected = _effective_base(expected)
|
|
load = load or LoadOptions()
|
|
# Leave a bare GGUF repo's variant unset: the server's own quant preference already
|
|
# picks the best available (UD-Q4_K_XL for Unsloth uploads, else Q4_K_M) and falls back
|
|
# when that exact quant is missing, which forcing a fixed variant here would break.
|
|
return expected, _start_studio_server(expected, model, load, server_options)
|
|
model_hint = "" if model else " Pass --model to have it start one for you, or"
|
|
_fail(
|
|
f"No running Unsloth server found at {expected}.{model_hint} start one with "
|
|
"`unsloth studio`, or point UNSLOTH_STUDIO_URL at a remote server."
|
|
)
|
|
|
|
|
|
def _key_cache_path() -> Path:
|
|
ensure_studio_backend_path()
|
|
from utils.paths import auth_root
|
|
return auth_root() / "agent_api_key.json"
|
|
|
|
|
|
def _read_cache(cache: Path) -> dict:
|
|
try:
|
|
data = json.loads(cache.read_text(encoding = "utf-8"))
|
|
except Exception:
|
|
return {}
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
|
|
def _server_buckets(servers: dict, base: str) -> dict:
|
|
# Normalise a server's entry to {"saved": [...], "minted": [...]}, tolerating a
|
|
# corrupt/legacy value (bare string/list -> treated as minted, behind the handshake).
|
|
entry = servers.get(base) if isinstance(servers, dict) else None
|
|
if isinstance(entry, list):
|
|
return {"saved": [], "minted": [k for k in entry if isinstance(k, str)]}
|
|
if not isinstance(entry, dict):
|
|
return {"saved": [], "minted": []}
|
|
|
|
def _strs(name: str) -> list:
|
|
value = entry.get(name)
|
|
return [k for k in value if isinstance(k, str)] if isinstance(value, list) else []
|
|
|
|
return {"saved": _strs("saved"), "minted": _strs("minted")}
|
|
|
|
|
|
def _cached_keys(cache: Path, base: str, source: str) -> list:
|
|
# Keys are scoped per server. `source` splits user-supplied --api-key keys
|
|
# ("saved", trusted for that base) from auto-minted ones ("minted", replayed
|
|
# only after the identity check). Legacy unscoped caches are ignored.
|
|
return _server_buckets(_read_cache(cache).get("servers", {}), base)[source]
|
|
|
|
|
|
def _write_private_json(path: Path, data: dict) -> None:
|
|
# O_CREAT with 0o600 so a file holding an API key is never world-readable,
|
|
# even briefly (existing files keep whatever perms the user set).
|
|
path.parent.mkdir(parents = True, exist_ok = True, mode = 0o700)
|
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
with os.fdopen(fd, "w") as handle:
|
|
handle.write(json.dumps(data, indent = 2) + "\n")
|
|
|
|
|
|
def _write_private_text(path: Path, text: str) -> None:
|
|
path.parent.mkdir(parents = True, exist_ok = True, mode = 0o700)
|
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
with os.fdopen(fd, "w", encoding = "utf-8") as handle:
|
|
handle.write(text)
|
|
|
|
|
|
def _read_json_object(path: Path) -> Optional[dict]:
|
|
# {} when missing, None when it can't be parsed as an object (so the caller
|
|
# leaves a user-managed file untouched rather than clobbering it).
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
data = json.loads(path.read_text(encoding = "utf-8"))
|
|
except (ValueError, OSError):
|
|
return None
|
|
return data if isinstance(data, dict) else None
|
|
|
|
|
|
def _subdict(parent: dict, key: str) -> dict:
|
|
child = parent.get(key)
|
|
if not isinstance(child, dict):
|
|
child = parent[key] = {}
|
|
return child
|
|
|
|
|
|
def _remember_key(cache: Path, base: str, key: str, source: str) -> None:
|
|
data = _read_cache(cache)
|
|
servers = data.get("servers")
|
|
if not isinstance(servers, dict):
|
|
servers = data["servers"] = {}
|
|
buckets = _server_buckets(servers, base)
|
|
other = "minted" if source == "saved" else "saved"
|
|
buckets[source] = ([key] + [k for k in buckets[source] if k != key])[:8]
|
|
buckets[other] = [k for k in buckets[other] if k != key] # a key has one provenance
|
|
new_entry = {"saved": buckets["saved"], "minted": buckets["minted"]}
|
|
if servers.get(base) == new_entry:
|
|
return
|
|
servers[base] = new_entry
|
|
# Collapse legacy unscoped fields.
|
|
data.pop("keys", None)
|
|
data.pop("key", None)
|
|
try:
|
|
_write_private_json(cache, data)
|
|
except OSError:
|
|
pass # worst case the next launch mints another key
|
|
|
|
|
|
def _key_accepted(base: str, key: str) -> bool:
|
|
# Only a genuine auth rejection (401/403) means "this key is bad -- skip it and try
|
|
# the next cached key or mint a fresh one". A 5xx or a network blip is a server-side
|
|
# outage, not a bad key: fail with a clean message (never a traceback) instead of
|
|
# silently discarding a working key and minting extras against a struggling server.
|
|
try:
|
|
_http_json("GET", f"{base}/v1/models", key)
|
|
return True
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code in (401, 403):
|
|
return False
|
|
_fail(
|
|
f"Unsloth server error while checking an API key ({exc.code}). "
|
|
"The server may be starting up or unhealthy; try again shortly."
|
|
)
|
|
except (urllib.error.URLError, TimeoutError) as exc:
|
|
_fail(
|
|
"Couldn't reach the Unsloth server while checking an API key: "
|
|
f"{getattr(exc, 'reason', None) or exc}"
|
|
)
|
|
|
|
|
|
def _agent_api_key(
|
|
base: str,
|
|
explicit: Optional[str],
|
|
*,
|
|
auto_started: bool = False,
|
|
) -> str:
|
|
cache = _key_cache_path()
|
|
if explicit:
|
|
if not auto_started or _key_accepted(base, explicit):
|
|
_remember_key(cache, base, explicit, "saved")
|
|
return explicit
|
|
# The server was auto-started for this run, so an exported
|
|
# UNSLOTH_API_KEY meant for some other server must not fail the
|
|
# launch: the loopback mint path below is guaranteed to work.
|
|
# (An explicit key that the fresh server accepts, e.g. one persisted
|
|
# in this Unsloth home's auth db, is still honored above.)
|
|
|
|
# Replay a key the user saved for *this exact* server first (scoped per base,
|
|
# so it only goes back there -- including a remote/SSH-tunnelled Unsloth whose
|
|
# secret the local handshake can't match). Skip ones the server rejects.
|
|
for key in _cached_keys(cache, base, "saved"):
|
|
if _key_accepted(base, key):
|
|
_remember_key(cache, base, key, "saved")
|
|
return key
|
|
|
|
# Beyond here we auto-mint or replay an auto-minted key. find_studio_server()
|
|
# trusts a base after only a health check, so both are limited to a loopback
|
|
# server we can cryptographically confirm is ours.
|
|
if not is_loopback_url(base):
|
|
_fail(
|
|
f"No saved API key for {base} and automatic minting only runs against "
|
|
"a local Unsloth. Create an API key in Unsloth → Settings → API and "
|
|
"pass it with --api-key (it is remembered per server), or set "
|
|
"UNSLOTH_API_KEY."
|
|
)
|
|
if not verify_studio_identity(base):
|
|
_fail(
|
|
f"Couldn't verify that {base} is your Unsloth (it may be running as a "
|
|
"different OS user, or another process took the port). Create an API "
|
|
"key in Unsloth → Settings → API and pass it with --api-key, or set "
|
|
"UNSLOTH_API_KEY."
|
|
)
|
|
|
|
# Identity verified: replay a previously auto-minted key, else mint a new one.
|
|
for key in _cached_keys(cache, base, "minted"):
|
|
if _key_accepted(base, key):
|
|
_remember_key(cache, base, key, "minted")
|
|
return key
|
|
|
|
# Self-issue a JWT (signed with the local secret) and mint a key.
|
|
token = _studio_token()
|
|
if token is None:
|
|
_fail(
|
|
"Couldn't authenticate with the Unsloth server automatically. Create "
|
|
"an API key in Unsloth → Settings → API and pass it with --api-key, "
|
|
"or set UNSLOTH_API_KEY."
|
|
)
|
|
key = _http_json(
|
|
"POST",
|
|
f"{base}/api/auth/api-keys",
|
|
token,
|
|
{"name": "Coding agents (unsloth start)"},
|
|
error = "Couldn't create an API key",
|
|
)["key"]
|
|
_remember_key(cache, base, key, "minted")
|
|
return key
|
|
|
|
|
|
def _loaded_models(base: str, key: str) -> list:
|
|
return _http_json("GET", f"{base}/v1/models", key, error = "Couldn't list models").get("data", [])
|
|
|
|
|
|
_HF_REPO_ID_SEGMENT_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
|
|
|
|
def _is_hub_model_id(value: object) -> bool:
|
|
if not isinstance(value, str):
|
|
return False
|
|
text = value.strip()
|
|
if "\\" in text:
|
|
return False
|
|
if text.startswith(("/", "./", "../", "~")):
|
|
return False
|
|
if len(text) >= 2 and text[1] == ":" and text[0].isalpha():
|
|
return False
|
|
# A hub id is exactly "namespace/name" over a restricted charset. Anything with
|
|
# extra path segments (e.g. a server-side relative path such as
|
|
# models/Llama/Foo.gguf on a remote Unsloth) is not a hub id and must not be
|
|
# casefold-matched against a differently cased path on a case-sensitive
|
|
# filesystem. This is host independent, unlike the existence probe below which
|
|
# cannot see a path that only exists on the server.
|
|
parts = text.split("/")
|
|
if len(parts) != 2:
|
|
return False
|
|
if any(part in ("", ".", "..") or not _HF_REPO_ID_SEGMENT_RE.match(part) for part in parts):
|
|
return False
|
|
try:
|
|
if Path(os.path.expanduser(text)).exists():
|
|
return False
|
|
except OSError:
|
|
return False
|
|
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,
|
|
*,
|
|
allow_casefold: bool = True,
|
|
) -> bool:
|
|
if actual == requested:
|
|
return True
|
|
# Case-insensitive matching is only safe when the local existence probe in
|
|
# _is_hub_model_id is authoritative, i.e. against a loopback Unsloth on this host.
|
|
# Against a remote Unsloth a two-segment string is indistinguishable from a
|
|
# server-side relative path (e.g. Models/Foo vs models/foo), so casefolding it
|
|
# could attach to the wrong model on a case-sensitive server; defer to an exact
|
|
# match there and let the load endpoint resolve the requested path.
|
|
if not allow_casefold:
|
|
return False
|
|
if not (_is_hub_model_id(actual) and _is_hub_model_id(requested)):
|
|
return False
|
|
return str(actual).casefold() == str(requested).casefold()
|
|
|
|
|
|
def _resolve_model(
|
|
base: str,
|
|
key: str,
|
|
requested: Optional[str],
|
|
load: LoadOptions = LoadOptions(),
|
|
) -> dict:
|
|
models = _loaded_models(base, key)
|
|
load_requested = False
|
|
# Only casefold-match ids against a loopback Unsloth, where _is_hub_model_id's
|
|
# local existence probe can actually reject a server-side path; see the note there.
|
|
allow_casefold = is_loopback_url(base)
|
|
# /v1/models reports the model id but not the active GGUF variant or runtime load
|
|
# settings, so an id match alone can hide the wrong quant (Q8_0 serving while the
|
|
# user asked for UD-Q4_K_XL). When the user passed any explicit load knob, defer to
|
|
# /api/inference/load: the server's already-loaded dedup answers "already_loaded"
|
|
# without reloading when the variant AND settings match, so a second session running
|
|
# the same command still attaches without evicting the first.
|
|
load_has_overrides = bool(
|
|
load.gguf_variant
|
|
or load.max_seq_length
|
|
or not load.load_in_4bit
|
|
or load.tensor_parallel
|
|
or load.gpu_memory_mode is not None
|
|
)
|
|
# /v1/models also lists cached-but-unloaded catalog entries (loaded == False);
|
|
# matching one would skip /api/inference/load and leave the agent pointed at a
|
|
# model that is not resident, so only attach to an entry that is actually loaded.
|
|
match = (
|
|
None
|
|
if requested and load_has_overrides
|
|
else next(
|
|
(
|
|
m
|
|
for m in models
|
|
if _model_id_matches(m.get("id"), requested, allow_casefold = allow_casefold)
|
|
and m.get("loaded") is not False
|
|
),
|
|
None,
|
|
)
|
|
)
|
|
if requested and match is None:
|
|
load_requested = True
|
|
active = next((m for m in models if m.get("loaded") is not False), None)
|
|
active_id = active.get("id") if active else None
|
|
if active_id and not _model_id_matches(
|
|
active_id,
|
|
requested,
|
|
allow_casefold = allow_casefold,
|
|
):
|
|
typer.echo(f"Switching the Unsloth server from {active_id} to {requested}.")
|
|
typer.echo("This unloads the current model for every attached session.")
|
|
elif active_id and load.gguf_variant:
|
|
# Same repo id but an explicit quant still replaces the resident
|
|
# weights; /v1/models has no variant, so ask the status endpoint.
|
|
try:
|
|
status = _http_json("GET", f"{base}/api/inference/status", key)
|
|
except Exception:
|
|
status = {}
|
|
resident = status.get("gguf_variant") if status.get("is_gguf") else None
|
|
if resident and _normalized_variant(resident) != _normalized_variant(load.gguf_variant):
|
|
typer.echo(
|
|
f"Switching the Unsloth server from {active_id}:{resident} "
|
|
f"to {requested}:{load.gguf_variant}."
|
|
)
|
|
typer.echo("This unloads the current model for every attached session.")
|
|
# Mirror `unsloth run`'s load knobs; keep the default payload as just
|
|
# model_path so a bare `--model` load is unchanged.
|
|
payload = {"model_path": requested}
|
|
if load.gguf_variant:
|
|
payload["gguf_variant"] = load.gguf_variant
|
|
if load.max_seq_length:
|
|
payload["max_seq_length"] = load.max_seq_length
|
|
if not load.load_in_4bit:
|
|
payload["load_in_4bit"] = False
|
|
if load.tensor_parallel:
|
|
payload["tensor_parallel"] = True
|
|
if load.gpu_memory_mode is not None:
|
|
payload["gpu_memory_mode"] = load.gpu_memory_mode
|
|
if load.gpu_memory_mode == "manual":
|
|
payload["gpu_layers"] = -1
|
|
loaded = _load_model_with_progress(base, key, requested, load, payload)
|
|
if loaded.get("status") == "already_loaded":
|
|
typer.echo(f"Reusing loaded model: {_display_model_spec(requested, load.gguf_variant)}")
|
|
# Unsloth registers the model under a canonical id (resolved identifier,
|
|
# 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, _public_model_id(requested)} - {None}
|
|
if isinstance(loaded, dict):
|
|
wanted |= {loaded.get("model"), loaded.get("display_name")} - {None}
|
|
models = _loaded_models(base, key)
|
|
match = next(
|
|
(
|
|
m
|
|
for m in models
|
|
if m.get("loaded") is not False
|
|
and any(
|
|
_model_id_matches(m.get("id"), w, allow_casefold = allow_casefold) for w in wanted
|
|
)
|
|
),
|
|
None,
|
|
)
|
|
if match is not None:
|
|
if requested and not load_requested:
|
|
typer.echo(f"Reusing loaded model: {_display_model_spec(requested, load.gguf_variant)}")
|
|
return match
|
|
if requested:
|
|
# We asked Unsloth to load it and it didn't surface in /v1/models; don't
|
|
# silently hand back an unrelated loaded model.
|
|
_fail(
|
|
f"Unsloth didn't report '{requested}' as loaded. Double-check the model "
|
|
"id, or load it from the model dropdown in the UI."
|
|
)
|
|
if not models:
|
|
_fail(
|
|
"No model is loaded in Unsloth. Load one from the model dropdown in "
|
|
"the UI, or pass --model <hf-id-or-path> to load it from here."
|
|
)
|
|
resident = next((m for m in models if m.get("loaded") is not False), None)
|
|
if resident is None:
|
|
_fail(
|
|
"No model is currently resident in Unsloth. Pass --model <hf-id-or-path> "
|
|
"to reload one, or load it from the model dropdown in the UI."
|
|
)
|
|
return resident
|
|
|
|
|
|
def _require_gguf_for_codex(base: str, key: str, model_id: str) -> None:
|
|
# Codex always streams, and Unsloth only streams /v1/responses from llama-server.
|
|
try:
|
|
status = _http_json("GET", f"{base}/api/inference/status", key)
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code == 404:
|
|
return # older server without the endpoint; don't block the launch
|
|
raise
|
|
if status.get("is_gguf"):
|
|
return
|
|
hint = model_id if "gguf" in model_id.lower() else f"{model_id}-GGUF"
|
|
_fail(
|
|
f"Codex needs a GGUF model served by llama-server, but {model_id} is on "
|
|
f"the transformers backend. Try: unsloth start codex --model {hint}"
|
|
)
|
|
|
|
|
|
_DYNAMIC_SECTIONS_FLAG = "--exclude-dynamic-system-prompt-sections"
|
|
|
|
|
|
def _claude_settings_overlay(model_id: str) -> str:
|
|
# Session-only `claude --settings` overlay (command-line tier, no ~/.claude write):
|
|
# suppress the attribution header, keep every subagent on the served model (a user
|
|
# CLAUDE_CODE_SUBAGENT_MODEL pin would otherwise route delegated work off the local
|
|
# endpoint), and pin availableModels to the served model so a user allowlist can't
|
|
# reject it. The pin must be non-empty; [] is ignored.
|
|
return json.dumps(
|
|
{
|
|
"env": {
|
|
"CLAUDE_CODE_ATTRIBUTION_HEADER": "0",
|
|
"CLAUDE_CODE_SUBAGENT_MODEL": "inherit",
|
|
},
|
|
"availableModels": [model_id],
|
|
}
|
|
)
|
|
|
|
|
|
def _claude_version() -> Optional[tuple]:
|
|
# None = no local `claude` (a --no-launch printout for another machine; assume a
|
|
# current build). An unparseable version is treated as too old for the new flags.
|
|
executable = _which_with_install_dirs("claude")
|
|
if executable is None:
|
|
return None
|
|
try:
|
|
result = subprocess.run(
|
|
[executable, "--version"], capture_output = True, text = True, timeout = 10
|
|
)
|
|
# Pull the X.Y.Z out of the output rather than assuming it is the first token.
|
|
# claude prints it first today ("2.1.98 (Claude Code)"), but a format change
|
|
# (e.g. "claude version 2.1.98") shouldn't silently drop the optimization flags;
|
|
# no match falls through to "too old", same as an unparseable version.
|
|
match = re.search(r"(\d+)\.(\d+)\.(\d+)", result.stdout)
|
|
return tuple(int(part) for part in match.groups()) if match else (0,)
|
|
except Exception:
|
|
return (0,)
|
|
|
|
|
|
def _claude_flags(model_id: str) -> list:
|
|
# KV-cache-preserving flags: move per-session context out of the system prompt and pass
|
|
# the session overlay. claude < 2.1.98 rejects unknown flags; no local binary means a
|
|
# printout for another machine, so assume a current build.
|
|
version = _claude_version()
|
|
if version is not None and version < (2, 1, 98):
|
|
return []
|
|
return [_DYNAMIC_SECTIONS_FLAG, "--settings", _claude_settings_overlay(model_id)]
|
|
|
|
|
|
def _claude_local_env(base: str, key: str, entry: dict) -> dict:
|
|
"""Build the local endpoint, cache, display, and compaction environment."""
|
|
model_id = entry["id"]
|
|
env = {
|
|
"ANTHROPIC_BASE_URL": base,
|
|
"ANTHROPIC_AUTH_TOKEN": key,
|
|
"ANTHROPIC_MODEL": model_id,
|
|
"CLAUDE_CODE_ATTRIBUTION_HEADER": "0",
|
|
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
|
"CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1",
|
|
"CLAUDE_CODE_NO_FLICKER": "1",
|
|
}
|
|
window = entry.get("context_length") or entry.get("max_context_length")
|
|
if window:
|
|
env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = str(int(window))
|
|
env["CLAUDE_AUTOCOMPACT_PCT_OVERRIDE"] = "90"
|
|
return env
|
|
|
|
|
|
def _merge_codex_config(existing: str, base: str) -> str:
|
|
chunks = re.split(r"(?m)^(?=\[)", existing) # preamble, then one chunk per table
|
|
if not re.search(r"(?m)^\s*oss_provider\s*=", chunks[0]):
|
|
if chunks[0] and not chunks[0].endswith("\n"):
|
|
chunks[0] += "\n"
|
|
chunks[0] += f'oss_provider = "{_CODEX_PROFILE}"\n'
|
|
# Drop the provider table and any stale [model_providers.unsloth_api.*] subtables.
|
|
stale = (_PROVIDER_HEADER, _PROVIDER_HEADER[:-1] + ".")
|
|
text = "".join(c for c in chunks if not c.startswith(stale))
|
|
if not text.endswith("\n"):
|
|
text += "\n"
|
|
if not text.endswith("\n\n"):
|
|
text += "\n"
|
|
return text + (
|
|
f"{_PROVIDER_HEADER}\n"
|
|
'name = "Unsloth Studio"\n'
|
|
f"base_url = {json.dumps(base + '/v1')}\n"
|
|
f'env_key = "{_CODEX_ENV_KEY}"\n'
|
|
'wire_api = "responses"\n'
|
|
"requires_openai_auth = false\n"
|
|
)
|
|
|
|
|
|
# Keep custom-model behavior aligned with Codex's own unknown-model fallback. This
|
|
# Apache-2.0 prompt is copied from openai/codex rust-v0.144.0 models-manager/prompt.md.
|
|
_CODEX_FALLBACK_PROMPT = Path(__file__).parent.parent / "codex_fallback_prompt.md"
|
|
_CODEX_MODEL_CATALOG_MIN_VERSION = (0, 110, 0)
|
|
|
|
|
|
def _codex_supports_model_catalog() -> bool:
|
|
executable = _which_with_install_dirs("codex")
|
|
if executable is None:
|
|
# A --no-launch recipe may be copied to another machine; assume a current Codex.
|
|
return True
|
|
try:
|
|
output = subprocess.check_output(
|
|
[executable, "--version"], text = True, timeout = 10, stderr = subprocess.DEVNULL
|
|
)
|
|
except Exception:
|
|
return False
|
|
match = re.search(r"(\d+)\.(\d+)\.(\d+)", output)
|
|
return bool(match) and tuple(int(part) for part in match.groups()) >= (
|
|
_CODEX_MODEL_CATALOG_MIN_VERSION
|
|
)
|
|
|
|
|
|
def _codex_model_catalog(model: dict) -> dict:
|
|
"""Return conservative metadata for an Unsloth model unknown to Codex's built-in catalog."""
|
|
model_id = model["id"]
|
|
window = model.get("context_length") or model.get("max_context_length")
|
|
entry = {
|
|
"slug": model_id,
|
|
"display_name": model_id,
|
|
"description": "Model served by Unsloth Studio",
|
|
"supported_reasoning_levels": [],
|
|
"shell_type": "default",
|
|
"visibility": "none",
|
|
"supported_in_api": True,
|
|
"priority": 99,
|
|
"availability_nux": None,
|
|
"upgrade": None,
|
|
"base_instructions": _CODEX_FALLBACK_PROMPT.read_text(encoding = "utf-8"),
|
|
"supports_reasoning_summaries": False,
|
|
"supports_reasoning_summary_parameter": False,
|
|
"support_verbosity": False,
|
|
"default_verbosity": None,
|
|
"apply_patch_tool_type": None,
|
|
"truncation_policy": {"mode": "bytes", "limit": 10_000},
|
|
"supports_parallel_tool_calls": False,
|
|
"experimental_supported_tools": [],
|
|
}
|
|
if window:
|
|
entry["context_window"] = int(window)
|
|
entry["max_context_window"] = int(window)
|
|
return {"models": [entry]}
|
|
|
|
|
|
def write_codex_config(base: str, model: dict, home: Path) -> None:
|
|
home.mkdir(parents = True, exist_ok = True)
|
|
|
|
config = home / "config.toml"
|
|
existing = config.read_text(encoding = "utf-8") if config.exists() else ""
|
|
merged = _merge_codex_config(existing, base)
|
|
if merged != existing:
|
|
config.write_text(merged, encoding = "utf-8")
|
|
typer.echo(f"Updated {config}")
|
|
|
|
# oss_provider here too: codex --oss picks the provider from it, and the
|
|
# profile layer must beat a user-set value (e.g. "ollama") in config.toml.
|
|
profile_text = (
|
|
f'oss_provider = "{_CODEX_PROFILE}"\n'
|
|
f'model_provider = "{_CODEX_PROFILE}"\n'
|
|
f"model = {json.dumps(model['id'])}\n"
|
|
)
|
|
if _codex_supports_model_catalog() and _CODEX_FALLBACK_PROMPT.is_file():
|
|
catalog = home / "model-catalog.json"
|
|
catalog_text = json.dumps(_codex_model_catalog(model), indent = 2) + "\n"
|
|
if not catalog.exists() or catalog.read_text(encoding = "utf-8") != catalog_text:
|
|
catalog.write_text(catalog_text, encoding = "utf-8")
|
|
typer.echo(f"Updated {catalog}")
|
|
# Resolve relative to the profile file. This also survives WSL launching a Windows
|
|
# Codex binary, where a Linux absolute path inside TOML would not be usable.
|
|
profile_text += f"model_catalog_json = {json.dumps(catalog.name)}\n"
|
|
|
|
window = model.get("context_length") or model.get("max_context_length")
|
|
if window:
|
|
profile_text += f"model_context_window = {int(window)}\n"
|
|
profile = home / f"{_CODEX_PROFILE}.config.toml"
|
|
if not profile.exists() or profile.read_text(encoding = "utf-8") != profile_text:
|
|
profile.write_text(profile_text, encoding = "utf-8")
|
|
typer.echo(f"Updated {profile}")
|
|
|
|
|
|
def write_codex_subagent_bridge(
|
|
base: str, key: str, model: dict, home: Path, *, yolo: bool
|
|
) -> Path:
|
|
"""Write private config for an explicit local Codex child launched through MCP."""
|
|
child_home = home / "child"
|
|
write_codex_config(base, model, child_home)
|
|
path = home / "subagent.json"
|
|
_write_private_json(
|
|
path,
|
|
{
|
|
"api_key": key,
|
|
"codex_home": str(child_home),
|
|
"bypass_permissions": yolo,
|
|
},
|
|
)
|
|
return path
|
|
|
|
|
|
def _wsl_windows_user_profile(executable: str) -> Path:
|
|
"""Return the Windows user profile as a path accessible from WSL."""
|
|
profile = os.environ.get("USERPROFILE", "").strip()
|
|
if not profile:
|
|
try:
|
|
profile = subprocess.check_output(
|
|
["cmd.exe", "/d", "/c", "echo %USERPROFILE%"],
|
|
text = True,
|
|
stderr = subprocess.DEVNULL,
|
|
cwd = str(Path(executable).parent),
|
|
).strip()
|
|
except (OSError, subprocess.CalledProcessError) as exc:
|
|
_fail(f"Could not find the Windows user profile for Codex: {exc}")
|
|
if not profile or profile == "%USERPROFILE%":
|
|
_fail("Could not find the Windows user profile for Codex.")
|
|
if profile.startswith("/"):
|
|
return Path(profile)
|
|
try:
|
|
translated = subprocess.check_output(
|
|
["wslpath", "-u", profile],
|
|
text = True,
|
|
stderr = subprocess.DEVNULL,
|
|
).strip()
|
|
except (OSError, subprocess.CalledProcessError) as exc:
|
|
_fail(f"Could not translate Windows user profile {profile}: {exc}")
|
|
if not translated:
|
|
_fail(f"Could not translate Windows user profile {profile}.")
|
|
return Path(translated)
|
|
|
|
|
|
def _codex_source_home(*, ignore_configured: bool = False) -> Path:
|
|
configured = None if ignore_configured else os.environ.get("CODEX_HOME")
|
|
if configured:
|
|
if _wsl_windows_executable(["codex"]) and _looks_like_path(configured):
|
|
if not configured.startswith("/"):
|
|
try:
|
|
configured = subprocess.check_output(
|
|
["wslpath", "-u", configured],
|
|
text = True,
|
|
stderr = subprocess.DEVNULL,
|
|
).strip()
|
|
except (OSError, subprocess.CalledProcessError) as exc:
|
|
_fail(f"Could not translate Windows CODEX_HOME {configured}: {exc}")
|
|
if not configured:
|
|
_fail("Could not translate Windows CODEX_HOME.")
|
|
return Path(configured).expanduser()
|
|
executable = _wsl_windows_executable(["codex"])
|
|
if executable:
|
|
return _wsl_windows_user_profile(executable) / ".codex"
|
|
return Path.home() / ".codex"
|
|
|
|
|
|
def _remove_overlay_entry(path: Path) -> None:
|
|
is_junction = getattr(path, "is_junction", None)
|
|
if is_junction and is_junction():
|
|
path.rmdir()
|
|
elif path.is_symlink() or path.is_file():
|
|
path.unlink()
|
|
elif path.is_dir():
|
|
shutil.rmtree(path)
|
|
elif path.exists():
|
|
path.unlink()
|
|
|
|
|
|
def _create_directory_junction(source: Path, target: Path) -> bool:
|
|
if os.name != "nt":
|
|
return False
|
|
try:
|
|
result = subprocess.run(
|
|
["cmd.exe", "/d", "/c", "mklink", "/J", str(target), str(source)],
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 30,
|
|
check = False,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return False
|
|
return result.returncode == 0
|
|
|
|
|
|
def write_codex_parent_overlay(overlay: Path) -> Path:
|
|
"""Add local-agent routing without replacing the cloud parent's configuration."""
|
|
overlay.mkdir(parents = True, exist_ok = True, mode = 0o700)
|
|
|
|
manifest_path = overlay / _CODEX_PARENT_OVERLAY_MANIFEST
|
|
try:
|
|
manifest = json.loads(manifest_path.read_text(encoding = "utf-8"))
|
|
except (FileNotFoundError, OSError, json.JSONDecodeError):
|
|
manifest = None
|
|
source_home = _codex_source_home()
|
|
overlay_key = str(overlay.resolve(strict = False))
|
|
source_key = str(source_home.resolve(strict = False))
|
|
if source_key == overlay_key:
|
|
previous_source = manifest.get("source_home") if isinstance(manifest, dict) else None
|
|
if isinstance(previous_source, str) and previous_source:
|
|
candidate = Path(previous_source).expanduser()
|
|
if str(candidate.resolve(strict = False)) != overlay_key:
|
|
source_home = candidate
|
|
else:
|
|
source_home = _codex_source_home(ignore_configured = True)
|
|
else:
|
|
source_home = _codex_source_home(ignore_configured = True)
|
|
source_key = str(source_home.resolve(strict = False))
|
|
same_source = isinstance(manifest, dict) and manifest.get("source_home") == source_key
|
|
if same_source:
|
|
managed_entries = manifest.get("entries", [])
|
|
if not isinstance(managed_entries, list):
|
|
managed_entries = []
|
|
for name in managed_entries:
|
|
if isinstance(name, str) and name not in {"", ".", ".."} and Path(name).name == name:
|
|
_remove_overlay_entry(overlay / name)
|
|
else:
|
|
# A reused overlay must never mix credentials, config, or plugins from two
|
|
# different Codex homes. Legacy overlays have no manifest, so rebuild them once.
|
|
for target in list(overlay.iterdir()):
|
|
_remove_overlay_entry(target)
|
|
|
|
# Keep the user's auth, config, plugins, agents, skills, rules, and session state visible.
|
|
# Symlinks make this an overlay rather than a stale copy. If Windows denies them,
|
|
# use directory junctions so large runtime state remains shared without a bulk copy.
|
|
# Copy the configuration surfaces and sessions only if both link forms are unavailable.
|
|
fallback_dirs = {"agents", "skills", "rules", "plugins", "marketplaces", "sessions"}
|
|
entries = []
|
|
if source_home.is_dir():
|
|
for source in source_home.iterdir():
|
|
if source.name in {
|
|
"AGENTS.md",
|
|
"AGENTS.override.md",
|
|
_CODEX_PARENT_OVERLAY_MANIFEST,
|
|
}:
|
|
continue
|
|
target = overlay / source.name
|
|
_remove_overlay_entry(target)
|
|
try:
|
|
target.symlink_to(source, target_is_directory = source.is_dir())
|
|
entries.append(source.name)
|
|
except OSError:
|
|
if source.is_file():
|
|
shutil.copy2(source, target)
|
|
entries.append(source.name)
|
|
elif source.is_dir():
|
|
if _create_directory_junction(source, target):
|
|
entries.append(source.name)
|
|
elif source.name in fallback_dirs:
|
|
shutil.copytree(source, target)
|
|
entries.append(source.name)
|
|
|
|
_write_private_json(
|
|
manifest_path,
|
|
{"source_home": source_key, "entries": sorted(entries)},
|
|
)
|
|
|
|
inherited = ""
|
|
instruction_name = "AGENTS.md"
|
|
for candidate in (source_home / "AGENTS.override.md", source_home / "AGENTS.md"):
|
|
try:
|
|
text = candidate.read_text(encoding = "utf-8")
|
|
except FileNotFoundError:
|
|
continue
|
|
except OSError as exc:
|
|
_fail(f"Could not preserve Codex instructions from {candidate}: {exc}")
|
|
if text.strip():
|
|
inherited = text.rstrip()
|
|
instruction_name = candidate.name
|
|
break
|
|
|
|
other_name = "AGENTS.md" if instruction_name == "AGENTS.override.md" else "AGENTS.override.md"
|
|
other = overlay / other_name
|
|
if other.is_file() or other.is_symlink():
|
|
other.unlink()
|
|
routing = _CODEX_SUBAGENT_ROUTING_INSTRUCTIONS
|
|
combined = f"{inherited}\n\n{routing}\n" if inherited else f"{routing}\n"
|
|
_write_private_text(overlay / instruction_name, combined)
|
|
return overlay
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _codex_parent_overlay(session_home: Path, *, launch: bool, persist: bool):
|
|
if launch and not persist:
|
|
temp_root = _agents_config_root() / ".tmp"
|
|
temp_root.mkdir(parents = True, exist_ok = True, mode = 0o700)
|
|
overlay = Path(tempfile.mkdtemp(prefix = "codex-parent-", dir = temp_root))
|
|
try:
|
|
yield write_codex_parent_overlay(overlay)
|
|
finally:
|
|
shutil.rmtree(overlay, ignore_errors = True)
|
|
else:
|
|
yield write_codex_parent_overlay(session_home / "parent")
|
|
|
|
|
|
def _agent_config_path(path: Path, command: list) -> str:
|
|
"""Translate a generated config path when a Windows agent runs through WSL."""
|
|
return _wsl_windows_path(path) if _wsl_windows_executable(command) else str(path)
|
|
|
|
|
|
def _opencode_subagent_inline_config(path: Path, permission: dict) -> dict:
|
|
"""Keep the local provider visible without hiding the parent's allowed providers."""
|
|
inline: dict = {}
|
|
inherited = os.environ.get("OPENCODE_CONFIG_CONTENT")
|
|
if inherited:
|
|
try:
|
|
parsed = json.loads(inherited)
|
|
except ValueError:
|
|
_fail("OPENCODE_CONFIG_CONTENT is not valid JSON.")
|
|
if not isinstance(parsed, dict):
|
|
_fail("OPENCODE_CONFIG_CONTENT must contain a JSON object.")
|
|
inline.update(parsed)
|
|
|
|
def merge_provider_filters(effective_config: dict) -> None:
|
|
enabled = effective_config.get("enabled_providers")
|
|
if isinstance(enabled, list):
|
|
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"] = [
|
|
provider for provider in disabled if provider != _OPENCODE_PROVIDER
|
|
]
|
|
|
|
# The inherited inline layer is already highest priority. Merge it even when
|
|
# OpenCode is not installed yet, as in fresh-install and --no-launch flows.
|
|
merge_provider_filters(inline)
|
|
effective = inline
|
|
|
|
executable = _which_with_install_dirs("opencode")
|
|
if executable is None:
|
|
typer.echo(
|
|
f"Warning: OpenCode is not installed, so provider filters could not be checked. "
|
|
f"The target configuration must allow '{_OPENCODE_PROVIDER}'.",
|
|
err = True,
|
|
)
|
|
else:
|
|
env = os.environ.copy()
|
|
env["OPENCODE_CONFIG"] = _agent_config_path(path, ["opencode"])
|
|
try:
|
|
resolved = subprocess.run(
|
|
[executable, "debug", "config"],
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 15,
|
|
env = env,
|
|
)
|
|
except Exception as exc:
|
|
_fail(f"Could not inspect OpenCode provider filters: {exc}")
|
|
if resolved.returncode != 0:
|
|
detail = resolved.stderr.strip() or resolved.stdout.strip()
|
|
_fail(f"Could not inspect OpenCode provider filters: {detail or 'unknown error'}")
|
|
try:
|
|
effective = json.loads(resolved.stdout)
|
|
except ValueError:
|
|
_fail("Could not inspect OpenCode provider filters: invalid JSON response.")
|
|
if not isinstance(effective, dict):
|
|
_fail("Could not inspect OpenCode provider filters: expected a JSON object.")
|
|
|
|
merge_provider_filters(effective)
|
|
|
|
depth = effective.get("subagent_depth")
|
|
inline["subagent_depth"] = (
|
|
depth if isinstance(depth, int) and not isinstance(depth, bool) and depth > 0 else 1
|
|
)
|
|
if permission:
|
|
inline["permission"] = permission
|
|
return inline
|
|
|
|
|
|
def _b64_path(path: Path) -> str:
|
|
"""Path as base64, so it can cross a shell without being expanded."""
|
|
return base64.b64encode(str(path).encode("utf-8")).decode("ascii")
|
|
|
|
|
|
_CLAUDE_PLAN_GATE_SCRIPT = '''\
|
|
"""Deny the editing agent while the parent session is in plan mode."""
|
|
import json, sys
|
|
|
|
try:
|
|
mode = (json.load(sys.stdin) or {}).get("permission_mode")
|
|
except Exception:
|
|
sys.exit(0) # fail open: a hook error must never block the parent session
|
|
if mode == "plan":
|
|
print(json.dumps({"hookSpecificOutput": {
|
|
"hookEventName": "PreToolUse",
|
|
"permissionDecision": "deny",
|
|
"permissionDecisionReason": (
|
|
"Plan mode is active. Call the read-only Unsloth plan agent "
|
|
"(unsloth_plan_agent) instead of unsloth_agent."
|
|
),
|
|
}}))
|
|
sys.exit(0)
|
|
'''
|
|
|
|
|
|
def write_claude_subagent_plugin(path: Path, server_env: dict) -> Path:
|
|
"""Write a session plugin that exposes the local Claude child through MCP."""
|
|
plugin = path / "unsloth-local-agent"
|
|
command = sys.executable
|
|
args = ["-m", _CLAUDE_SUBAGENT_MCP_MODULE]
|
|
mcp_env = dict(server_env)
|
|
if _wsl_windows_executable(["claude"]):
|
|
command = "wsl.exe"
|
|
args = [
|
|
"-d",
|
|
os.environ["WSL_DISTRO_NAME"],
|
|
"--",
|
|
sys.executable,
|
|
"-m",
|
|
_CLAUDE_SUBAGENT_MCP_MODULE,
|
|
]
|
|
mcp_env["WSLENV"] = _merge_wslenv(
|
|
os.environ.get("WSLENV", ""),
|
|
_wsl_bridge_names(server_env, ()),
|
|
)
|
|
_write_private_json(
|
|
plugin / ".claude-plugin" / "plugin.json",
|
|
{
|
|
"name": "unsloth-local-agent",
|
|
"version": "1.0.0",
|
|
"description": _SUBAGENT_DESCRIPTION,
|
|
"author": {"name": "Unsloth AI"},
|
|
},
|
|
)
|
|
_write_private_json(
|
|
plugin / ".mcp.json",
|
|
{
|
|
"mcpServers": {
|
|
"unsloth": {
|
|
"type": "stdio",
|
|
"command": command,
|
|
"args": args,
|
|
"env": mcp_env,
|
|
}
|
|
}
|
|
},
|
|
)
|
|
# Claude already refuses the editing tool in plan mode, since it advertises
|
|
# readOnlyHint false. This PreToolUse hook replaces that dead end with a reason
|
|
# naming the read-only tool to call instead. Skipped under the WSL bridge, where
|
|
# the gate is a Linux path but the hook would run beside the Windows claude.
|
|
gate = plugin / "hooks" / "plan_gate.py"
|
|
if command == "wsl.exe":
|
|
# A persisted plugin dir may still hold a gate from an earlier non-WSL run.
|
|
for stale in (gate, plugin / "hooks" / "hooks.json"):
|
|
stale.unlink(missing_ok = True)
|
|
else:
|
|
_write_private_text(gate, _CLAUDE_PLAN_GATE_SCRIPT)
|
|
_write_private_json(
|
|
plugin / "hooks" / "hooks.json",
|
|
{
|
|
"hooks": {
|
|
"PreToolUse": [
|
|
{
|
|
"matcher": _CLAUDE_SUBAGENT_TOOL,
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
# Run through runpy rather than handing the path to
|
|
# the interpreter: a missing gate is then an
|
|
# ordinary traceback (exit 1, fails open) instead
|
|
# of exit 2, which Claude treats as a blocking
|
|
# error and would deny the tool in every mode.
|
|
# The path is base64'd because this string goes
|
|
# through a shell: a temp root holding $(..) or a
|
|
# backtick expands under sh, %VAR% under cmd, and
|
|
# the gate then silently fails open. base64's
|
|
# alphabet has no metacharacter in either.
|
|
"command": (
|
|
f'"{sys.executable}" -c '
|
|
f'"import base64,runpy; runpy.run_path('
|
|
f"base64.b64decode('{_b64_path(gate)}').decode())\""
|
|
),
|
|
# A hook with no timeout stalls the parent for as
|
|
# long as it hangs; measured unbounded past 400s.
|
|
"timeout": 10,
|
|
}
|
|
],
|
|
}
|
|
]
|
|
}
|
|
},
|
|
)
|
|
skill = plugin / "skills" / "local-agent" / "SKILL.md"
|
|
skill.parent.mkdir(parents = True, exist_ok = True, mode = 0o700)
|
|
skill.write_text(
|
|
"---\n"
|
|
"description: Delegate a task to the local agent powered by Unsloth. Use when the "
|
|
"user asks to spawn an Unsloth agent or local agent.\n"
|
|
"---\n\n"
|
|
"Call the Unsloth local agent tool once with the complete task. In plan mode, call "
|
|
"the read-only Unsloth plan agent instead. Return its result to the user without "
|
|
"claiming that the cloud parent completed the local work.\n",
|
|
encoding = "utf-8",
|
|
)
|
|
return plugin
|
|
|
|
|
|
def _codex_subagent_flags(path: Path) -> list[str]:
|
|
command = sys.executable
|
|
package_root = str(Path(__file__).resolve().parents[2])
|
|
bootstrap = (
|
|
f"import sys;sys.path.insert(0,{json.dumps(package_root)});"
|
|
f"from {_CODEX_SUBAGENT_MCP_MODULE} import main;main()"
|
|
)
|
|
args = ["-c", bootstrap, str(path)]
|
|
if _wsl_windows_executable(["codex"]):
|
|
command = "wsl.exe"
|
|
args = [
|
|
"-d",
|
|
os.environ["WSL_DISTRO_NAME"],
|
|
"--",
|
|
sys.executable,
|
|
"-c",
|
|
bootstrap,
|
|
str(path),
|
|
]
|
|
server = (
|
|
"{ "
|
|
f"command = {json.dumps(command)}, "
|
|
f"args = {json.dumps(args)}, "
|
|
f"required = true, enabled_tools = [{json.dumps(_CODEX_SUBAGENT_MCP_TOOL)}], "
|
|
'default_tools_approval_mode = "approve", '
|
|
"startup_timeout_sec = 15, tool_timeout_sec = 3600 }"
|
|
)
|
|
return ["-c", f"mcp_servers.{_CODEX_SUBAGENT_MCP_SERVER}={server}"]
|
|
|
|
|
|
def _wsl_windows_executable(command: list) -> Optional[str]:
|
|
if os.name == "nt" or not os.environ.get("WSL_DISTRO_NAME"):
|
|
return None
|
|
executable = shutil.which(command[0])
|
|
if executable and executable.startswith("/mnt/"):
|
|
return executable
|
|
return None
|
|
|
|
|
|
def _wsl_windows_path(path: Path) -> str:
|
|
try:
|
|
translated = subprocess.check_output(["wslpath", "-w", str(path)], text = True).strip()
|
|
except (OSError, subprocess.CalledProcessError) as exc:
|
|
_fail(f"Could not translate WSL path {path}: {exc}")
|
|
if not translated:
|
|
_fail(f"Could not translate WSL path {path}")
|
|
return translated
|
|
|
|
|
|
def _looks_like_path(value: str) -> bool:
|
|
# A var only wants the WSLENV /p flag if its value is a filesystem path: an
|
|
# absolute POSIX path (/...), a UNC path (\\...), or a drive-qualified Windows
|
|
# path (C:...). Scalar knobs (e.g. a numeric context window) must pass through
|
|
# untranslated, so they get no flag.
|
|
return bool(value) and (value.startswith(("/", "\\")) or (len(value) >= 2 and value[1] == ":"))
|
|
|
|
|
|
def _wsl_bridge_names(env: dict, unset_env: tuple) -> tuple:
|
|
# Build the WSLENV share list for a Windows shim reached from WSL. Path-valued
|
|
# vars get /p so WSLENV translates them to the Windows path the /mnt shim can
|
|
# actually open; a cleared var carries no value to translate.
|
|
names = [name + ("/p" if _looks_like_path(value) else "") for name, value in env.items()]
|
|
names.extend(unset_env)
|
|
return tuple(dict.fromkeys(names))
|
|
|
|
|
|
def _merge_wslenv(current: str, names: tuple) -> str:
|
|
# Index WSLENV entries by bare var name, preserving first-seen order. The vars we
|
|
# bridge are applied last so our entry wins: a user's pre-existing unflagged "HOME"
|
|
# is upgraded to "HOME/p" (rather than left as-is), since WSLENV ignores a duplicate
|
|
# name and a bare entry would leave the path untranslated for a Windows shim.
|
|
ordered = []
|
|
by_name = {}
|
|
for entry in (*current.split(":"), *names):
|
|
if not entry:
|
|
continue
|
|
base = entry.split("/", 1)[0]
|
|
if base not in by_name:
|
|
ordered.append(base)
|
|
by_name[base] = entry
|
|
return ":".join(by_name[base] for base in ordered)
|
|
|
|
|
|
def _powershell_quote(arg: str) -> str:
|
|
# PowerShell reads single-quoted strings literally (an embedded ' is doubled), so
|
|
# JSON args such as `--settings {"env":...}` survive intact. list2cmdline's
|
|
# backslash-escaped double quotes are cmd.exe syntax and PowerShell mis-parses them.
|
|
if arg and re.fullmatch(r"[A-Za-z0-9_./:=+-]+", arg):
|
|
return arg
|
|
return "'" + arg.replace("'", "''") + "'"
|
|
|
|
|
|
def _print_env(
|
|
env: dict,
|
|
command: list,
|
|
unset_env: tuple = (),
|
|
wsl_env_bridge: tuple = (),
|
|
) -> None:
|
|
if os.name == "nt":
|
|
for name in unset_env:
|
|
typer.echo(f"Remove-Item Env:{name} -ErrorAction SilentlyContinue")
|
|
for name, value in env.items():
|
|
# PowerShell: ` is the escape char, and $ triggers expansion inside "".
|
|
escaped = value.replace("`", "``").replace('"', '`"').replace("$", "`$")
|
|
typer.echo(f'$env:{name} = "{escaped}"')
|
|
typer.echo(" ".join(_powershell_quote(arg) for arg in command))
|
|
return
|
|
for name in unset_env:
|
|
typer.echo(f"export {name}=" if wsl_env_bridge else f"unset {name}")
|
|
for name, value in env.items():
|
|
typer.echo(f"export {name}={shlex.quote(value)}")
|
|
if wsl_env_bridge:
|
|
typer.echo(
|
|
f"export WSLENV={shlex.quote(_merge_wslenv(os.environ.get('WSLENV', ''), wsl_env_bridge))}"
|
|
)
|
|
# The final line is a SELF-CONTAINED one-liner (inline env, VAR=... cmd) rather than a
|
|
# bare command. People copy just the last line, and a bare `codex`/`claude` would then
|
|
# run against their real ~/.codex or Anthropic credentials with zero isolation -- e.g.
|
|
# inheriting a pre-existing damaged ~/.codex state DB and blaming the recipe. Inline
|
|
# assignments scope every var (and empty-string the conflicting ones) to this single
|
|
# invocation, so a partial copy behaves the same as pasting the whole block.
|
|
inline = [f"{name}=" for name in unset_env]
|
|
inline += [f"{name}={shlex.quote(value)}" for name, value in env.items()]
|
|
if wsl_env_bridge:
|
|
inline.append(
|
|
f"WSLENV={shlex.quote(_merge_wslenv(os.environ.get('WSLENV', ''), wsl_env_bridge))}"
|
|
)
|
|
typer.echo(" ".join((*inline, shlex.join(command))))
|
|
|
|
|
|
def _refresh_windows_path() -> None:
|
|
# Merge Windows registry PATH hives after the current process PATH so a
|
|
# freshly installed agent is visible without changing existing precedence.
|
|
if os.name != "nt":
|
|
return
|
|
try:
|
|
import winreg
|
|
except Exception:
|
|
return
|
|
|
|
entries = []
|
|
seen = set()
|
|
|
|
def add_path(value: str) -> bool:
|
|
added = False
|
|
for entry in str(value).split(os.pathsep):
|
|
entry = entry.strip()
|
|
if not entry:
|
|
continue
|
|
key = os.path.normcase(entry).casefold()
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
entries.append(entry)
|
|
added = True
|
|
return added
|
|
|
|
add_path(os.environ.get("PATH", ""))
|
|
added_registry = False
|
|
hives = (
|
|
(winreg.HKEY_CURRENT_USER, "Environment"),
|
|
(
|
|
winreg.HKEY_LOCAL_MACHINE,
|
|
r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment",
|
|
),
|
|
)
|
|
for root, sub in hives:
|
|
try:
|
|
with winreg.OpenKey(root, sub) as key:
|
|
value, _ = winreg.QueryValueEx(key, "Path")
|
|
except OSError:
|
|
continue
|
|
if value:
|
|
added_registry = add_path(os.path.expandvars(str(value))) or added_registry
|
|
if added_registry:
|
|
os.environ["PATH"] = os.pathsep.join(entries)
|
|
|
|
|
|
def _augment_path_with_install_dirs() -> None:
|
|
# Append known install dirs to PATH so a freshly installed agent resolves without a new
|
|
# shell: some installers write the binary but not PATH (claude drops ~/.local/bin and
|
|
# only prints a note; npm -g shims land in %APPDATA%\npm). Appended, so precedence holds.
|
|
try:
|
|
home = Path.home()
|
|
except (RuntimeError, OSError):
|
|
return
|
|
candidates = [home / ".local" / "bin"]
|
|
if os.name == "nt":
|
|
appdata = os.environ.get("APPDATA")
|
|
if appdata:
|
|
candidates.append(Path(appdata) / "npm")
|
|
current = os.environ.get("PATH")
|
|
if current is None:
|
|
# PATH unset: shutil.which() and exec*p* fall back to os.defpath (e.g. /bin:/usr/bin), so
|
|
# keep that default instead of collapsing to just the install dirs (which would hide a
|
|
# system-installed agent and strip the launched child's normal PATH). An explicitly empty
|
|
# PATH is left as-is: like shutil.which, it means "search nothing", not os.defpath.
|
|
current = os.defpath
|
|
seen = {os.path.normcase(entry) for entry in current.split(os.pathsep) if entry}
|
|
additions = [
|
|
str(directory)
|
|
for directory in candidates
|
|
if directory.is_dir() and os.path.normcase(str(directory)) not in seen
|
|
]
|
|
if additions:
|
|
os.environ["PATH"] = os.pathsep.join([current, *additions] if current else additions)
|
|
|
|
|
|
def _which_with_install_dirs(name: str) -> Optional[str]:
|
|
# shutil.which(name), but searching the known agent install dirs too, so a version probe
|
|
# resolves the same binary _launch() will (it augments PATH before it runs). Without this an
|
|
# agent present only in ~/.local/bin / %APPDATA%\npm is missed, wrongly assumed current, and
|
|
# launched with flags an older build rejects. PATH is restored afterward: only _launch()
|
|
# should persist the augmentation for the child process.
|
|
original = os.environ.get("PATH")
|
|
_augment_path_with_install_dirs()
|
|
try:
|
|
return shutil.which(name)
|
|
finally:
|
|
if original is None:
|
|
os.environ.pop("PATH", None)
|
|
else:
|
|
os.environ["PATH"] = original
|
|
|
|
|
|
def _install_source(install_hint: str) -> Optional[str]:
|
|
"""The first http(s) URL an install hint fetches, or None (e.g. an npm install)."""
|
|
match = re.search(r"https?://[^\s'\")]+", install_hint)
|
|
return match.group(0) if match else None
|
|
|
|
|
|
def _pinned_raw_github_commit(source: str) -> Optional[str]:
|
|
"""Return the immutable full commit in a raw GitHub URL, if present."""
|
|
match = re.match(
|
|
r"^https://raw\.githubusercontent\.com/[^/]+/[^/]+/([0-9a-f]{40})/",
|
|
source,
|
|
flags = re.IGNORECASE,
|
|
)
|
|
return match.group(1).lower() if match else None
|
|
|
|
|
|
def _install_agent(name: str, install_hint: str) -> Optional[str]:
|
|
# Missing agent under --launch: offer to run its documented install command, then
|
|
# re-resolve it on PATH. Consent-based (we never auto-run a remote install script
|
|
# silently), and a non-interactive stdin cannot answer the prompt, so both the
|
|
# no-TTY and declined cases return None and let the caller print the hint and exit.
|
|
if not sys.stdin.isatty():
|
|
return None
|
|
typer.echo(f"`{name}` is not installed.")
|
|
# Make the supply-chain risk explicit before the prompt: these are the vendors'
|
|
# own installers (curl | bash, irm | iex, npm), run with the user's privileges,
|
|
# and nothing checks a signature or hash on the fetched content. Naming the source
|
|
# turns a blind "yes" into informed consent.
|
|
source = _install_source(install_hint)
|
|
if source:
|
|
pinned_commit = _pinned_raw_github_commit(source)
|
|
if pinned_commit:
|
|
warning = (
|
|
"Security warning: This will download and execute a third-party script "
|
|
f"from {source} with your privileges. Unsloth pins this content to "
|
|
f"immutable upstream commit {pinned_commit}, but does not independently "
|
|
"verify or sandbox it. Continue only if you trust this source and commit."
|
|
)
|
|
else:
|
|
warning = (
|
|
"Security warning: This will download and execute an unverified third-party "
|
|
f"script from {source} with your privileges. Unsloth does not pin or verify "
|
|
"the downloaded content. Continue only if you trust this source."
|
|
)
|
|
else:
|
|
warning = (
|
|
f"This will RUN `{install_hint}` with your privileges; "
|
|
"there is no signature or hash check."
|
|
)
|
|
typer.secho(warning, fg = "yellow", err = True)
|
|
if not typer.confirm(f"Install `{name}` now with `{install_hint}`?", default = False):
|
|
return None
|
|
# Run each hint through its shell: PowerShell on Windows, /bin/sh elsewhere.
|
|
# -ExecutionPolicy Bypass is process-scoped (nothing persistent) so npm's npm.ps1 and
|
|
# irm | iex run under the Windows default Restricted policy instead of failing with a
|
|
# PSSecurityException.
|
|
if os.name == "nt":
|
|
install_command = [
|
|
"powershell",
|
|
"-NoProfile",
|
|
"-ExecutionPolicy",
|
|
"Bypass",
|
|
"-Command",
|
|
install_hint,
|
|
]
|
|
else:
|
|
install_command = ["/bin/sh", "-c", install_hint]
|
|
if subprocess.run(install_command).returncode != 0:
|
|
message = f"Install command failed. Run it yourself, then re-run: {install_hint}"
|
|
if os.name == "nt":
|
|
# A hand-run retry can still hit the policy; point at the one-time per-user fix.
|
|
message += (
|
|
"\nIf it fails because running scripts is disabled (PSSecurityException), "
|
|
"allow local scripts for your user, then retry:\n"
|
|
" Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned"
|
|
)
|
|
_fail(message)
|
|
# Resolve the freshly installed agent without a shell restart: pull registry PATH
|
|
# (Windows) plus well-known install dirs the installer may not have added to PATH.
|
|
_refresh_windows_path()
|
|
_augment_path_with_install_dirs()
|
|
executable = shutil.which(name)
|
|
if executable is None:
|
|
_fail(
|
|
f"`{name}` installed but isn't on PATH yet. Open a new shell (or add it to "
|
|
f"PATH), then re-run. Install command: {install_hint}"
|
|
)
|
|
return executable
|
|
|
|
|
|
def _wsl_shim_env(command: list, env: dict, unset_env: tuple) -> tuple[dict, tuple]:
|
|
wsl_env_bridge = _wsl_bridge_names(env, unset_env) if _wsl_windows_executable(command) else ()
|
|
if not wsl_env_bridge:
|
|
return env, wsl_env_bridge
|
|
# Bridge PWD via WSLENV (PWD/p) so the Windows shim finds its project root from the
|
|
# live cwd, not a stale inherited Linux PWD. Don't freeze env["PWD"]: a --no-launch
|
|
# recipe must translate the live PWD when run, not when generated; _launch overrides it.
|
|
return env, (*wsl_env_bridge, "PWD/p")
|
|
|
|
|
|
def _launch(
|
|
command: list,
|
|
env: dict,
|
|
install_hint: str,
|
|
unset_env: tuple = (),
|
|
) -> int:
|
|
# Resolve well-known install dirs (e.g. ~/.local/bin) first, so an already-installed
|
|
# agent not yet on PATH is found instead of prompting a needless reinstall.
|
|
_augment_path_with_install_dirs()
|
|
executable = shutil.which(command[0]) or _install_agent(command[0], install_hint)
|
|
if executable is None:
|
|
_fail(f"`{command[0]}` not found on PATH. Install it with: {install_hint}")
|
|
env, wsl_env_bridge = _wsl_shim_env(command, env, unset_env)
|
|
child_env = dict(os.environ)
|
|
if wsl_env_bridge:
|
|
# Override stale inherited PWD with the real cwd so the shim resolves the project root.
|
|
env = {**env, "PWD": os.getcwd()}
|
|
child_env["WSLENV"] = _merge_wslenv(child_env.get("WSLENV", ""), wsl_env_bridge)
|
|
for name in unset_env:
|
|
child_env[name] = ""
|
|
else:
|
|
for name in unset_env:
|
|
child_env.pop(name, None)
|
|
child_env.update(env)
|
|
if os.name != "nt" and not wsl_env_bridge:
|
|
# Keep POSIX child processes from seeing a stale inherited PWD when
|
|
# subprocess cwd was changed by the caller. Some Node CLIs use PWD for
|
|
# project-root discovery instead of process.cwd().
|
|
child_env["PWD"] = os.getcwd()
|
|
# Ctrl+C cancels a turn inside the agent; don't let it kill this wrapper.
|
|
previous = signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
try:
|
|
code = subprocess.run([executable, *command[1:]], env = child_env).returncode
|
|
finally:
|
|
signal.signal(signal.SIGINT, previous)
|
|
# Negative returncode means killed by signal N; shells expect 128+N.
|
|
return code if code >= 0 else 128 - code
|
|
|
|
|
|
def _connect(
|
|
api_key: Optional[str],
|
|
model: Optional[str],
|
|
load: LoadOptions = LoadOptions(),
|
|
*,
|
|
serve: bool = False,
|
|
launch: bool = True,
|
|
server_options: ServerOptions = ServerOptions(),
|
|
) -> tuple:
|
|
# `--model org/name:QUANT` is shorthand for `--model org/name --gguf-variant QUANT`.
|
|
# Split it before we match/serve so the attach path resolves against the already-loaded
|
|
# `org/name` (listed without the suffix) instead of reloading a `:`-suffixed repo id --
|
|
# which Unsloth rejects and which would evict a model another session is using.
|
|
if model:
|
|
repo, variant = _split_repo_variant(model)
|
|
if variant:
|
|
model = repo
|
|
if not load.gguf_variant:
|
|
load = load._replace(gguf_variant = variant)
|
|
base, server = _require_studio(
|
|
model, load, serve = serve, launch = launch, server_options = server_options
|
|
)
|
|
try:
|
|
key = _agent_api_key(base, api_key, auto_started = server is not None)
|
|
# A server we just started has exactly the requested model loaded, so resolve to
|
|
# whatever it is serving instead of re-matching the raw --model string.
|
|
entry = _resolve_model(base, key, None if server is not None else model, load)
|
|
except BaseException:
|
|
_shutdown_auto_served()
|
|
raise
|
|
return base, key, entry
|
|
|
|
|
|
def _run(
|
|
base: str,
|
|
entry: dict,
|
|
env: dict,
|
|
command: list,
|
|
*,
|
|
launch: bool,
|
|
install_hint: str,
|
|
unset_env: tuple = (),
|
|
clear_screen: bool = False,
|
|
) -> None:
|
|
# Some agents (Pi) render inline from wherever the cursor sits: their first
|
|
# paint assumes a clean screen rather than clearing or entering the
|
|
# alternate screen themselves. Hand them one so the session doesn't start
|
|
# mid-scroll under our connection output. click.clear() is cross-platform
|
|
# and a no-op when stdout is not a terminal (piped/CI), so transcripts and
|
|
# --no-launch recipes stay intact.
|
|
if launch and clear_screen:
|
|
click.clear()
|
|
typer.echo(f"Unsloth ready at {base} · model {entry['id']}")
|
|
if not launch:
|
|
env, wsl_env_bridge = _wsl_shim_env(command, env, unset_env)
|
|
_print_env(env, command, unset_env = unset_env, wsl_env_bridge = wsl_env_bridge)
|
|
if _keep_auto_served():
|
|
typer.echo(f"Unsloth Studio is still running at {base}.")
|
|
typer.echo("Stop it with: unsloth studio stop")
|
|
return
|
|
try:
|
|
code = _launch(command, env, install_hint = install_hint, unset_env = unset_env)
|
|
except BaseException:
|
|
# Startup succeeded but the agent failed to launch; tear the server down
|
|
# rather than orphan it.
|
|
_shutdown_auto_served()
|
|
raise
|
|
auto_started = _auto_served_server is not None
|
|
kept = _keep_auto_served()
|
|
if auto_started and not kept:
|
|
typer.echo(f"The auto-started Unsloth server at {base} stopped during the session.")
|
|
raise typer.Exit(code = code)
|
|
if code:
|
|
# The server status below must not read as a successful agent session.
|
|
typer.echo(f"The agent exited with code {code}.")
|
|
if is_loopback_url(base):
|
|
typer.echo(f"Unsloth Studio is still running at {base}.")
|
|
typer.echo("Stop it with: unsloth studio stop")
|
|
else:
|
|
typer.echo(f"The remote Unsloth server is still running at {base}.")
|
|
raise typer.Exit(code = code)
|
|
|
|
|
|
def _agents_config_root() -> Path:
|
|
ensure_studio_backend_path()
|
|
from utils.paths import auth_root
|
|
return auth_root() / "agents"
|
|
|
|
|
|
def _ephemeral_session_parent(agent: str) -> Optional[Path]:
|
|
"""Return a non-system-temp parent when an agent needs one."""
|
|
if os.name != "nt" or agent != "codex":
|
|
return None
|
|
# Codex creates a deeply nested curated-plugin checkout below CODEX_HOME.
|
|
# A normal %TEMP%\unsloth-codex-* home can exceed legacy Windows path
|
|
# limits during startup, and Codex also refuses to create its PATH helpers
|
|
# below the system temp directory. Keep the throwaway home short but still
|
|
# private to the current user; _session_config removes it on exit.
|
|
root = Path.home() / ".unsloth" / ".tmp"
|
|
root.mkdir(parents = True, exist_ok = True, mode = 0o700)
|
|
return root
|
|
|
|
|
|
def _ephemeral_session_prefix(agent: str, parent: Optional[Path]) -> str:
|
|
"""Return the platform-specific prefix for an ephemeral agent home."""
|
|
return "u-codex-" if agent == "codex" and parent is not None else f"unsloth-{agent}-"
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _locked_file(path: Path, blocking: bool = True):
|
|
"""Yield whether an advisory lock was acquired for the first byte of path."""
|
|
handle = path.open("a+b")
|
|
acquired = False
|
|
try:
|
|
if os.name == "nt":
|
|
import msvcrt
|
|
|
|
handle.seek(0, os.SEEK_END)
|
|
if handle.tell() == 0:
|
|
handle.write(b"\0")
|
|
handle.flush()
|
|
handle.seek(0)
|
|
while True:
|
|
try:
|
|
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
acquired = True
|
|
break
|
|
except OSError as exc:
|
|
if exc.errno not in (errno.EACCES, errno.EAGAIN, errno.EDEADLK):
|
|
raise
|
|
if not blocking:
|
|
break
|
|
# LK_LOCK gives up after roughly ten seconds. Poll LK_NBLCK
|
|
# instead so a large stale plugin checkout cannot make a
|
|
# concurrent launch fail just because cleanup takes longer.
|
|
time.sleep(0.05)
|
|
else:
|
|
import fcntl
|
|
mode = fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB)
|
|
try:
|
|
fcntl.flock(handle.fileno(), mode)
|
|
acquired = True
|
|
except BlockingIOError:
|
|
acquired = False
|
|
yield acquired
|
|
finally:
|
|
if acquired:
|
|
if os.name == "nt":
|
|
handle.seek(0)
|
|
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
else:
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
handle.close()
|
|
|
|
|
|
def _reclaim_stale_ephemeral_sessions(parent: Path) -> None:
|
|
"""Remove abandoned short Codex homes while preserving locked live sessions."""
|
|
for path in parent.glob("u-codex-*"):
|
|
if not path.is_dir():
|
|
continue
|
|
active_lock = path / ".active.lock"
|
|
try:
|
|
modified = active_lock.stat().st_mtime if active_lock.exists() else path.stat().st_mtime
|
|
except FileNotFoundError:
|
|
continue
|
|
# The wrapper owns the advisory lock, not the Codex child. If only the
|
|
# wrapper is killed, its child may still be using CODEX_HOME; give that
|
|
# process a full day to finish before treating the unlocked home as stale.
|
|
if time.time() - modified < _CODEX_EPHEMERAL_STALE_SECONDS:
|
|
continue
|
|
try:
|
|
with _locked_file(active_lock, blocking = False) as stale:
|
|
pass
|
|
except FileNotFoundError:
|
|
# A normally exiting session may have removed itself after the glob.
|
|
continue
|
|
if stale:
|
|
shutil.rmtree(path, ignore_errors = True)
|
|
|
|
|
|
def _refresh_ephemeral_session_marker(path: Path, stop: threading.Event) -> None:
|
|
"""Keep the stale grace period relative to wrapper death, not session start."""
|
|
while not stop.wait(_CODEX_EPHEMERAL_HEARTBEAT_SECONDS):
|
|
with contextlib.suppress(OSError):
|
|
os.utime(path, None)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _short_ephemeral_session(parent: Path):
|
|
"""Create a short Codex home whose lock makes crash cleanup concurrency-safe."""
|
|
path = None
|
|
active_lock = contextlib.ExitStack()
|
|
heartbeat_stop = None
|
|
heartbeat = None
|
|
try:
|
|
with _locked_file(parent / ".cleanup.lock") as cleanup_lock:
|
|
if not cleanup_lock: # The blocking acquisition should always succeed.
|
|
raise RuntimeError(f"Could not lock ephemeral session root: {parent}")
|
|
_reclaim_stale_ephemeral_sessions(parent)
|
|
path = Path(tempfile.mkdtemp(prefix = "u-codex-", dir = parent))
|
|
locked = active_lock.enter_context(_locked_file(path / ".active.lock"))
|
|
if not locked:
|
|
raise RuntimeError(f"Could not lock ephemeral session home: {path}")
|
|
heartbeat_stop = threading.Event()
|
|
heartbeat = threading.Thread(
|
|
target = _refresh_ephemeral_session_marker,
|
|
args = (path / ".active.lock", heartbeat_stop),
|
|
name = "unsloth-codex-home-heartbeat",
|
|
daemon = True,
|
|
)
|
|
heartbeat.start()
|
|
yield path
|
|
finally:
|
|
if heartbeat_stop is not None:
|
|
heartbeat_stop.set()
|
|
if heartbeat is not None:
|
|
heartbeat.join(timeout = 1)
|
|
try:
|
|
with _locked_file(parent / ".cleanup.lock") as cleanup_lock:
|
|
if not cleanup_lock: # The blocking acquisition should always succeed.
|
|
raise RuntimeError(f"Could not lock ephemeral session root: {parent}")
|
|
# Release the live marker only after deletion is serialized with
|
|
# startup scavenging, so no scanner can race this rmtree.
|
|
active_lock.close()
|
|
if path is not None:
|
|
shutil.rmtree(path, ignore_errors = True)
|
|
finally:
|
|
active_lock.close()
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _session_config(
|
|
agent: str,
|
|
launch: bool,
|
|
persist: bool = False,
|
|
):
|
|
"""Yield a private directory for an agent's session config (never the user's own).
|
|
|
|
launch (default): an ephemeral temp dir removed after the agent process exits, so
|
|
nothing persists. no-launch: a stable Unsloth-owned dir (the printed recipe is run
|
|
later on this machine), reused across runs. persist (from --persist): use that same
|
|
stable dir even for a launch, so the agent's session survives the exit and can be
|
|
resumed next time. Either way the user's real ~/.<agent> config is left untouched.
|
|
"""
|
|
if launch and not persist:
|
|
parent = _ephemeral_session_parent(agent)
|
|
if parent is not None:
|
|
with _short_ephemeral_session(parent) as path:
|
|
yield path
|
|
else:
|
|
path = Path(tempfile.mkdtemp(prefix = _ephemeral_session_prefix(agent, parent)))
|
|
try:
|
|
yield path
|
|
finally:
|
|
shutil.rmtree(path, ignore_errors = True)
|
|
else:
|
|
# Never wipe this dir: a previously printed recipe may still be running
|
|
# an agent whose sessions/state live here, and every config writer
|
|
# merges idempotently into an existing home anyway. Writers must also
|
|
# reset any state a previous run's flags left behind (--yolo especially),
|
|
# since files here outlive the invocation that wrote them.
|
|
path = _agents_config_root() / agent
|
|
path.mkdir(parents = True, exist_ok = True, mode = 0o700)
|
|
yield path
|
|
|
|
|
|
def write_openclaw_config(
|
|
base: str,
|
|
key: str,
|
|
model: dict,
|
|
path: Path,
|
|
yolo: bool = False,
|
|
workspace_path: Optional[str] = None,
|
|
) -> None:
|
|
config = _read_json_object(path)
|
|
if config is None:
|
|
typer.echo(
|
|
f"Warning: couldn't parse {path} — add an 'unsloth' provider there "
|
|
"yourself, or move the file aside and re-run.",
|
|
err = True,
|
|
)
|
|
return
|
|
before = json.dumps(config, sort_keys = True)
|
|
# Unsloth is a generic OpenAI-compatible /v1 endpoint (the vLLM/LM Studio path).
|
|
provider_model = {"id": model["id"], "name": model["id"]}
|
|
window = model.get("context_length") or model.get("max_context_length")
|
|
if window:
|
|
provider_model["contextWindow"] = int(window)
|
|
models = _subdict(config, "models")
|
|
models.setdefault("mode", "merge")
|
|
_subdict(models, "providers")["unsloth"] = {
|
|
"baseUrl": f"{base}/v1",
|
|
"apiKey": key,
|
|
"api": "openai-completions",
|
|
"models": [provider_model],
|
|
}
|
|
# Pin a default model, else OpenClaw drops into its setup agent ("no models available").
|
|
agents = _subdict(config, "agents")
|
|
defaults = _subdict(agents, "defaults")
|
|
_subdict(defaults, "model")["primary"] = f"unsloth/{model['id']}"
|
|
# OPENCLAW_STATE_DIR does not relocate the workspace. Keep it beside the managed
|
|
# config so ephemeral launches avoid ~/.openclaw and persisted sessions retain it.
|
|
workspace = path.parent / "workspace"
|
|
workspace.mkdir(parents = True, exist_ok = True, mode = 0o700)
|
|
defaults["workspace"] = workspace_path or str(workspace)
|
|
# Per-agent paths override agents.defaults.workspace and OPENCLAW_STATE_DIR. This
|
|
# config is itself an isolated Unsloth copy, so remove stale explicit paths and let
|
|
# OpenClaw resolve every listed agent beneath the managed defaults/state directory.
|
|
agent_list = agents.get("list")
|
|
if isinstance(agent_list, list):
|
|
for agent_config in agent_list:
|
|
if isinstance(agent_config, dict):
|
|
agent_config.pop("workspace", None)
|
|
agent_config.pop("agentDir", None)
|
|
# Unauthenticated loopback gateway: without auth.mode=none the client won't open
|
|
# the websocket. The daemon must still be started separately (`openclaw gateway`).
|
|
gateway = _subdict(config, "gateway")
|
|
gateway.setdefault("mode", "local")
|
|
_subdict(gateway, "auth").setdefault("mode", "none")
|
|
if yolo:
|
|
# OpenClaw has no --yolo flag, and it gates tool execution on BOTH the
|
|
# tools.exec config AND a host-local approvals file (the stricter wins), so
|
|
# setting only the config still lets the agent prompt/deny. Set both, mirroring
|
|
# `openclaw exec-policy preset yolo`.
|
|
exec_policy = _subdict(_subdict(config, "tools"), "exec")
|
|
exec_policy["host"] = "gateway"
|
|
exec_policy["security"] = "full"
|
|
exec_policy["ask"] = "off"
|
|
# Approvals file in OPENCLAW_STATE_DIR (== this config's dir). ask=off means
|
|
# nothing is ever prompted, so the runtime socket block is unnecessary here.
|
|
approvals = path.parent / "exec-approvals.json"
|
|
_write_private_json(
|
|
approvals,
|
|
{"version": 1, "defaults": {"security": "full", "ask": "off", "askFallback": "full"}},
|
|
)
|
|
typer.echo(f"Updated {approvals}")
|
|
else:
|
|
# The no-launch config dir is reused across runs, so a previous --yolo run may
|
|
# have left auto-approval state behind. OpenClaw treats an omitted exec policy as
|
|
# security=full, ask=off on the gateway host, so deleting the keys would keep
|
|
# auto-approval on: a non-yolo run must WRITE a prompting policy. Only a
|
|
# permissive/yolo policy is replaced; a stricter one set by hand survives.
|
|
tools = config.get("tools")
|
|
exec_policy = tools.get("exec") if isinstance(tools, dict) else None
|
|
exec_policy = exec_policy if isinstance(exec_policy, dict) else {}
|
|
# Match ONLY the exact fingerprint --yolo writes (host=gateway, security=full,
|
|
# ask=off, all explicit, no mode); anything else is left untouched. host=auto or an
|
|
# omitted host resolves to security=deny under an active sandbox, so treating those
|
|
# as the permissive gateway default would broaden a fresh sandboxed config from
|
|
# deny to allowlist. host=node and host=sandbox are user-set (--yolo only writes
|
|
# gateway). tools.exec.mode is OpenClaw's normalized knob (it cannot be combined
|
|
# with security/ask, and OpenClaw never rewrites our security/ask write into it),
|
|
# so a mode is always a deliberate user policy; never clobber it.
|
|
permissive = (
|
|
"mode" not in exec_policy
|
|
and exec_policy.get("host") == "gateway"
|
|
and exec_policy.get("security") == "full"
|
|
and exec_policy.get("ask") == "off"
|
|
)
|
|
if permissive:
|
|
exec_policy = _subdict(_subdict(config, "tools"), "exec")
|
|
exec_policy.pop("host", None) # routing only; defaults to the gateway host
|
|
exec_policy["security"] = "allowlist" # only allowlisted commands skip approval
|
|
exec_policy["ask"] = "on-miss" # prompt on every non-allowlisted command
|
|
# Drop the yolo defaults from the host approvals file (a stricter default set by
|
|
# the user or OpenClaw is kept). With a prompting tools.exec the stricter of the
|
|
# two layers wins, so an omitted approvals default still prompts.
|
|
approvals = path.parent / "exec-approvals.json"
|
|
if approvals.exists():
|
|
state = _read_json_object(approvals)
|
|
if state is not None:
|
|
defaults = state.get("defaults")
|
|
# Strip the defaults only when they are exactly the yolo fingerprint; a
|
|
# user-managed mixed policy that merely shares a field (e.g. askFallback=full,
|
|
# whose omitted default is deny) must be kept intact.
|
|
yolo_defaults = (("security", "full"), ("ask", "off"), ("askFallback", "full"))
|
|
is_yolo = isinstance(defaults, dict) and all(
|
|
defaults.get(k) == v for k, v in yolo_defaults
|
|
)
|
|
if is_yolo:
|
|
for k, _ in yolo_defaults:
|
|
del defaults[k]
|
|
if not defaults:
|
|
del state["defaults"]
|
|
if set(state) <= {"version"}:
|
|
# Nothing left but our own yolo payload: remove it.
|
|
approvals.unlink()
|
|
typer.echo(f"Removed {approvals}")
|
|
else:
|
|
# Keep approvals OpenClaw itself recorded; only the yolo defaults go.
|
|
_write_private_json(approvals, state)
|
|
typer.echo(f"Updated {approvals}")
|
|
if json.dumps(config, sort_keys = True) != before:
|
|
_write_private_json(path, config)
|
|
typer.echo(f"Updated {path}")
|
|
|
|
|
|
def write_opencode_config(
|
|
base: str,
|
|
key: str,
|
|
model: dict,
|
|
path: Path,
|
|
yolo: bool = False,
|
|
as_subagent: bool = False,
|
|
) -> dict:
|
|
config = _read_json_object(path)
|
|
if config is None:
|
|
typer.echo(
|
|
f"Warning: couldn't parse {path} — add an '{_OPENCODE_PROVIDER}' provider "
|
|
"there yourself, or move the file aside and re-run.",
|
|
err = True,
|
|
)
|
|
return {}
|
|
before = json.dumps(config, sort_keys = True)
|
|
config.setdefault("$schema", "https://opencode.ai/config.json")
|
|
# Keep the provider definition in this private session file. The launch path
|
|
# adjusts effective provider filters in the higher-priority inline overlay.
|
|
model_entry = {"name": model["id"]}
|
|
window = model.get("context_length") or model.get("max_context_length")
|
|
if window:
|
|
window = int(window)
|
|
# A custom-provider model with no limit defaults to context 0, which silently
|
|
# disables OpenCode's auto-compaction; declare the real window (and a sane
|
|
# output cap) so it compacts instead of overflowing the server.
|
|
model_entry["limit"] = {"context": window, "output": min(window // 4, 8192)}
|
|
_subdict(config, "provider")[_OPENCODE_PROVIDER] = {
|
|
"npm": "@ai-sdk/openai-compatible",
|
|
"name": "Unsloth Studio",
|
|
"options": {"baseURL": f"{base}/v1", "apiKey": key},
|
|
"models": {model["id"]: model_entry},
|
|
}
|
|
# Normal mode pins this as the session model. Subagent mode leaves the user's
|
|
# main/small models alone and exposes the local model to @unsloth and /models.
|
|
opencode_model = f"{_OPENCODE_PROVIDER}/{model['id']}"
|
|
if as_subagent:
|
|
for field in ("model", "small_model"):
|
|
if str(config.get(field) or "").startswith(f"{_OPENCODE_PROVIDER}/"):
|
|
config.pop(field, None)
|
|
managed_compaction = {"auto": True, "reserved": max(1, window // 10)} if window else None
|
|
if managed_compaction and config.get("compaction") == managed_compaction:
|
|
config.pop("compaction", None)
|
|
_subdict(config, "agent")[_SUBAGENT_NAME] = {
|
|
"description": _SUBAGENT_DESCRIPTION,
|
|
"mode": "subagent",
|
|
"model": opencode_model,
|
|
"prompt": _SUBAGENT_INSTRUCTIONS,
|
|
}
|
|
else:
|
|
config["model"] = opencode_model
|
|
agents = config.get("agent")
|
|
if isinstance(agents, dict):
|
|
agents.pop(_SUBAGENT_NAME, None)
|
|
if not agents:
|
|
config.pop("agent", None)
|
|
if window and not as_subagent:
|
|
# Compact with ~10% headroom (near 90% full). The fixed 20k-token default
|
|
# buffer over-compacts, or never settles, on a small local context.
|
|
compaction = _subdict(config, "compaction")
|
|
compaction["auto"] = True
|
|
compaction["reserved"] = max(1, window // 10)
|
|
tools = ("edit", "bash", "webfetch", *(("task",) if as_subagent else ()))
|
|
if yolo:
|
|
# Fallback for commands without native --auto and for the append-safe bare
|
|
# --no-launch command (subcommand unknown yet). Rides inline (OPENCODE_CONFIG_CONTENT)
|
|
# so it wins over a project config. TUI and `run` launches use --auto and call here
|
|
# with yolo=False, letting OpenCode preserve explicit deny rules.
|
|
session_permission = {t: "allow" for t in tools}
|
|
session_permission["external_directory"] = {"*": "allow"}
|
|
config["permission"] = dict(session_permission)
|
|
else:
|
|
# Undo only what --yolo wrote: our yolo sets an explicit per-tool "allow" for these
|
|
# three tools, so flip exactly those explicit allows back to "ask". A "deny"/"ask",
|
|
# a granular object, a string, or a "*" catch-all is the user's own rule and is left
|
|
# untouched. We do NOT carry a permission inline for a non-yolo session: since
|
|
# OPENCODE_CONFIG_CONTENT outranks the project opencode.json we cannot read, any
|
|
# value forced there would override the user's project rules (weakening a project
|
|
# deny, or auto-approving through a granular object's permissive default). Clearing
|
|
# our own persisted yolo state is the fix; the project's own permissions are honored.
|
|
session_permission: dict = {}
|
|
permission = config.get("permission")
|
|
if isinstance(permission, dict):
|
|
for tool in tools:
|
|
if permission.get(tool) == "allow":
|
|
permission[tool] = "ask"
|
|
if permission.get("external_directory") == {"*": "allow"}:
|
|
permission["external_directory"] = {"*": "ask"}
|
|
if json.dumps(config, sort_keys = True) != before:
|
|
_write_private_json(path, config)
|
|
typer.echo(f"Updated {path}")
|
|
return session_permission
|
|
|
|
|
|
def write_hermes_config(base: str, model: dict, path: Path) -> None:
|
|
import yaml
|
|
|
|
config: dict = {}
|
|
if path.exists():
|
|
try:
|
|
loaded = yaml.safe_load(path.read_text(encoding = "utf-8"))
|
|
except (yaml.YAMLError, OSError):
|
|
typer.echo(
|
|
f"Warning: couldn't parse {path} — configure the custom endpoint "
|
|
"there yourself, or move the file aside and re-run.",
|
|
err = True,
|
|
)
|
|
return
|
|
if isinstance(loaded, dict):
|
|
config = loaded
|
|
elif loaded is not None:
|
|
# Non-empty, non-mapping YAML is a user-managed file; leave it.
|
|
typer.echo(
|
|
f"Warning: couldn't parse {path} — configure the custom endpoint "
|
|
"there yourself, or move the file aside and re-run.",
|
|
err = True,
|
|
)
|
|
return
|
|
# Hermes only reads the key for a *named* custom provider (a bare
|
|
# `provider: custom` ignores it), so register it under providers.*.
|
|
_subdict(config, "model").update(
|
|
provider = f"custom:{_HERMES_PROVIDER}",
|
|
default = model["id"],
|
|
api_mode = "openai",
|
|
)
|
|
window = model.get("context_length") or model.get("max_context_length")
|
|
if window:
|
|
window = int(window)
|
|
# Hermes auto-detects context from GET /v1/models, but OpenAI's schema has no
|
|
# context field, so it can fall back to a 256k default that overflows a small
|
|
# local model. Pin the real window (top-level model.context_length is the
|
|
# highest-priority override) and compact at 90% of it (Hermes defaults to 50%).
|
|
if window >= _HERMES_MIN_CONTEXT:
|
|
_subdict(config, "model")["context_length"] = window
|
|
_subdict(config, "compression").update(enabled = True, threshold = 0.9)
|
|
else:
|
|
# Below Hermes' 64,000-token floor it refuses to initialize, so claim
|
|
# the floor and shrink the threshold so compaction still fires at 90%
|
|
# of the REAL window (the threshold is a fraction of the claimed
|
|
# context_length). The auxiliary override keeps the same floor check
|
|
# from rejecting the compression model mid-session.
|
|
_subdict(config, "model")["context_length"] = _HERMES_MIN_CONTEXT
|
|
threshold = round(0.9 * window / _HERMES_MIN_CONTEXT, 4)
|
|
_subdict(config, "compression").update(enabled = True, threshold = threshold)
|
|
auxiliary = _subdict(_subdict(config, "auxiliary"), "compression")
|
|
auxiliary["context_length"] = _HERMES_MIN_CONTEXT
|
|
_subdict(config, "providers")[_HERMES_PROVIDER] = {
|
|
"base_url": f"{base}/v1",
|
|
"api_mode": "openai",
|
|
"key_env": _HERMES_ENV_KEY,
|
|
}
|
|
text = yaml.safe_dump(config, sort_keys = False)
|
|
if not path.exists() or path.read_text(encoding = "utf-8") != text:
|
|
path.parent.mkdir(parents = True, exist_ok = True)
|
|
path.write_text(text, encoding = "utf-8")
|
|
typer.echo(f"Updated {path}")
|
|
|
|
|
|
def write_pi_config(base: str, key: str, model: dict, path: Path) -> None:
|
|
config = _read_json_object(path)
|
|
if config is None:
|
|
typer.echo(
|
|
f"Warning: couldn't parse {path} — add an 'unsloth' provider there "
|
|
"yourself, or move the file aside and re-run.",
|
|
err = True,
|
|
)
|
|
return
|
|
before = json.dumps(config, sort_keys = True)
|
|
# Pi reads custom providers from ~/.pi/agent/models.json (HOME-relocated for the
|
|
# session). Unsloth is a generic OpenAI-compatible /v1 endpoint, and the key lives
|
|
# in the config rather than the env (matching openclaw/opencode).
|
|
provider_model = {"id": model["id"]}
|
|
window = model.get("context_length") or model.get("max_context_length")
|
|
if window:
|
|
window = int(window)
|
|
# An unspecified model defaults to contextWindow 128000 / maxTokens 16384,
|
|
# far larger than a small Unsloth context, so Pi compacts too late and overflows
|
|
# the server. Pin the real window and a sane output cap (mirrors OpenCode).
|
|
provider_model["contextWindow"] = window
|
|
provider_model["maxTokens"] = min(window // 4, 8192)
|
|
_subdict(config, "providers")[_PI_PROVIDER] = {
|
|
"api": "openai-completions",
|
|
"baseUrl": f"{base}/v1",
|
|
"apiKey": key,
|
|
"models": [provider_model],
|
|
}
|
|
if json.dumps(config, sort_keys = True) != before:
|
|
_write_private_json(path, config)
|
|
typer.echo(f"Updated {path}")
|
|
|
|
|
|
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
|
|
_write_private_json(
|
|
path,
|
|
{
|
|
"baseUrl": f"{base}/v1",
|
|
"apiKey": key,
|
|
"model": model["id"],
|
|
"contextWindow": window,
|
|
"maxTokens": min(window // 4, 8192),
|
|
"approve": approve,
|
|
},
|
|
)
|
|
|
|
|
|
@start_app.command("claude", cls = _PassthroughCommand, context_settings = _PASSTHROUGH)
|
|
def claude(
|
|
ctx: typer.Context,
|
|
model: Optional[str] = _MODEL_OPTION,
|
|
api_key: Optional[str] = _KEY_OPTION,
|
|
launch: bool = _LAUNCH_OPTION,
|
|
gguf_variant: Optional[str] = _GGUF_VARIANT_OPTION,
|
|
max_seq_length: int = _CONTEXT_OPTION,
|
|
load_in_4bit: bool = _LOAD_4BIT_OPTION,
|
|
tensor_parallel: bool = _TENSOR_PARALLEL_OPTION,
|
|
gpu_memory_mode: Optional[Literal["auto", "manual"]] = _GPU_MEMORY_MODE_OPTION,
|
|
enable_tools: bool = _ENABLE_TOOLS_OPTION,
|
|
tool_call_healing: Optional[bool] = _TOOL_CALL_HEALING_OPTION,
|
|
tool_call_nudging: Optional[bool] = _TOOL_CALL_NUDGING_OPTION,
|
|
reasoning: Optional[Literal["on", "off", "auto"]] = _REASONING_OPTION,
|
|
temperature: Optional[float] = _TEMPERATURE_OPTION,
|
|
top_p: Optional[float] = _TOP_P_OPTION,
|
|
top_k: Optional[int] = _TOP_K_OPTION,
|
|
min_p: Optional[float] = _MIN_P_OPTION,
|
|
repetition_penalty: Optional[float] = _REPETITION_PENALTY_OPTION,
|
|
presence_penalty: Optional[float] = _PRESENCE_PENALTY_OPTION,
|
|
serve: bool = _SERVE_OPTION,
|
|
yolo: bool = _YOLO_OPTION,
|
|
persist: bool = _PERSIST_OPTION,
|
|
as_subagent: bool = _AS_SUBAGENT_OPTION,
|
|
):
|
|
"""Point Claude Code at the running Unsloth server and start it."""
|
|
# Route a leading `org/name` positional to --model; forward the rest to the agent.
|
|
model, ctx.args[:] = _consume_positional_model(model, ctx.args)
|
|
base, key, entry = _connect(
|
|
api_key,
|
|
model,
|
|
LoadOptions(gguf_variant, max_seq_length, load_in_4bit, tensor_parallel, gpu_memory_mode),
|
|
serve = serve,
|
|
launch = launch,
|
|
server_options = ServerOptions(
|
|
enable_tools = enable_tools,
|
|
tool_call_healing = tool_call_healing,
|
|
tool_call_nudging = tool_call_nudging,
|
|
reasoning = reasoning,
|
|
temperature = temperature,
|
|
top_p = top_p,
|
|
top_k = top_k,
|
|
min_p = min_p,
|
|
repetition_penalty = repetition_penalty,
|
|
presence_penalty = presence_penalty,
|
|
),
|
|
)
|
|
model_id = entry["id"]
|
|
install_hint = (
|
|
"irm https://claude.ai/install.ps1 | iex"
|
|
if os.name == "nt"
|
|
else "curl -fsSL https://claude.ai/install.sh | bash"
|
|
)
|
|
if as_subagent:
|
|
subagent_id = _subagent_model_id(base, key, entry, model, gguf_variant)
|
|
subagent_model = {**entry, "id": subagent_id}
|
|
window = subagent_model.get("context_length") or subagent_model.get("max_context_length")
|
|
server_env = {
|
|
"UNSLOTH_CLAUDE_SUBAGENT_BASE_URL": base,
|
|
"UNSLOTH_CLAUDE_SUBAGENT_API_KEY": key,
|
|
"UNSLOTH_CLAUDE_SUBAGENT_MODEL": subagent_id,
|
|
"UNSLOTH_CLAUDE_SUBAGENT_BYPASS_PERMISSIONS": "1" if yolo else "0",
|
|
}
|
|
if window:
|
|
server_env["UNSLOTH_CLAUDE_SUBAGENT_CONTEXT_WINDOW"] = str(int(window))
|
|
with _session_config("claude-subagent", launch, persist = persist) as config:
|
|
plugin = write_claude_subagent_plugin(config, server_env)
|
|
command = [
|
|
"claude",
|
|
"--plugin-dir",
|
|
_agent_config_path(plugin, ["claude"]),
|
|
# Before ctx.args: a forwarded `--` would turn later flags positional.
|
|
"--allowedTools",
|
|
f"{_CLAUDE_SUBAGENT_TOOL},{_CLAUDE_SUBAGENT_PLAN_TOOL}",
|
|
*_yolo_command_flags("claude", yolo),
|
|
*ctx.args,
|
|
]
|
|
typer.echo(
|
|
"Unsloth is available as a local agent. "
|
|
"Ask Claude to spawn an Unsloth or local agent."
|
|
)
|
|
_run(
|
|
base,
|
|
subagent_model,
|
|
{},
|
|
command,
|
|
launch = launch,
|
|
install_hint = install_hint,
|
|
)
|
|
return
|
|
|
|
env = _claude_local_env(base, key, entry)
|
|
# Claude Code auto-compacts against its native context window. The local env
|
|
# above supplies the loaded model's real window and a 90% threshold instead.
|
|
# --yolo (or its aliases) maps to Claude's own --dangerously-skip-permissions.
|
|
# IS_SANDBOX is left unset on purpose: Claude refuses bypass mode as root unless a
|
|
# sandbox is detected, and we don't want to falsely claim one on the user's host.
|
|
# claude keeps its history in ~/.claude/projects, which --settings/env never
|
|
# relocate, so a session already survives exit; resume it with `claude --continue`
|
|
# or `--resume <id>` passed through.
|
|
command = [
|
|
"claude",
|
|
"--model",
|
|
model_id,
|
|
*_claude_flags(model_id),
|
|
*_yolo_command_flags("claude", yolo),
|
|
*ctx.args,
|
|
]
|
|
_run(
|
|
base,
|
|
entry,
|
|
env,
|
|
command,
|
|
launch = launch,
|
|
install_hint = install_hint,
|
|
unset_env = _CLAUDE_ENV_UNSET,
|
|
)
|
|
|
|
|
|
@start_app.command("codex", cls = _PassthroughCommand, context_settings = _PASSTHROUGH)
|
|
def codex(
|
|
ctx: typer.Context,
|
|
model: Optional[str] = _MODEL_OPTION,
|
|
api_key: Optional[str] = _KEY_OPTION,
|
|
launch: bool = _LAUNCH_OPTION,
|
|
gguf_variant: Optional[str] = _GGUF_VARIANT_OPTION,
|
|
max_seq_length: int = _CONTEXT_OPTION,
|
|
load_in_4bit: bool = _LOAD_4BIT_OPTION,
|
|
tensor_parallel: bool = _TENSOR_PARALLEL_OPTION,
|
|
gpu_memory_mode: Optional[Literal["auto", "manual"]] = _GPU_MEMORY_MODE_OPTION,
|
|
enable_tools: bool = _ENABLE_TOOLS_OPTION,
|
|
tool_call_healing: Optional[bool] = _TOOL_CALL_HEALING_OPTION,
|
|
tool_call_nudging: Optional[bool] = _TOOL_CALL_NUDGING_OPTION,
|
|
reasoning: Optional[Literal["on", "off", "auto"]] = _REASONING_OPTION,
|
|
temperature: Optional[float] = _TEMPERATURE_OPTION,
|
|
top_p: Optional[float] = _TOP_P_OPTION,
|
|
top_k: Optional[int] = _TOP_K_OPTION,
|
|
min_p: Optional[float] = _MIN_P_OPTION,
|
|
repetition_penalty: Optional[float] = _REPETITION_PENALTY_OPTION,
|
|
presence_penalty: Optional[float] = _PRESENCE_PENALTY_OPTION,
|
|
serve: bool = _SERVE_OPTION,
|
|
yolo: bool = _YOLO_OPTION,
|
|
persist: bool = _PERSIST_OPTION,
|
|
as_subagent: bool = _AS_SUBAGENT_OPTION,
|
|
):
|
|
"""Point OpenAI Codex at the running Unsloth server and start it."""
|
|
# Route a leading `org/name` positional to --model; forward the rest to the agent.
|
|
model, ctx.args[:] = _consume_positional_model(model, ctx.args)
|
|
base, key, entry = _connect(
|
|
api_key,
|
|
model,
|
|
LoadOptions(gguf_variant, max_seq_length, load_in_4bit, tensor_parallel, gpu_memory_mode),
|
|
serve = serve,
|
|
launch = launch,
|
|
server_options = ServerOptions(
|
|
enable_tools = enable_tools,
|
|
tool_call_healing = tool_call_healing,
|
|
tool_call_nudging = tool_call_nudging,
|
|
reasoning = reasoning,
|
|
temperature = temperature,
|
|
top_p = top_p,
|
|
top_k = top_k,
|
|
min_p = min_p,
|
|
repetition_penalty = repetition_penalty,
|
|
presence_penalty = presence_penalty,
|
|
),
|
|
)
|
|
# This preflight runs after _connect may have auto-started a server but before _run
|
|
# takes over its lifecycle, so tear the server down here if it rejects the model
|
|
# (e.g. a transformers-backend model) rather than leaving it on the atexit backstop.
|
|
try:
|
|
_require_gguf_for_codex(base, key, entry["id"])
|
|
except BaseException:
|
|
_shutdown_auto_served()
|
|
raise
|
|
if as_subagent:
|
|
subagent_id = _subagent_model_id(base, key, entry, model, gguf_variant)
|
|
subagent_model = {**entry, "id": subagent_id}
|
|
with _session_config("codex-subagent", launch, persist = persist) as home:
|
|
bridge_config = write_codex_subagent_bridge(
|
|
base,
|
|
key,
|
|
subagent_model,
|
|
home,
|
|
yolo = yolo,
|
|
)
|
|
with _codex_parent_overlay(home, launch = launch, persist = persist) as parent_home:
|
|
command = [
|
|
"codex",
|
|
*_codex_subagent_flags(bridge_config),
|
|
*_yolo_command_flags("codex", yolo),
|
|
*ctx.args,
|
|
]
|
|
typer.echo(
|
|
"Unsloth is available as a local agent. "
|
|
"Ask Codex to spawn an Unsloth or local agent."
|
|
)
|
|
_run(
|
|
base,
|
|
subagent_model,
|
|
{"CODEX_HOME": str(parent_home)},
|
|
command,
|
|
launch = launch,
|
|
install_hint = "npm install -g @openai/codex",
|
|
)
|
|
return
|
|
command = [
|
|
"codex",
|
|
"--oss",
|
|
"--profile",
|
|
_CODEX_PROFILE,
|
|
*_yolo_command_flags("codex", yolo),
|
|
*ctx.args,
|
|
]
|
|
with _session_config("codex", launch, persist = persist) as home:
|
|
write_codex_config(base, entry, home)
|
|
env = {_CODEX_ENV_KEY: key, "CODEX_HOME": str(home)}
|
|
_run(base, entry, env, command, launch = launch, install_hint = "npm install -g @openai/codex")
|
|
|
|
|
|
@start_app.command("openclaw", cls = _PassthroughCommand, context_settings = _PASSTHROUGH)
|
|
def openclaw(
|
|
ctx: typer.Context,
|
|
model: Optional[str] = _MODEL_OPTION,
|
|
api_key: Optional[str] = _KEY_OPTION,
|
|
launch: bool = _LAUNCH_OPTION,
|
|
gguf_variant: Optional[str] = _GGUF_VARIANT_OPTION,
|
|
max_seq_length: int = _CONTEXT_OPTION,
|
|
load_in_4bit: bool = _LOAD_4BIT_OPTION,
|
|
tensor_parallel: bool = _TENSOR_PARALLEL_OPTION,
|
|
gpu_memory_mode: Optional[Literal["auto", "manual"]] = _GPU_MEMORY_MODE_OPTION,
|
|
enable_tools: bool = _ENABLE_TOOLS_OPTION,
|
|
tool_call_healing: Optional[bool] = _TOOL_CALL_HEALING_OPTION,
|
|
tool_call_nudging: Optional[bool] = _TOOL_CALL_NUDGING_OPTION,
|
|
reasoning: Optional[Literal["on", "off", "auto"]] = _REASONING_OPTION,
|
|
temperature: Optional[float] = _TEMPERATURE_OPTION,
|
|
top_p: Optional[float] = _TOP_P_OPTION,
|
|
top_k: Optional[int] = _TOP_K_OPTION,
|
|
min_p: Optional[float] = _MIN_P_OPTION,
|
|
repetition_penalty: Optional[float] = _REPETITION_PENALTY_OPTION,
|
|
presence_penalty: Optional[float] = _PRESENCE_PENALTY_OPTION,
|
|
serve: bool = _SERVE_OPTION,
|
|
yolo: bool = _YOLO_OPTION,
|
|
persist: bool = _PERSIST_OPTION,
|
|
):
|
|
"""Point OpenClaw at the running Unsloth server and start it."""
|
|
# Route a leading `org/name` positional to --model; forward the rest to the agent.
|
|
model, ctx.args[:] = _consume_positional_model(model, ctx.args)
|
|
_reject_as_subagent("openclaw", ctx.args)
|
|
base, key, entry = _connect(
|
|
api_key,
|
|
model,
|
|
LoadOptions(gguf_variant, max_seq_length, load_in_4bit, tensor_parallel, gpu_memory_mode),
|
|
serve = serve,
|
|
launch = launch,
|
|
server_options = ServerOptions(
|
|
enable_tools = enable_tools,
|
|
tool_call_healing = tool_call_healing,
|
|
tool_call_nudging = tool_call_nudging,
|
|
reasoning = reasoning,
|
|
temperature = temperature,
|
|
top_p = top_p,
|
|
top_k = top_k,
|
|
min_p = min_p,
|
|
repetition_penalty = repetition_penalty,
|
|
presence_penalty = presence_penalty,
|
|
),
|
|
)
|
|
openclaw_args = list(ctx.args)
|
|
# Default a bare `unsloth start openclaw` to the local TUI. Anything the caller
|
|
# passes through is forwarded verbatim so OpenClaw parses it under its own grammar
|
|
# (openclaw [global-flags] <command> [options]): an explicit subcommand, a global
|
|
# flag that must precede the command such as --profile/--dev, or a tui option. We
|
|
# cannot reinterpret those safely because a leading "--flag value" is ambiguous
|
|
# between a global (`--profile test`) and a tui option (`--message hi`); prepending
|
|
# `tui --local` would break the global form, so only the empty case is defaulted.
|
|
if not openclaw_args:
|
|
openclaw_args = ["tui", "--local"]
|
|
command = ["openclaw", *openclaw_args]
|
|
install_hint = (
|
|
"iwr -useb https://openclaw.ai/install.ps1 | iex"
|
|
if os.name == "nt"
|
|
else "curl -fsSL https://openclaw.ai/install.sh | bash"
|
|
)
|
|
with _session_config("openclaw", launch, persist = persist) as cfg:
|
|
config_path = cfg / "openclaw.json"
|
|
workspace_path = None
|
|
if _wsl_windows_executable(command):
|
|
workspace_path = _wsl_windows_path(cfg / "workspace")
|
|
# key lives in the config, not the env; --yolo writes the exec policy here too.
|
|
write_openclaw_config(
|
|
base,
|
|
key,
|
|
entry,
|
|
config_path,
|
|
yolo = yolo,
|
|
workspace_path = workspace_path,
|
|
)
|
|
# Scope both config and state so OpenClaw never touches the user's ~/.openclaw.
|
|
env = {"OPENCLAW_CONFIG_PATH": str(config_path), "OPENCLAW_STATE_DIR": str(cfg)}
|
|
_run(base, entry, env, command, launch = launch, install_hint = install_hint)
|
|
|
|
|
|
@start_app.command("opencode", cls = _PassthroughCommand, context_settings = _PASSTHROUGH)
|
|
def opencode(
|
|
ctx: typer.Context,
|
|
model: Optional[str] = _MODEL_OPTION,
|
|
api_key: Optional[str] = _KEY_OPTION,
|
|
launch: bool = _LAUNCH_OPTION,
|
|
gguf_variant: Optional[str] = _GGUF_VARIANT_OPTION,
|
|
max_seq_length: int = _CONTEXT_OPTION,
|
|
load_in_4bit: bool = _LOAD_4BIT_OPTION,
|
|
tensor_parallel: bool = _TENSOR_PARALLEL_OPTION,
|
|
gpu_memory_mode: Optional[Literal["auto", "manual"]] = _GPU_MEMORY_MODE_OPTION,
|
|
enable_tools: bool = _ENABLE_TOOLS_OPTION,
|
|
tool_call_healing: Optional[bool] = _TOOL_CALL_HEALING_OPTION,
|
|
tool_call_nudging: Optional[bool] = _TOOL_CALL_NUDGING_OPTION,
|
|
reasoning: Optional[Literal["on", "off", "auto"]] = _REASONING_OPTION,
|
|
temperature: Optional[float] = _TEMPERATURE_OPTION,
|
|
top_p: Optional[float] = _TOP_P_OPTION,
|
|
top_k: Optional[int] = _TOP_K_OPTION,
|
|
min_p: Optional[float] = _MIN_P_OPTION,
|
|
repetition_penalty: Optional[float] = _REPETITION_PENALTY_OPTION,
|
|
presence_penalty: Optional[float] = _PRESENCE_PENALTY_OPTION,
|
|
serve: bool = _SERVE_OPTION,
|
|
yolo: bool = _YOLO_OPTION,
|
|
persist: bool = _PERSIST_OPTION,
|
|
as_subagent: bool = _AS_SUBAGENT_OPTION,
|
|
):
|
|
"""Point OpenCode at the running Unsloth server and start it."""
|
|
# Route a leading `org/name` positional to --model; forward the rest to the agent.
|
|
model, ctx.args[:] = _consume_positional_model(model, ctx.args)
|
|
base, key, entry = _connect(
|
|
api_key,
|
|
model,
|
|
LoadOptions(gguf_variant, max_seq_length, load_in_4bit, tensor_parallel, gpu_memory_mode),
|
|
serve = serve,
|
|
launch = launch,
|
|
server_options = ServerOptions(
|
|
enable_tools = enable_tools,
|
|
tool_call_healing = tool_call_healing,
|
|
tool_call_nudging = tool_call_nudging,
|
|
reasoning = reasoning,
|
|
temperature = temperature,
|
|
top_p = top_p,
|
|
top_k = top_k,
|
|
min_p = min_p,
|
|
repetition_penalty = repetition_penalty,
|
|
presence_penalty = presence_penalty,
|
|
),
|
|
)
|
|
if as_subagent:
|
|
subagent_id = _subagent_model_id(base, key, entry, model, gguf_variant)
|
|
subagent_model = {**entry, "id": subagent_id}
|
|
# Stay append-safe for a bare no-launch recipe: a later `run <prompt>` would make
|
|
# `opencode --auto run ...` parse as the TUI, so keep yolo in the inline fallback.
|
|
route_native_auto = yolo and _opencode_supports_native_auto() and (launch or bool(ctx.args))
|
|
opencode_args, native_auto = _opencode_native_auto_args(list(ctx.args), route_native_auto)
|
|
command = ["opencode", *opencode_args]
|
|
with _session_config("opencode-subagent", launch, persist = persist) as cfg:
|
|
config_path = cfg / "opencode.json"
|
|
session_permission = write_opencode_config(
|
|
base,
|
|
key,
|
|
subagent_model,
|
|
config_path,
|
|
yolo = yolo and not native_auto,
|
|
as_subagent = True,
|
|
)
|
|
env = {"OPENCODE_CONFIG": str(config_path)}
|
|
if launch and _which_with_install_dirs("opencode") is None:
|
|
# Provider-filter inspection needs the binary; offer the install now so
|
|
# a global/project allowlist is honored on this first launch instead of
|
|
# being read only after _launch installs OpenCode.
|
|
_install_agent("opencode", "npm install -g opencode-ai")
|
|
inline_config = _opencode_subagent_inline_config(config_path, session_permission)
|
|
# A project opencode.json outranks the session file and could field-merge its
|
|
# own agent.unsloth over ours. Pin ours in the inline overlay so it wins.
|
|
inline_config.setdefault("agent", {})[_SUBAGENT_NAME] = {
|
|
"description": _SUBAGENT_DESCRIPTION,
|
|
"mode": "subagent",
|
|
"model": f"{_OPENCODE_PROVIDER}/{subagent_model['id']}",
|
|
"prompt": _SUBAGENT_INSTRUCTIONS,
|
|
}
|
|
env["OPENCODE_CONFIG_CONTENT"] = json.dumps(inline_config)
|
|
typer.echo("Unsloth is available as @unsloth and in /models.")
|
|
_run(
|
|
base,
|
|
subagent_model,
|
|
env,
|
|
command,
|
|
launch = launch,
|
|
install_hint = "npm install -g opencode-ai",
|
|
)
|
|
return
|
|
opencode_model = f"{_OPENCODE_PROVIDER}/{entry['id']}"
|
|
# The inline OPENCODE_CONFIG_CONTENT below pins the model in the highest-priority
|
|
# layer, so the session model is forced without a --model flag. Only add --model for
|
|
# an interactive bare launch (a convenience so the TUI opens on our model). It is
|
|
# omitted for passthrough (inserting it before a subcommand can be misparsed) and for
|
|
# --no-launch, where the printed command is consumed by drivers that append a
|
|
# subcommand such as `run <prompt>`; a leading --model would land before that
|
|
# subcommand and break it. Those paths rely on the inline pin instead.
|
|
native_auto = False
|
|
route_native_auto = yolo and _opencode_supports_native_auto()
|
|
if ctx.args:
|
|
opencode_args, native_auto = _opencode_native_auto_args(list(ctx.args), route_native_auto)
|
|
command = ["opencode", *opencode_args]
|
|
elif launch:
|
|
opencode_args, native_auto = _opencode_native_auto_args(
|
|
["--model", opencode_model],
|
|
route_native_auto,
|
|
)
|
|
command = ["opencode", *opencode_args]
|
|
else:
|
|
# Append-safe base: `opencode --auto run ...` parses as the TUI with a project
|
|
# "run", not the run subcommand. Command unknown here, so keep the config fallback.
|
|
command = ["opencode"]
|
|
# opencode keeps sessions in ~/.local/share/opencode (never relocated), so resume
|
|
# already survives exit; reopen the last one by passing `opencode --continue` through.
|
|
with _session_config("opencode", launch, persist = persist) as cfg:
|
|
config_path = cfg / "opencode.json"
|
|
# OPENCODE_CONFIG is an overlay (loaded between the user's global and project
|
|
# configs), so this adds the Unsloth provider/model for the session without
|
|
# changing the user's default model. Key lives in the config, not the env.
|
|
session_permission = write_opencode_config(
|
|
base,
|
|
key,
|
|
entry,
|
|
config_path,
|
|
yolo = yolo and not native_auto,
|
|
)
|
|
# A project's own opencode.json outranks OPENCODE_CONFIG, so the session model pin
|
|
# would silently lose to a repo config. Carry it in OPENCODE_CONFIG_CONTENT, which
|
|
# outranks project config; the API key stays in the private file, never the env.
|
|
# Only the config fallback carries a permission. Native --auto omits it (auto-approve
|
|
# asks, keep explicit denies); a non-yolo session omits it too, honoring project rules.
|
|
# opencode filters every provider (a config-defined custom one included) through
|
|
# its enabled_providers allowlist and disabled_providers denylist, and a model pin
|
|
# does not bypass that gate -- a filtered provider resolves to ModelNotFoundError.
|
|
# To guarantee the session model loads without reading or modifying the user's real
|
|
# config, scope THIS session to our provider alone: allowlist _OPENCODE_PROVIDER and
|
|
# clear the denylist. These arrays are replaced (not merged) by higher layers, so
|
|
# setting them in the highest-priority inline overlay neutralizes any user allowlist
|
|
# or denylist for the launch. It is session-only: it lives in OPENCODE_CONFIG_CONTENT
|
|
# for this invocation and never touches the user's config files, so their normal
|
|
# `opencode` is unchanged; only this session is limited to the Unsloth provider.
|
|
# small_model is opencode's separate model for lightweight tasks; pin it to the
|
|
# session model too, or a user/project small_model on another (now filtered)
|
|
# provider would resolve a not-found error mid-session. The session serves one
|
|
# model, so the session model is the only valid target here anyway.
|
|
inline_config: dict = {
|
|
"model": opencode_model,
|
|
"small_model": opencode_model,
|
|
"enabled_providers": [_OPENCODE_PROVIDER],
|
|
"disabled_providers": [],
|
|
}
|
|
if session_permission:
|
|
inline_config["permission"] = session_permission
|
|
env = {
|
|
"OPENCODE_CONFIG": str(config_path),
|
|
"OPENCODE_CONFIG_CONTENT": json.dumps(inline_config),
|
|
}
|
|
_run(base, entry, env, command, launch = launch, install_hint = "npm install -g opencode-ai")
|
|
|
|
|
|
@start_app.command("hermes", cls = _PassthroughCommand, context_settings = _PASSTHROUGH)
|
|
def hermes(
|
|
ctx: typer.Context,
|
|
model: Optional[str] = _MODEL_OPTION,
|
|
api_key: Optional[str] = _KEY_OPTION,
|
|
launch: bool = _LAUNCH_OPTION,
|
|
gguf_variant: Optional[str] = _GGUF_VARIANT_OPTION,
|
|
max_seq_length: int = _CONTEXT_OPTION,
|
|
load_in_4bit: bool = _LOAD_4BIT_OPTION,
|
|
tensor_parallel: bool = _TENSOR_PARALLEL_OPTION,
|
|
gpu_memory_mode: Optional[Literal["auto", "manual"]] = _GPU_MEMORY_MODE_OPTION,
|
|
enable_tools: bool = _ENABLE_TOOLS_OPTION,
|
|
tool_call_healing: Optional[bool] = _TOOL_CALL_HEALING_OPTION,
|
|
tool_call_nudging: Optional[bool] = _TOOL_CALL_NUDGING_OPTION,
|
|
reasoning: Optional[Literal["on", "off", "auto"]] = _REASONING_OPTION,
|
|
temperature: Optional[float] = _TEMPERATURE_OPTION,
|
|
top_p: Optional[float] = _TOP_P_OPTION,
|
|
top_k: Optional[int] = _TOP_K_OPTION,
|
|
min_p: Optional[float] = _MIN_P_OPTION,
|
|
repetition_penalty: Optional[float] = _REPETITION_PENALTY_OPTION,
|
|
presence_penalty: Optional[float] = _PRESENCE_PENALTY_OPTION,
|
|
serve: bool = _SERVE_OPTION,
|
|
yolo: bool = _YOLO_OPTION,
|
|
persist: bool = _PERSIST_OPTION,
|
|
):
|
|
"""Point Hermes (Nous Research) at the running Unsloth server and start it."""
|
|
# Route a leading `org/name` positional to --model; forward the rest to the agent.
|
|
model, ctx.args[:] = _consume_positional_model(model, ctx.args)
|
|
_reject_as_subagent("hermes", ctx.args)
|
|
native_args = [*_yolo_command_flags("hermes", yolo), *ctx.args]
|
|
command = ["hermes", *_hermes_resume_oneshot_args(native_args)]
|
|
base, key, entry = _connect(
|
|
api_key,
|
|
model,
|
|
LoadOptions(gguf_variant, max_seq_length, load_in_4bit, tensor_parallel, gpu_memory_mode),
|
|
serve = serve,
|
|
launch = launch,
|
|
server_options = ServerOptions(
|
|
enable_tools = enable_tools,
|
|
tool_call_healing = tool_call_healing,
|
|
tool_call_nudging = tool_call_nudging,
|
|
reasoning = reasoning,
|
|
temperature = temperature,
|
|
top_p = top_p,
|
|
top_k = top_k,
|
|
min_p = min_p,
|
|
repetition_penalty = repetition_penalty,
|
|
presence_penalty = presence_penalty,
|
|
),
|
|
)
|
|
install_hint = _hermes_install_hint()
|
|
with _session_config("hermes", launch, persist = persist) as home:
|
|
# HERMES_HOME relocates hermes' whole home dir (config.yaml, sessions, state)
|
|
# like CODEX_HOME, so the user's ~/.hermes is left untouched for the session.
|
|
write_hermes_config(base, entry, home / "config.yaml")
|
|
env = {_HERMES_ENV_KEY: key, "HERMES_HOME": str(home)}
|
|
_run(base, entry, env, command, launch = launch, install_hint = install_hint)
|
|
|
|
|
|
@start_app.command("pi", cls = _PassthroughCommand, context_settings = _PASSTHROUGH)
|
|
def pi(
|
|
ctx: typer.Context,
|
|
model: Optional[str] = _MODEL_OPTION,
|
|
api_key: Optional[str] = _KEY_OPTION,
|
|
launch: bool = _LAUNCH_OPTION,
|
|
gguf_variant: Optional[str] = _GGUF_VARIANT_OPTION,
|
|
max_seq_length: int = _CONTEXT_OPTION,
|
|
load_in_4bit: bool = _LOAD_4BIT_OPTION,
|
|
tensor_parallel: bool = _TENSOR_PARALLEL_OPTION,
|
|
gpu_memory_mode: Optional[Literal["auto", "manual"]] = _GPU_MEMORY_MODE_OPTION,
|
|
enable_tools: bool = _ENABLE_TOOLS_OPTION,
|
|
tool_call_healing: Optional[bool] = _TOOL_CALL_HEALING_OPTION,
|
|
tool_call_nudging: Optional[bool] = _TOOL_CALL_NUDGING_OPTION,
|
|
reasoning: Optional[Literal["on", "off", "auto"]] = _REASONING_OPTION,
|
|
temperature: Optional[float] = _TEMPERATURE_OPTION,
|
|
top_p: Optional[float] = _TOP_P_OPTION,
|
|
top_k: Optional[int] = _TOP_K_OPTION,
|
|
min_p: Optional[float] = _MIN_P_OPTION,
|
|
repetition_penalty: Optional[float] = _REPETITION_PENALTY_OPTION,
|
|
presence_penalty: Optional[float] = _PRESENCE_PENALTY_OPTION,
|
|
serve: bool = _SERVE_OPTION,
|
|
yolo: bool = _YOLO_OPTION,
|
|
persist: bool = _PERSIST_OPTION,
|
|
as_subagent: bool = _AS_SUBAGENT_OPTION,
|
|
):
|
|
"""Point Pi (coding agent) at the running Unsloth server and start it."""
|
|
# Route a leading `org/name` positional to --model; forward the rest to the agent.
|
|
model, ctx.args[:] = _consume_positional_model(model, ctx.args)
|
|
base, key, entry = _connect(
|
|
api_key,
|
|
model,
|
|
LoadOptions(gguf_variant, max_seq_length, load_in_4bit, tensor_parallel, gpu_memory_mode),
|
|
serve = serve,
|
|
launch = launch,
|
|
server_options = ServerOptions(
|
|
enable_tools = enable_tools,
|
|
tool_call_healing = tool_call_healing,
|
|
tool_call_nudging = tool_call_nudging,
|
|
reasoning = reasoning,
|
|
temperature = temperature,
|
|
top_p = top_p,
|
|
top_k = top_k,
|
|
min_p = min_p,
|
|
repetition_penalty = repetition_penalty,
|
|
presence_penalty = presence_penalty,
|
|
),
|
|
)
|
|
install_hint = "npm install -g --ignore-scripts @earendil-works/pi-coding-agent"
|
|
if as_subagent:
|
|
if not _PI_SUBAGENT_EXTENSION.is_file():
|
|
_fail(f"Missing Pi subagent extension: {_PI_SUBAGENT_EXTENSION}")
|
|
subagent_id = _subagent_model_id(base, key, entry, model, gguf_variant)
|
|
subagent_model = {**entry, "id": subagent_id}
|
|
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,
|
|
approve = yolo,
|
|
)
|
|
command = [
|
|
"pi",
|
|
"--extension",
|
|
extension,
|
|
*_yolo_command_flags("pi", yolo),
|
|
*ctx.args,
|
|
]
|
|
typer.echo(
|
|
"Unsloth is available as a local agent and in /model. "
|
|
"Ask Pi to spawn an Unsloth or local agent."
|
|
)
|
|
_run(
|
|
base,
|
|
subagent_model,
|
|
{"UNSLOTH_PI_SUBAGENT_CONFIG": str(config_path)},
|
|
command,
|
|
launch = launch,
|
|
install_hint = install_hint,
|
|
clear_screen = True,
|
|
)
|
|
return
|
|
# Pi defaults to the google provider, so pin our provider/model on the command
|
|
# line; the custom OpenAI-compatible endpoint itself is only configurable via
|
|
# ~/.pi/agent/models.json.
|
|
command = [
|
|
"pi",
|
|
"--provider",
|
|
_PI_PROVIDER,
|
|
"--model",
|
|
entry["id"],
|
|
*_yolo_command_flags("pi", yolo),
|
|
*ctx.args,
|
|
]
|
|
# --ignore-scripts matches Pi's documented install recipe (its README notes Pi needs
|
|
# no install scripts), so accepting the prompt skips dependency lifecycle scripts.
|
|
with _session_config("pi", launch, persist = persist) as home:
|
|
# Pi resolves its config dir from PI_CODING_AGENT_DIR first (getAgentDir() prefers
|
|
# it over $HOME/.pi/agent), so pin it at the session dir: an inherited
|
|
# PI_CODING_AGENT_DIR in the user's shell would otherwise send Pi to their real
|
|
# config and skip our provider/key. HOME is relocated too so any other ~/.pi paths
|
|
# stay in the session. The key rides in the config rather than the env.
|
|
pi_agent_dir = home / ".pi" / "agent"
|
|
write_pi_config(base, key, entry, pi_agent_dir / "models.json")
|
|
env = {"HOME": str(home), "PI_CODING_AGENT_DIR": str(pi_agent_dir)}
|
|
if os.name == "nt" or os.environ.get("WSL_DISTRO_NAME"):
|
|
# Node resolves ~/.pi via USERPROFILE (then HOMEDRIVE + HOMEPATH) on Windows,
|
|
# not HOME. Set them whenever Pi may run as a Windows process: native Windows,
|
|
# or a /mnt Windows shim launched from WSL (the WSLENV bridge then translates
|
|
# the path). Otherwise the Windows process falls back to the user's real
|
|
# %USERPROFILE%\.pi. splitdrive yields no drive off a POSIX path, so
|
|
# HOMEDRIVE/HOMEPATH stay unset there.
|
|
env["USERPROFILE"] = str(home)
|
|
drive, tail = os.path.splitdrive(str(home))
|
|
if drive:
|
|
env["HOMEDRIVE"], env["HOMEPATH"] = drive, tail
|
|
# Pi paints inline from the current cursor position (no alternate screen,
|
|
# no clear on first render), so give it the clean screen it assumes.
|
|
_run(
|
|
base,
|
|
entry,
|
|
env,
|
|
command,
|
|
launch = launch,
|
|
install_hint = install_hint,
|
|
clear_screen = True,
|
|
)
|