unsloth/unsloth_cli/tests/test_start.py
Nilay b8400f40df
CLI: Rename unsloth connect to unsloth start (#6613)
* replaced connect with start

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix

* Studio: build the coding-agent command from the selected server

The API keys panel showed a hardcoded `unsloth start claude`. `unsloth start`
defaults to 127.0.0.1:8888 and only mints a key for a loopback server, so a
non-default port or a tunnel/remote base would target the wrong server or fail
to mint. Build the command from the panel base/key (and emit a key for
non-loopback), matching the other snippets in the panel.

* CLI: keep `unsloth connect` as a hidden alias for `unsloth start`

Avoids breaking existing scripts and docs that still call `unsloth connect`.

* Tests: stub _unstarted_cleanup in same-task disconnect test

The test builds _SameTaskStreamingResponse via __new__, so set the attribute
that __call__ now reads.

* Match coding-agent command loopback check to the CLI 127.0.0.0/8 rule (#6613)

* Keep unsloth_cli.commands.connect importable as a deprecated shim (#6613)

* Format the new coding-agents panel strings and import per biome (#6613)

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Drop the unsloth connect alias and shim; unsloth start is the only command (#6613)

* Route unsloth connect to unsloth start as a hidden backward-compatible alias (#6613)

* Forward unsloth run model-load flags to unsloth start (gguf-variant, context-length, load-in-4bit, tensor-parallel) (#6613)

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Session-scope coding agent config in unsloth start

Configure each agent for the current session instead of writing the Studio endpoint, key, and default model into the user's own config. Codex, OpenCode, OpenClaw, and Hermes get a private config relocated through their config-path env vars (CODEX_HOME, OPENCODE_CONFIG overlay, OPENCLAW_CONFIG_PATH plus OPENCLAW_STATE_DIR, HERMES_HOME). Claude Code suppresses the attribution header for the session via the CLAUDE_CODE_ATTRIBUTION_HEADER env var plus a --settings overlay, with no ~/.claude write. --launch uses an ephemeral temp dir removed after the agent exits; --no-launch uses a stable Unsloth-owned dir and prints the matching export lines.

* Read relocated agent session config in Local Agent Guides CI

The contract crosscheck and the openclaw/hermes patch helpers now read each agent's config from the relocated path printed by unsloth start --no-launch (CODEX_HOME, OPENCODE_CONFIG, OPENCLAW_CONFIG_PATH, HERMES_HOME) instead of fixed home paths. The Claude attribution A/B toggles the header for the session only (shipped-config HIT vs vanilla MISS) instead of editing ~/.claude/settings.json.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Skip the POSIX-only --no-launch parser test on Windows

test_no_launch_output_is_parseable mirrors the #6547 bash CI parser, which greps export/unset lines and only runs on Linux/macOS runners. On Windows --no-launch prints PowerShell ($env: / Remove-Item), so the export-line assertion does not apply there. Cross-OS staging CI surfaced this.

* Size Claude Code's auto-compact window to the loaded model's context

Claude Code auto-compacts against its native (~600k token) window, so against a smaller local model it overflows the server's context (silent truncation) long before it compacts. Set CLAUDE_CODE_AUTO_COMPACT_WINDOW to the loaded model's real context length (the value codex/openclaw already get via model_context_window / contextWindow). Omitted when the model reports no context length.

* Pin OpenCode/Hermes context window and set 90% compaction across agents

Feed every agent the server-determined sequence length (the value /v1/models reports from runtime_context_length) and a ~90% compaction threshold. OpenCode: a custom-provider model with no limit defaults to context 0, which silently disables auto-compaction, so set limit.context/output and scale the compaction buffer to 10% of the window. Hermes: pin model.context_length (it otherwise falls back to a 256k default when the server's /v1/models omits the field) and set compression.threshold 0.9. Claude: add CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=90 alongside the window. Codex (model_context_window) and OpenClaw (contextWindow) already carried the window and auto-manage off it.

* Add `unsloth start pi` recipe

Pi was the only agent without a built-in recipe, so the agent-guides CI
hand-wrote ~/.pi/agent/models.json. Add a first-class `pi` command mirroring
the others:

- write_pi_config writes the session-scoped OpenAI-compatible provider config
  (key in the config, like openclaw/opencode).
- pi() launches `pi --provider unsloth --model <id>` (Pi defaults to the google
  provider, so the provider/model are pinned on the command line) with HOME
  relocated for the session. Pi has no config-dir env var and resolves ~/.pi off
  $HOME, so HOME-scoping keeps the user's ~/.pi untouched.

Migrate the agent-guides CI off the hand-written config onto the
`unsloth start pi --no-launch` path (connection + file-edit), with a crosscheck
for the provider api, so the documented recipe is exercised.

* Harden unsloth start for Windows and WSL agent launches

Address the Codex review on PR 6613:
- write_pi_config now pins the loaded contextWindow and a sane maxTokens so Pi
  compacts instead of overflowing a small Studio context (it otherwise assumes
  its 128000 default), matching the other agents.
- pi() sets USERPROFILE (and HOMEDRIVE/HOMEPATH when present) alongside HOME on
  native Windows, where Node resolves ~/.pi via USERPROFILE rather than HOME, so
  the session no longer reads or writes the user's real ~/.pi.
- The WSLENV bridge flags path-valued vars with /p so a Windows npm shim under
  /mnt receives translated paths, while scalar vars (the numeric context window)
  pass through untranslated. WSLENV is deduped on the bare name.
- _print_env prints the launch command with PowerShell-safe quoting so the inline
  --settings JSON survives copy-paste on native Windows --no-launch.

Add tests for the WSLENV path flagging, PowerShell quoting, the Pi context
window, and the Pi USERPROFILE relocation.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Set CLAUDE_CODE_NO_FLICKER for the Claude session

A local server streams in bursts, so Claude Code's full-screen TUI redraw
flickers between tokens. Disable it for the session via CLAUDE_CODE_NO_FLICKER,
alongside the other CLAUDE_CODE_* session env knobs.

* Add a normalized --yolo flag routed to each agent's auto-approve mode

It is easy to forget which agent spells "run tools without prompting" which way,
so `unsloth start` now accepts all three spellings as one option (--yolo,
--dangerously-skip-permissions, --dangerously-bypass-approvals-and-sandbox) and
routes to the agent's own mechanism:

- claude:   --dangerously-skip-permissions
- codex:    --dangerously-bypass-approvals-and-sandbox
- hermes:   --yolo
- pi:       --approve (Pi's only approval gate is project trust)
- opencode: a permission allow block in opencode.json (no CLI flag exists)
- openclaw: tools.exec security=full / ask=off / host=gateway (no CLI flag exists)

Because the option is parsed by `unsloth start`, the "wrong" spelling for an
agent still routes correctly instead of leaking through to the agent and erroring.
IS_SANDBOX is deliberately left unset for Claude so its root/sandbox safety gate
still applies. Adds routing, cross-routing, and per-config tests.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix review findings: IPv6 loopback command, pi USERPROFILE under WSL, yolo guard

From a 10-reviewer pass over the PR:

- studio/frontend agent-command.ts: normalize bracketed IPv6 hosts. URL.hostname
  returns "[::1]" for http://[::1]:8888, which never matched the "::1" loopback
  checks, so the copied command embedded the placeholder API key for a local IPv6
  server instead of the bare auto-minting command. Now [::1] is treated as loopback
  like the CLI's is_loopback_url, so the command matches the CLI contract.

- pi(): also relocate USERPROFILE (and HOMEDRIVE/HOMEPATH) when running under WSL
  against a /mnt Windows shim, not just on native Windows. Windows Node resolves
  ~/.pi via USERPROFILE, and the WSLENV bridge translates the path, so pi no longer
  falls back to the user's real ~/.pi in that case.

- _yolo_command_flags: use .get so a config-based agent (or a typo) yields no flag
  instead of a latent KeyError.

Adds tests for the WSL pi USERPROFILE relocation, the yolo unmapped-agent guard,
and that opencode/openclaw --yolo stays config-only (no argv flag).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix round-2 review findings: WSLENV /p upgrade, agent help text

- _merge_wslenv now upgrades a user's pre-existing unflagged WSLENV entry (e.g. a
  bare HOME or USERPROFILE) to the path-translated form (HOME/p) instead of leaving
  it as-is, so a Windows agent shim under WSL receives the translated session path
  rather than the raw Linux path.
- Generalize the `unsloth start` registration help to list all six agents (was only
  "Claude Code, Codex").

Adds a test for the WSLENV unflagged-entry upgrade.

* Fix round-3 review findings: complete openclaw --yolo, refresh stale copy

- openclaw --yolo now also writes the host approvals file (exec-approvals.json with
  defaults security=full / ask=off / askFallback=full) alongside the tools.exec
  config. OpenClaw gates tool execution on both layers (the stricter wins), so the
  config alone could still leave it prompting or denying. Mirrors `openclaw
  exec-policy preset yolo`. ask=off means nothing is ever prompted, so the runtime
  socket block is unnecessary.
- Studio API panel copy: clarify that a local server auto-mints the key while a
  remote one embeds it in the command, and add pi to the swap hint.
- Local Agent Guides CI: drop the stale "pi has no start.py recipe" note now that
  all six agents are driven via `unsloth start <agent> --no-launch`.

Adds the openclaw approvals-file assertions and a no-yolo openclaw test.

* start: parse claude --version with a regex so a format change does not drop optimization flags

* start: offer to install a missing agent (prompt then run its install command)

* start: auto-start a Studio server for --model when none is running, and stop it on exit

* inference: surface an actionable message when llama-server cannot compile a tool grammar

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix review findings: kill the auto-started server tree on Windows; apply the tool-grammar message to the OpenAI passthrough too

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* start: split --model org/repo:variant so a running session is not evicted

`unsloth start <agent> --model org/repo:QUANT` failed against an already-running
Studio server and, worse, killed whatever model another session had loaded.

/v1/models lists a loaded GGUF under its bare repo id (e.g. unsloth/Qwen3-1.7B-GGUF),
so _resolve_model never matched the `:QUANT`-suffixed request. It then POSTed
/api/inference/load with model_path=org/repo:QUANT, which (a) Hugging Face rejects
("Repo id must use alphanumeric chars, '-', '_' or '.'") and (b) evicts the model the
other session was using, so a second 'unsloth start' in a new tmux/terminal tore down
the first. Re-running the command then attached to the now-empty server, which is why
it 'worked the second time'.

Mirror the org/repo:QUANT -> org/repo + --gguf-variant QUANT shorthand that
'unsloth run' and llama.cpp already accept, splitting it in _connect before we match or
serve. Matching now resolves against the loaded bare repo id (no spurious reload, no
eviction), and any real load uses a valid repo id plus gguf_variant. An explicit
--gguf-variant still wins; local paths and Windows drive letters pass through untouched.
The auto-serve path likewise spawns 'unsloth run --model org/repo --gguf-variant QUANT'.

* start: harden auth-key handling, codex teardown, and CI transcript redaction

Three review findings:

1. CI could leak a live key. agent-guides-drive.sh printed the raw
   'unsloth start --no-launch' transcript (which carries export UNSLOTH_API_KEY /
   ANTHROPIC_AUTH_TOKEN lines) to the Actions log on both the failure path and the
   success path before redact() ran. Add cat_redacted() and use it for those two
   prints, so the key is scrubbed on the way to the log while the on-disk file stays
   intact for the env parsing that follows.

2. Outages masqueraded as bad keys. _key_accepted caught a broad Exception and
   returned False, so a 5xx or timeout while checking a cached key looked like a
   rejection: it discarded a good key and minted extra ones (local) or reported 'no
   saved key' (remote). Only treat HTTP 401/403 as a rejection; let other errors
   propagate so a real outage surfaces.

3. Codex preflight could leave the auto-started server up. _require_gguf_for_codex
   runs after _connect may have auto-started Studio but before _run installs its
   teardown finally, so a preflight rejection (e.g. a transformers-backend model) left
   the server holding the port/GPU until the atexit backstop. Tear it down explicitly
   at the point of failure.

Tests: a 5xx on a saved key surfaces without minting; a non-GGUF codex preflight
tears down the auto-served server.

* start: fix IPv6/portless studio URLs, Pi config-dir isolation, and Pi install recipe

Four review findings:

1. Pi ignored the session config when PI_CODING_AGENT_DIR was already set. Pi's
   getAgentDir() reads process.env.PI_CODING_AGENT_DIR before falling back to
   $HOME/.pi/agent, so a value inherited from the user's shell sent Pi to their real
   config and skipped our provider/key (the HOME relocation alone was not enough). Pin
   PI_CODING_AGENT_DIR at the session's .pi/agent dir; it is path-valued so the WSL
   bridge translates it automatically.

2. Pi install hint dropped Pi's documented --ignore-scripts. Pi's README installs with
   'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' and notes it needs
   no install scripts, so accepting the prompt now follows that safe recipe.

3. Auto-start ignored a portless UNSLOTH_STUDIO_URL. unsloth run binds to
   'parsed.port or 8888', so http://127.0.0.1 launched the child on 8888 but the health
   poll (and the returned base) still used port 80, stalling until the startup timeout.
   Normalize the base to host:8888 (IPv6-safe) before starting and polling.

4. API-panel command mistook IPv6 loopback for the bare default. The bare 'unsloth
   start' only probes 127.0.0.1:8888 on the IPv4 stack, so http://[::1]:8888 must carry
   an explicit UNSLOTH_STUDIO_URL. Drop ::1 from the bare-default host set while keeping
   it a loopback host (URL emitted, no key needed).

Tests: PI_CODING_AGENT_DIR is set to the session dir; _effective_base normalizes
portless/IPv6 bases; a portless UNSLOTH_STUDIO_URL auto-serves on :8888.

* start: apply fresh-review findings across CLI, CI, and the API-panel command

From a fresh multi-reviewer pass over the merged head plus the latest Codex bot review:

1. Load knobs now always consult the server. _resolve_model matched on model id alone,
   so --gguf-variant / --context-length / --no-load-in-4bit / --tensor-parallel were
   silently ignored whenever the id was already loaded (asking for UD-Q4_K_XL kept a
   Q8_0 serving). With any explicit knob the CLI defers to /api/inference/load, whose
   already-loaded dedup answers without reloading when variant and settings match, so a
   second session running the same command still attaches without evicting the first.

2. OpenCode --yolo and the session model pin now ride in OPENCODE_CONFIG_CONTENT. A
   project's own opencode.json outranks OPENCODE_CONFIG, so a repo config could silently
   override the session model and the --yolo permission block; OPENCODE_CONFIG_CONTENT
   outranks project config. The API key stays in the private file, never in printed env.

3. The --no-launch recipe's last line is a self-contained one-liner (inline VAR=value
   assignments before the command, conflicting vars blanked). People copy just the last
   line, and a bare codex/claude there ran against the user's real ~/.codex or Anthropic
   credentials with zero isolation, e.g. inheriting a pre-existing damaged ~/.codex
   state DB and blaming the recipe. The CI drive script scrubs the key from the one
   'invoking:' echo this adds.

4. The auto-serve log is 0600 and the parent handle is closed. It sat world-readable in
   the shared tempdir under a predictable name while carrying the minted sk-unsloth-
   key from the unsloth run banner.

5. _key_accepted fails with a clean message on outages. Non-auth errors (5xx, network,
   timeout) surfaced as a raw traceback; 401/403 still mean a rejected key.

6. _effective_base strips URL paths, and https loopback targets never auto-serve.
   http://127.0.0.1:8888/studio polled /studio/api/health (404) and https://127.0.0.1
   polled the wrong scheme, both spinning until the 15-minute startup timeout.

7. API-panel command: only literal 127.0.0.1:8888 earns the bare command. localhost can
   resolve to ::1, which the bare CLI never probes, so it keeps UNSLOTH_STUDIO_URL.

8. CI artifact sweep covers redacted-configs/ and agent-workdir/, not just logs/.

Tests: 125 CLI tests pass (new coverage for each fix), 156 backend tests pass, ruff
clean. Adds an unsloth connect alias regression test.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* start: hand Pi a clean screen at launch

Pi paints inline from wherever the cursor sits: its first render assumes a
clean screen instead of clearing or entering the alternate screen itself
(current Pi never emits a clear at startup). Launched under unsloth start,
that left the session starting mid-scroll beneath the connection output.
Clear the screen (click.clear, cross-platform, no-op without a TTY) right
before the Studio banner so Pi opens exactly one line down on a clean
viewport. Launch path only: --no-launch recipes and piped output are never
wiped, and alternate-screen agents are left alone.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* start: auto-override hermes' 64K context floor for small model windows

Hermes refuses to initialize when the served model's context window is
under 64,000 tokens, and a second copy of the same check rejects the
compression model mid-session. write_hermes_config previously pinned the
real window, so any small local model (e.g. 40,960) failed at startup
with manual config.yaml instructions.

For windows below the floor the recipe now claims 65,536 in
model.context_length, scales compression.threshold so compaction still
fires at 90% of the real window, and sets
auxiliary.compression.context_length to cover the mid-session check.
Windows at or above the floor keep the exact previous behavior.

* ci: install pi with --ignore-scripts, matching the start.py hint

The pi cell predates the pi recipe in start.py and still installed the
package with lifecycle scripts enabled, so CI stopped exercising the
exact command users are prompted to run. npm_retry now passes extra
flags through, the pi branch mirrors the install hint verbatim, and the
stale no-recipe comment is refreshed.

* ci: fail loudly when a relocation var is missing from connect output

The empty-string guards ran after appending /config.toml or /config.yaml,
so they could never fire: crosscheck_contract silently skipped its
contract checks and patch_hermes_tools died on the root path with a bare
traceback. Check the raw variable first and guide_fail with the real
cause.

* staging: 6613 round 6 (https elision, no-launch home reuse, auto-start key fallback)

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: shimmyshimmer <107991372+shimmyshimmer@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Wasim Yousef Said <wasimysdev@gmail.com>
2026-07-03 08:17:27 -07:00

1848 lines
80 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
"""Tests for `unsloth start` — config merging and launch env, no network."""
from __future__ import annotations
import json
import os
import shlex
import sys
import urllib.error
from pathlib import Path
from types import SimpleNamespace
_REPO_ROOT = Path(__file__).resolve().parents[2]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
import pytest
from typer.testing import CliRunner
import unsloth_cli.commands.start as start
BASE = "http://127.0.0.1:8888"
MODEL = {"id": "unsloth/gemma-4-26B-A4B-it-GGUF", "context_length": 131072}
# --no-launch prints shell setup as POSIX (export/unset) on Unix/WSL and
# PowerShell ($env:/Remove-Item) on native Windows; assert the host's form.
def _assert_env_set(output: str, name: str, value: str) -> None:
needle = f'$env:{name} = "{value}"' if os.name == "nt" else f"export {name}={value}"
assert needle in output, f"{needle!r} not found in:\n{output}"
def _assert_env_unset(output: str, name: str) -> None:
needle = f"Remove-Item Env:{name}" if os.name == "nt" else f"unset {name}"
assert needle in output, f"{needle!r} not found in:\n{output}"
def _launch_command(output: str) -> list:
# The --no-launch recipe ends with a self-contained one-liner: inline NAME=value
# assignments, then the command. Return just the command argv.
last = [ln for ln in output.splitlines() if ln.strip()][-1]
parts = shlex.split(last)
for i, part in enumerate(parts):
name = part.partition("=")[0]
if "=" not in part or not name.replace("_", "").isalnum():
return parts[i:]
return []
def _fake_claude(monkeypatch, version_output: str) -> None:
monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/claude")
monkeypatch.setattr(
start.subprocess,
"run",
lambda *args, **kwargs: SimpleNamespace(stdout = version_output),
)
def test_claude_flags_passed_to_supported_claude(monkeypatch):
_fake_claude(monkeypatch, "2.1.98 (Claude Code)\n")
assert start._claude_flags() == [
"--exclude-dynamic-system-prompt-sections",
"--settings",
start._CLAUDE_SETTINGS_OVERLAY,
]
def test_claude_flags_skipped_on_old_claude(monkeypatch):
_fake_claude(monkeypatch, "2.0.14 (Claude Code)\n")
assert start._claude_flags() == []
def test_claude_flags_skipped_on_unparseable_version(monkeypatch):
_fake_claude(monkeypatch, "weird build string\n")
assert start._claude_flags() == []
def test_claude_flags_detected_when_version_not_first_token(monkeypatch):
# The X.Y.Z is pulled from anywhere in the output, so a format change (version not
# the first token) doesn't silently drop the optimization flags.
_fake_claude(monkeypatch, "claude version 2.1.98\n")
assert start._claude_flags() == [
"--exclude-dynamic-system-prompt-sections",
"--settings",
start._CLAUDE_SETTINGS_OVERLAY,
]
def test_install_agent_prompts_then_installs(monkeypatch):
# TTY + yes: run the documented install command, then re-resolve the now-present binary.
monkeypatch.setattr(start.sys, "stdin", SimpleNamespace(isatty = lambda: True))
monkeypatch.setattr(start.typer, "confirm", lambda *a, **k: True)
ran = []
monkeypatch.setattr(
start.subprocess,
"run",
lambda command, *a, **k: ran.append(command) or SimpleNamespace(returncode = 0),
)
# _install_agent only re-resolves after installing (the pre-install check is the
# caller's job), so `which` reports the now-present binary.
monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/codex")
executable = start._install_agent("codex", "npm install -g @openai/codex")
assert executable == "/usr/local/bin/codex"
assert ran == [["/bin/sh", "-c", "npm install -g @openai/codex"]]
def test_install_agent_declined_returns_none(monkeypatch):
# TTY + no: never runs anything; caller falls back to the print-hint failure.
monkeypatch.setattr(start.sys, "stdin", SimpleNamespace(isatty = lambda: True))
monkeypatch.setattr(start.typer, "confirm", lambda *a, **k: False)
monkeypatch.setattr(start.shutil, "which", lambda _: None)
monkeypatch.setattr(
start.subprocess, "run", lambda *a, **k: pytest.fail("should not install when declined")
)
assert start._install_agent("codex", "npm install -g @openai/codex") is None
def test_install_agent_non_interactive_returns_none(monkeypatch):
# No TTY (piped stdin): cannot prompt, so don't install; return None silently.
monkeypatch.setattr(start.sys, "stdin", SimpleNamespace(isatty = lambda: False))
monkeypatch.setattr(
start.subprocess, "run", lambda *a, **k: pytest.fail("should not install without a TTY")
)
assert start._install_agent("codex", "npm install -g @openai/codex") is None
def _parse_toml(text: str) -> dict:
tomllib = pytest.importorskip("tomllib")
return tomllib.loads(text)
def test_merge_codex_config_fresh():
merged = start._merge_codex_config("", BASE)
parsed = _parse_toml(merged)
assert parsed["oss_provider"] == "unsloth_api"
provider = parsed["model_providers"]["unsloth_api"]
assert provider["base_url"] == f"{BASE}/v1"
assert provider["wire_api"] == "responses"
assert provider["requires_openai_auth"] is False
def test_merge_codex_config_replaces_stale_block():
existing = (
'model = "gpt-5"\n'
"\n"
"[model_providers.unsloth_api]\n"
'base_url = "http://old-host:9999/v1"\n'
'wire_api = "chat"\n'
"\n"
"[model_providers.unsloth_api.http_headers]\n"
'x-old = "1"\n'
"\n"
"[model_providers.ollama]\n"
'base_url = "http://localhost:11434/v1"\n'
)
merged = start._merge_codex_config(existing, BASE)
parsed = _parse_toml(merged)
assert parsed["model"] == "gpt-5"
assert parsed["model_providers"]["unsloth_api"]["base_url"] == f"{BASE}/v1"
assert parsed["model_providers"]["unsloth_api"]["wire_api"] == "responses"
assert "http_headers" not in parsed["model_providers"]["unsloth_api"]
assert parsed["model_providers"]["ollama"]["base_url"] == "http://localhost:11434/v1"
assert start._merge_codex_config(merged, BASE) == merged
def test_merge_codex_config_keeps_user_oss_provider():
merged = start._merge_codex_config('oss_provider = "ollama"\n', BASE)
assert _parse_toml(merged)["oss_provider"] == "ollama"
def test_write_codex_config_profile(tmp_path):
start.write_codex_config(BASE, MODEL, tmp_path)
profile = _parse_toml((tmp_path / "unsloth_api.config.toml").read_text())
assert profile["oss_provider"] == "unsloth_api"
assert profile["model_provider"] == "unsloth_api"
assert profile["model"] == MODEL["id"]
assert profile["model_context_window"] == 131072
config = _parse_toml((tmp_path / "config.toml").read_text())
assert config["model_providers"]["unsloth_api"]["env_key"] == "UNSLOTH_STUDIO_AUTH_TOKEN"
@pytest.fixture()
def fake_studio(tmp_path, monkeypatch):
calls = []
state = {"models": [MODEL]}
def http_json(
method,
url,
token,
payload = None,
timeout = 30,
error = None,
):
calls.append((method, url, payload))
if url.endswith("/v1/models"):
return {"object": "list", "data": state["models"]}
if url.endswith("/api/inference/status"):
return {"is_gguf": True, "model_identifier": state["models"][0]["id"]}
if url.endswith("/api/auth/api-keys"):
return {"key": "sk-unsloth-feedfacefeedface"}
if url.endswith("/api/inference/load"):
state["models"] = [{"id": payload["model_path"], "context_length": 4096}]
return {}
raise AssertionError(f"unexpected request: {method} {url}")
monkeypatch.setattr(start, "find_studio_server", lambda: BASE)
# Identity handshake has its own tests; trust the loopback server here.
monkeypatch.setattr(start, "verify_studio_identity", lambda base: True)
# _studio_token / api-keys are faked so the mint flow stays offline.
monkeypatch.setattr(start, "_studio_token", lambda: "jwt-token")
monkeypatch.setattr(start, "_http_json", http_json)
monkeypatch.setattr(start, "_key_cache_path", lambda: tmp_path / "agent_api_key.json")
# --no-launch session configs land under tmp instead of the real Unsloth dir.
monkeypatch.setattr(start, "_agents_config_root", lambda: tmp_path / "agents")
# No `claude` on PATH, so _claude_flags never probes the real binary.
monkeypatch.setattr(start.shutil, "which", lambda _: None)
monkeypatch.delenv("UNSLOTH_API_KEY", raising = False)
return calls
def test_connect_claude_no_launch(fake_studio):
result = CliRunner().invoke(start.start_app, ["claude", "--no-launch"])
assert result.exit_code == 0, result.output
_assert_env_unset(result.output, "ANTHROPIC_API_KEY")
_assert_env_unset(result.output, "CLAUDE_CODE_OAUTH_TOKEN")
_assert_env_set(result.output, "ANTHROPIC_BASE_URL", BASE)
_assert_env_set(result.output, "ANTHROPIC_AUTH_TOKEN", "sk-unsloth-feedfacefeedface")
_assert_env_set(result.output, "ANTHROPIC_MODEL", MODEL["id"])
_assert_env_set(result.output, "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
_assert_env_set(result.output, "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", "1")
# Suppress the full-screen TUI redraw so a bursty local server doesn't flicker.
_assert_env_set(result.output, "CLAUDE_CODE_NO_FLICKER", "1")
# Attribution header is suppressed for the session via env + --settings, never
# by writing the user's ~/.claude/settings.json.
_assert_env_set(result.output, "CLAUDE_CODE_ATTRIBUTION_HEADER", "0")
# Auto-compact window is sized to the loaded model's real context length so the
# session compacts before it overflows the local server's (much smaller) window,
# and compaction is forced at 90% of it for headroom.
_assert_env_set(result.output, "CLAUDE_CODE_AUTO_COMPACT_WINDOW", str(MODEL["context_length"]))
_assert_env_set(result.output, "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE", "90")
assert f"claude --model {MODEL['id']} --exclude-dynamic-system-prompt-sections" in result.output
# Overlay is passed inline (session-only), not a path into the user's ~/.claude.
assert "--settings" in result.output
assert ".claude/settings.json" not in result.output
def test_connect_claude_compact_window_omitted_without_context(fake_studio, monkeypatch):
# A model that doesn't report a context length -> leave Claude's default window
# rather than guessing one.
monkeypatch.setattr(start, "_resolve_model", lambda *a, **k: {"id": "local-model"})
result = CliRunner().invoke(start.start_app, ["claude", "--no-launch"])
assert result.exit_code == 0, result.output
assert "CLAUDE_CODE_AUTO_COMPACT_WINDOW" not in result.output
assert "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE" not in result.output
def test_connect_claude_launch_scrubs_conflicting_auth_env(fake_studio, monkeypatch):
captured = {}
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-anthropic-stale")
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "oauth-stale")
monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/claude")
monkeypatch.setattr(start, "_claude_flags", lambda: [])
def run(command, env):
captured["command"] = command
captured["env"] = env
return SimpleNamespace(returncode = 0)
monkeypatch.setattr(start.subprocess, "run", run)
result = CliRunner().invoke(start.start_app, ["claude"])
assert result.exit_code == 0, result.output
assert captured["command"] == ["/usr/local/bin/claude", "--model", MODEL["id"]]
assert "ANTHROPIC_API_KEY" not in captured["env"]
assert "CLAUDE_CODE_OAUTH_TOKEN" not in captured["env"]
assert captured["env"]["ANTHROPIC_AUTH_TOKEN"] == "sk-unsloth-feedfacefeedface"
assert captured["env"]["ANTHROPIC_BASE_URL"] == BASE
assert captured["env"]["ANTHROPIC_MODEL"] == MODEL["id"]
assert captured["env"]["CLAUDE_CODE_ATTRIBUTION_HEADER"] == "0"
@pytest.mark.skipif(
os.name == "nt",
reason = "WSL-from-Linux scenario (calling a Windows agent .exe from inside WSL); "
"os.name is 'posix' under WSL, so this path can't run on a native Windows runner.",
)
def test_connect_claude_windows_shim_from_wsl_bridges_env(fake_studio, monkeypatch):
captured = {}
monkeypatch.setenv("WSL_DISTRO_NAME", "Ubuntu")
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-anthropic-stale")
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "oauth-stale")
monkeypatch.setattr(
start.shutil, "which", lambda _: "/mnt/c/Users/samle/AppData/Roaming/npm/claude"
)
monkeypatch.setattr(start, "_claude_flags", lambda: [])
def run(command, env):
captured["command"] = command
captured["env"] = env
return SimpleNamespace(returncode = 0)
monkeypatch.setattr(start.subprocess, "run", run)
result = CliRunner().invoke(start.start_app, ["claude"])
assert result.exit_code == 0, result.output
assert captured["command"] == [
"/mnt/c/Users/samle/AppData/Roaming/npm/claude",
"--model",
MODEL["id"],
]
assert captured["env"]["ANTHROPIC_API_KEY"] == ""
assert captured["env"]["CLAUDE_CODE_OAUTH_TOKEN"] == ""
assert captured["env"]["ANTHROPIC_AUTH_TOKEN"] == "sk-unsloth-feedfacefeedface"
assert captured["env"]["ANTHROPIC_BASE_URL"] == BASE
assert captured["env"]["ANTHROPIC_MODEL"] == MODEL["id"]
for name in (
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_MODEL",
"ANTHROPIC_API_KEY",
"CLAUDE_CODE_OAUTH_TOKEN",
):
assert name in captured["env"]["WSLENV"].split(":")
@pytest.mark.skipif(
os.name == "nt",
reason = "WSL-from-Linux scenario (calling a Windows agent .exe from inside WSL); "
"os.name is 'posix' under WSL, so this path can't run on a native Windows runner.",
)
def test_connect_claude_no_launch_windows_shim_from_wsl_prints_wslenv(fake_studio, monkeypatch):
monkeypatch.setenv("WSL_DISTRO_NAME", "Ubuntu")
monkeypatch.setattr(
start.shutil, "which", lambda _: "/mnt/c/Users/samle/AppData/Roaming/npm/claude"
)
result = CliRunner().invoke(start.start_app, ["claude", "--no-launch"])
assert result.exit_code == 0, result.output
assert "export ANTHROPIC_API_KEY=" in result.output
assert "export CLAUDE_CODE_OAUTH_TOKEN=" in result.output
assert "export WSLENV=" in result.output
assert "ANTHROPIC_AUTH_TOKEN" in result.output
assert "CLAUDE_CODE_OAUTH_TOKEN" in result.output
def test_connect_codex_no_launch(fake_studio, tmp_path):
result = CliRunner().invoke(start.start_app, ["codex", "--no-launch"])
assert result.exit_code == 0, result.output
_assert_env_set(result.output, "UNSLOTH_STUDIO_AUTH_TOKEN", "sk-unsloth-feedfacefeedface")
assert "codex --oss --profile unsloth_api" in result.output
# Config lands in the session-scoped CODEX_HOME, not the user's ~/.codex.
home = tmp_path / "agents" / "codex"
_assert_env_set(result.output, "CODEX_HOME", str(home))
assert (home / "config.toml").exists()
assert (home / "unsloth_api.config.toml").exists()
def test_connect_codex_launch_uses_ephemeral_home(fake_studio, monkeypatch):
# Launch mode writes config to a throwaway temp CODEX_HOME and removes it after
# the agent exits; the user's real ~/.codex is never the target.
captured = {}
monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/codex")
def run(command, env):
captured["home"] = env["CODEX_HOME"]
captured["config_present"] = (Path(env["CODEX_HOME"]) / "config.toml").exists()
return SimpleNamespace(returncode = 0)
monkeypatch.setattr(start.subprocess, "run", run)
result = CliRunner().invoke(start.start_app, ["codex"])
assert result.exit_code == 0, result.output
home = Path(captured["home"])
assert captured["config_present"] # config existed while codex ran
assert "unsloth-codex-" in home.name # an ephemeral temp dir, not ~/.codex
assert not home.exists() # cleaned up after the agent exits
@pytest.mark.skipif(
os.name == "nt",
reason = "the #6547 CI parser is bash-only; on Windows --no-launch prints PowerShell",
)
def test_no_launch_output_is_parseable(fake_studio):
# Mirror the #6547 CI parser: status lines, then `export`/`unset`, then exactly
# one launch command on the last line (now an inline-env one-liner, so the parser
# matches by substring rather than prefix).
result = CliRunner().invoke(start.start_app, ["codex", "--no-launch"])
assert result.exit_code == 0, result.output
lines = [ln for ln in result.output.splitlines() if ln.strip()]
skip = ("export ", "unset ", "Studio ", "Updated ", "Disabled ", "Warning", "Loading")
body = [ln for ln in lines if not ln.startswith(skip)]
assert "codex --oss --profile unsloth_api" in body[-1]
assert any(ln.startswith("export CODEX_HOME=") for ln in lines)
def test_no_launch_last_line_is_self_contained(fake_studio, tmp_path):
# People copy just the last line. A bare `codex` there would run against the user's
# real ~/.codex (e.g. a pre-existing damaged state DB) with zero isolation, so the
# last line must inline every session env var ahead of the command.
result = CliRunner().invoke(start.start_app, ["codex", "--no-launch"])
assert result.exit_code == 0, result.output
last = [ln for ln in result.output.splitlines() if ln.strip()][-1]
parts = shlex.split(last)
assignments = {}
command = []
for i, part in enumerate(parts):
if "=" not in part:
command = parts[i:]
break
name, _, value = part.partition("=")
assignments[name] = value
assert command and command[0] == "codex"
assert assignments["CODEX_HOME"] == str(tmp_path / "agents" / "codex")
assert assignments["UNSLOTH_STUDIO_AUTH_TOKEN"].startswith("sk-unsloth-")
def test_no_launch_claude_last_line_blanks_conflicting_auth(fake_studio):
# The unset vars must be neutralized inline too, or a partial copy would send the
# user's own ANTHROPIC_API_KEY to the Studio base.
result = CliRunner().invoke(start.start_app, ["claude", "--no-launch"])
assert result.exit_code == 0, result.output
last = [ln for ln in result.output.splitlines() if ln.strip()][-1]
assert "ANTHROPIC_API_KEY= " in last
assert "CLAUDE_CODE_OAUTH_TOKEN= " in last
assert "ANTHROPIC_AUTH_TOKEN=" in last # the real key still applied after the blanks
def test_opencode_inline_config_beats_project_config(fake_studio):
# A project's opencode.json outranks OPENCODE_CONFIG, so the model pin (and --yolo
# permissions) ride in OPENCODE_CONFIG_CONTENT, which outranks project config.
result = CliRunner().invoke(start.start_app, ["opencode", "--no-launch", "--yolo"])
assert result.exit_code == 0, result.output
content_line = next(
ln for ln in result.output.splitlines() if ln.startswith("export OPENCODE_CONFIG_CONTENT=")
)
inline = json.loads(
shlex.split(content_line.removeprefix("export OPENCODE_CONFIG_CONTENT="))[0]
)
assert inline["model"] == f"unsloth/{MODEL['id']}"
assert inline["permission"] == {"edit": "allow", "bash": "allow", "webfetch": "allow"}
assert "sk-unsloth" not in content_line # key stays in the private file
def test_opencode_inline_config_omits_permissions_without_yolo(fake_studio):
result = CliRunner().invoke(start.start_app, ["opencode", "--no-launch"])
assert result.exit_code == 0, result.output
content_line = next(
ln for ln in result.output.splitlines() if ln.startswith("export OPENCODE_CONFIG_CONTENT=")
)
inline = json.loads(
shlex.split(content_line.removeprefix("export OPENCODE_CONFIG_CONTENT="))[0]
)
assert inline == {"model": f"unsloth/{MODEL['id']}"}
def test_https_loopback_never_auto_serves(fake_studio, monkeypatch):
# `unsloth run` serves plain HTTP; auto-serving behind an https:// target would poll
# the wrong scheme until the startup timeout. Keep the plain "no server" error.
monkeypatch.setenv("UNSLOTH_STUDIO_URL", "https://127.0.0.1:8443")
monkeypatch.setattr(start, "find_studio_server", lambda: None)
started = {"called": False}
monkeypatch.setattr(
start, "_start_studio_server", lambda *a, **k: started.__setitem__("called", True)
)
result = CliRunner().invoke(start.start_app, ["claude", "--model", "unsloth/Qwen3-1.7B-GGUF"])
assert result.exit_code == 1
assert "No running Studio server" in result.output
assert started["called"] is False
def test_connect_alias_still_works(fake_studio):
# `unsloth connect` remains a compat alias for `unsloth start`.
from unsloth_cli import app
result = CliRunner().invoke(app, ["connect", "claude", "--no-launch"])
assert result.exit_code == 0, result.output
_assert_env_set(result.output, "ANTHROPIC_MODEL", MODEL["id"])
def test_connect_key_minted_once_then_cached(fake_studio, tmp_path):
CliRunner().invoke(start.start_app, ["claude", "--no-launch"])
CliRunner().invoke(start.start_app, ["claude", "--no-launch"])
# First run mints; second reuses the minted key cached for this server.
mints = [c for c in fake_studio if c[1].endswith("/api/auth/api-keys")]
assert len(mints) == 1
cached = json.loads((tmp_path / "agent_api_key.json").read_text())
assert cached["servers"][BASE]["minted"] == ["sk-unsloth-feedfacefeedface"]
def test_connect_explicit_key_remembered_for_keyless_runs(fake_studio, tmp_path):
CliRunner().invoke(
start.start_app,
["claude", "--no-launch", "--api-key", "sk-unsloth-deadbeefdeadbeef"],
)
result = CliRunner().invoke(start.start_app, ["claude", "--no-launch"])
assert result.exit_code == 0, result.output
# Reused, not re-minted (a mint would return the feedface stand-in).
_assert_env_set(result.output, "ANTHROPIC_AUTH_TOKEN", "sk-unsloth-deadbeefdeadbeef")
cached = json.loads((tmp_path / "agent_api_key.json").read_text())
# An explicit key is remembered as "saved" so it replays without the handshake.
assert cached["servers"][BASE]["saved"] == ["sk-unsloth-deadbeefdeadbeef"]
def test_connect_skips_cached_keys_the_server_rejects(fake_studio, tmp_path, monkeypatch):
cache = tmp_path / "agent_api_key.json"
cache.write_text(
json.dumps(
{"servers": {BASE: {"minted": ["sk-unsloth-stale", "sk-unsloth-feedfacefeedface"]}}}
)
)
inner = start._http_json
def http_json(
method,
url,
token,
payload = None,
timeout = 30,
error = None,
):
if url.endswith("/v1/models") and token == "sk-unsloth-stale":
raise urllib.error.HTTPError(url, 401, "Unauthorized", None, None)
return inner(method, url, token, payload, timeout, error)
monkeypatch.setattr(start, "_http_json", http_json)
result = CliRunner().invoke(start.start_app, ["claude", "--no-launch"])
assert result.exit_code == 0, result.output
_assert_env_set(result.output, "ANTHROPIC_AUTH_TOKEN", "sk-unsloth-feedfacefeedface")
# The working key moves to the front so the next run tries it first.
cached = json.loads(cache.read_text())
assert cached["servers"][BASE]["minted"] == ["sk-unsloth-feedfacefeedface", "sk-unsloth-stale"]
def test_connect_saved_key_server_outage_surfaces_not_reminted(fake_studio, tmp_path, monkeypatch):
# A 5xx/timeout while checking a saved key is a server outage, not a rejected key:
# surface it instead of discarding the key and minting a new one against a sick server.
cache = tmp_path / "agent_api_key.json"
cache.write_text(json.dumps({"servers": {BASE: {"saved": ["sk-unsloth-saved"]}}}))
inner = start._http_json
def http_json(
method,
url,
token,
payload = None,
timeout = 30,
error = None,
):
if url.endswith("/v1/models") and token == "sk-unsloth-saved":
raise urllib.error.HTTPError(url, 503, "Service Unavailable", None, None)
return inner(method, url, token, payload, timeout, error)
monkeypatch.setattr(start, "_http_json", http_json)
result = CliRunner().invoke(start.start_app, ["claude", "--no-launch"])
assert result.exit_code != 0, result.output
# The outage did not cause a fresh key to be minted.
mints = [c for c in fake_studio if c[1].endswith("/api/auth/api-keys")]
assert mints == []
def test_connect_legacy_unscoped_cache_not_replayed(fake_studio, tmp_path):
# Legacy unscoped caches have no server binding (could leak across servers),
# so they're ignored: a fresh key is minted and stored scoped to this server.
(tmp_path / "agent_api_key.json").write_text(json.dumps({"key": "sk-unsloth-oldformat"}))
result = CliRunner().invoke(start.start_app, ["claude", "--no-launch"])
assert result.exit_code == 0, result.output
_assert_env_set(result.output, "ANTHROPIC_AUTH_TOKEN", "sk-unsloth-feedfacefeedface")
cached = json.loads((tmp_path / "agent_api_key.json").read_text())
assert cached["servers"][BASE]["minted"] == ["sk-unsloth-feedfacefeedface"]
assert "key" not in cached # legacy field collapsed away
def test_connect_model_flag_loads_on_server(fake_studio):
result = CliRunner().invoke(
start.start_app, ["claude", "--no-launch", "--model", "unsloth/Qwen3.5-35B-A3B"]
)
assert result.exit_code == 0, result.output
loads = [c for c in fake_studio if c[1].endswith("/api/inference/load")]
assert loads == [
("POST", f"{BASE}/api/inference/load", {"model_path": "unsloth/Qwen3.5-35B-A3B"})
]
_assert_env_set(result.output, "ANTHROPIC_MODEL", "unsloth/Qwen3.5-35B-A3B")
def test_connect_model_flag_forwards_load_options(fake_studio):
# The model-load knobs mirrored from `unsloth run` reach /api/inference/load.
result = CliRunner().invoke(
start.start_app,
[
"claude",
"--no-launch",
"--model",
"unsloth/Qwen3-4B-GGUF",
"--gguf-variant",
"UD-Q4_K_XL",
"--context-length",
"8192",
"--no-load-in-4bit",
"--tensor-parallel",
],
)
assert result.exit_code == 0, result.output
loads = [c for c in fake_studio if c[1].endswith("/api/inference/load")]
assert loads == [
(
"POST",
f"{BASE}/api/inference/load",
{
"model_path": "unsloth/Qwen3-4B-GGUF",
"gguf_variant": "UD-Q4_K_XL",
"max_seq_length": 8192,
"load_in_4bit": False,
"tensor_parallel": True,
},
)
]
def test_connect_model_flag_matches_canonical_id(fake_studio, monkeypatch):
# Studio registers a loaded model under a canonical id (resolved identifier
# / casing) that can differ from the path we passed. The agent must connect
# to that model, not silently fall through to the first loaded one.
requested = "Unsloth/Qwen3.5-35B-A3B"
canonical = "unsloth/Qwen3.5-35B-A3B"
inner = start._http_json
def http_json(
method,
url,
token,
payload = None,
timeout = 30,
error = None,
):
if url.endswith("/api/inference/load"):
return {"model": canonical, "display_name": canonical}
if url.endswith("/v1/models"):
# Decoy sorts first, so models[0] is the wrong pick on the old code.
return {"object": "list", "data": [MODEL, {"id": canonical, "context_length": 4096}]}
return inner(method, url, token, payload, timeout, error)
monkeypatch.setattr(start, "_http_json", http_json)
result = CliRunner().invoke(start.start_app, ["claude", "--no-launch", "--model", requested])
assert result.exit_code == 0, result.output
_assert_env_set(result.output, "ANTHROPIC_MODEL", canonical)
@pytest.mark.parametrize(
"model, expected",
[
("unsloth/Qwen3-1.7B-GGUF:UD-Q4_K_XL", ("unsloth/Qwen3-1.7B-GGUF", "UD-Q4_K_XL")),
("unsloth/gemma-4-E2B-it-GGUF:Q8_0", ("unsloth/gemma-4-E2B-it-GGUF", "Q8_0")),
("unsloth/Qwen3-1.7B-GGUF", ("unsloth/Qwen3-1.7B-GGUF", None)), # no suffix
("/models/local.gguf", ("/models/local.gguf", None)), # absolute path
("./rel.gguf", ("./rel.gguf", None)), # relative path
("C:\\models\\x.gguf", ("C:\\models\\x.gguf", None)), # Windows drive
("repo:with/slash", ("repo:with/slash", None)), # slash in variant -> not a variant
("", ("", None)),
],
)
def test_split_repo_variant(model, expected):
assert start._split_repo_variant(model) == expected
def test_connect_model_bare_id_matches_loaded_without_reload(fake_studio):
# A bare `--model <loaded repo>` (no load knobs) attaches to the already-loaded model
# without touching /api/inference/load, so it can never evict another session.
result = CliRunner().invoke(start.start_app, ["claude", "--no-launch", "--model", MODEL["id"]])
assert result.exit_code == 0, result.output
loads = [c for c in fake_studio if c[1].endswith("/api/inference/load")]
assert loads == []
_assert_env_set(result.output, "ANTHROPIC_MODEL", MODEL["id"])
def test_connect_model_variant_suffix_defers_to_server_dedup(fake_studio):
# `--model repo:QUANT` splits into a VALID load payload (bare repo + gguf_variant),
# never the `:`-suffixed repo id Studio rejects. The variant knob defers to
# /api/inference/load, whose already-loaded dedup answers without reloading when the
# active variant+settings match -- so a second session running the same command
# attaches without evicting the first, while a genuinely different quant reloads.
result = CliRunner().invoke(
start.start_app, ["claude", "--no-launch", "--model", MODEL["id"] + ":UD-Q4_K_XL"]
)
assert result.exit_code == 0, result.output
loads = [c for c in fake_studio if c[1].endswith("/api/inference/load")]
assert loads == [
(
"POST",
f"{BASE}/api/inference/load",
{"model_path": MODEL["id"], "gguf_variant": "UD-Q4_K_XL"},
)
]
_assert_env_set(result.output, "ANTHROPIC_MODEL", MODEL["id"])
def test_connect_load_knobs_reach_server_even_when_id_loaded(fake_studio):
# /v1/models can't reveal the active quant, so an id match alone would silently keep
# the wrong variant loaded. Explicit knobs must always consult the load endpoint.
result = CliRunner().invoke(
start.start_app,
["claude", "--no-launch", "--model", MODEL["id"], "--gguf-variant", "Q8_0"],
)
assert result.exit_code == 0, result.output
loads = [c for c in fake_studio if c[1].endswith("/api/inference/load")]
assert loads == [
("POST", f"{BASE}/api/inference/load", {"model_path": MODEL["id"], "gguf_variant": "Q8_0"})
]
def test_connect_model_variant_suffix_loads_split_repo(fake_studio):
# When the model is not already loaded, the `:QUANT` suffix becomes the gguf_variant
# and the load uses the bare (valid) repo id, mirroring `unsloth run repo --gguf-variant`.
result = CliRunner().invoke(
start.start_app,
["claude", "--no-launch", "--model", "unsloth/Qwen3-4B-GGUF:UD-Q4_K_XL"],
)
assert result.exit_code == 0, result.output
loads = [c for c in fake_studio if c[1].endswith("/api/inference/load")]
assert loads == [
(
"POST",
f"{BASE}/api/inference/load",
{"model_path": "unsloth/Qwen3-4B-GGUF", "gguf_variant": "UD-Q4_K_XL"},
)
]
def test_connect_explicit_gguf_variant_wins_over_suffix(fake_studio):
# An explicit --gguf-variant takes precedence; the suffix is still stripped so the
# repo id stays valid.
result = CliRunner().invoke(
start.start_app,
[
"claude",
"--no-launch",
"--model",
"unsloth/Qwen3-4B-GGUF:Q8_0",
"--gguf-variant",
"UD-Q4_K_XL",
],
)
assert result.exit_code == 0, result.output
loads = [c for c in fake_studio if c[1].endswith("/api/inference/load")]
assert loads == [
(
"POST",
f"{BASE}/api/inference/load",
{"model_path": "unsloth/Qwen3-4B-GGUF", "gguf_variant": "UD-Q4_K_XL"},
)
]
def test_connect_no_model_loaded_errors(fake_studio, monkeypatch):
monkeypatch.setattr(
start,
"_http_json",
lambda method, url, token, payload = None, timeout = 30, error = None: (
{"key": "sk-unsloth-feedfacefeedface"}
if url.endswith("/api/auth/api-keys")
else {"object": "list", "data": []}
),
)
result = CliRunner().invoke(start.start_app, ["claude", "--no-launch"])
assert result.exit_code == 1
assert "No model is loaded" in result.output
def test_connect_requested_model_not_loaded_fails(fake_studio, monkeypatch):
# Studio never surfaces the requested model; fail loudly rather than
# silently connecting to whatever else happens to be loaded.
inner = start._http_json
def http_json(
method,
url,
token,
payload = None,
timeout = 30,
error = None,
):
if url.endswith("/api/inference/load"):
return {}
if url.endswith("/v1/models"):
return {"object": "list", "data": [MODEL]} # decoy; request never appears
return inner(method, url, token, payload, timeout, error)
monkeypatch.setattr(start, "_http_json", http_json)
result = CliRunner().invoke(
start.start_app, ["claude", "--no-launch", "--model", "unsloth/Missing-7B"]
)
assert result.exit_code == 1
assert "unsloth/Missing-7B" in result.output
def test_connect_codex_rejects_non_gguf_model(fake_studio, monkeypatch):
inner = start._http_json
def http_json(
method,
url,
token,
payload = None,
timeout = 30,
error = None,
):
if url.endswith("/api/inference/status"):
return {"is_gguf": False, "model_identifier": "unsloth/Qwen3-0.6B"}
return inner(method, url, token, payload, timeout, error)
monkeypatch.setattr(start, "_http_json", http_json)
result = CliRunner().invoke(start.start_app, ["codex", "--no-launch"])
assert result.exit_code == 1
assert "GGUF" in result.output
result = CliRunner().invoke(start.start_app, ["claude", "--no-launch"])
assert result.exit_code == 0, result.output
def test_connect_nonloopback_keyless_refuses_to_send_credential(fake_studio, monkeypatch):
# A server known only by URL + health check is unverified: keyless connect
# must refuse and make no request at all.
monkeypatch.setattr(start, "find_studio_server", lambda: "http://studio.evil.example:8888")
result = CliRunner().invoke(start.start_app, ["opencode", "--no-launch"])
assert result.exit_code == 1
assert "Settings → API" in result.output
assert "--api-key" in result.output
assert fake_studio == [] # no HTTP request of any kind (no mint, no /v1/models)
def test_connect_nonloopback_explicit_key_is_allowed(fake_studio, monkeypatch):
# User named both server and key, so it's their choice; only auto-send is blocked.
monkeypatch.setattr(start, "find_studio_server", lambda: "http://studio.example:8888")
result = CliRunner().invoke(
start.start_app,
["opencode", "--no-launch", "--api-key", "sk-unsloth-deadbeefdeadbeef"],
)
assert result.exit_code == 0, result.output
def test_connect_nonloopback_replays_saved_key(fake_studio, tmp_path, monkeypatch):
# A key saved for a remote (non-loopback) Studio is replayed on keyless runs;
# auto-minting stays blocked for non-loopback.
remote = "http://studio.example:8888"
monkeypatch.setattr(start, "find_studio_server", lambda: remote)
(tmp_path / "agent_api_key.json").write_text(
json.dumps({"servers": {remote: {"saved": ["sk-unsloth-deadbeefdeadbeef"]}}})
)
result = CliRunner().invoke(start.start_app, ["claude", "--no-launch"])
assert result.exit_code == 0, result.output
_assert_env_set(result.output, "ANTHROPIC_AUTH_TOKEN", "sk-unsloth-deadbeefdeadbeef")
assert not any(c[1].endswith("/api/auth/api-keys") for c in fake_studio) # never minted
def test_connect_studio_server_errors_on_explicit_remote(monkeypatch):
# A user who pointed UNSLOTH_STUDIO_URL at a remote Studio should get an
# error, not a silent local model load (which they did not ask for).
import typer
import unsloth_cli._inference as inference
monkeypatch.setenv("UNSLOTH_STUDIO_URL", "http://studio.example:8888")
monkeypatch.setattr(
inference, "find_studio_server", lambda *a, **k: "http://studio.example:8888"
)
with pytest.raises(typer.Exit):
inference.connect_studio_server("m", hf_token = None, max_seq_length = 4096, load_in_4bit = False)
def test_connect_studio_server_falls_back_locally_on_default_discovery(monkeypatch):
# Opportunistic local discovery (no UNSLOTH_STUDIO_URL): if the loopback
# server can't be verified, fall back to a local load rather than erroring.
import unsloth_cli._inference as inference
monkeypatch.delenv("UNSLOTH_STUDIO_URL", raising = False)
monkeypatch.setattr(inference, "find_studio_server", lambda *a, **k: "http://127.0.0.1:8888")
monkeypatch.setattr(inference, "verify_studio_identity", lambda *a, **k: False)
assert (
inference.connect_studio_server("m", hf_token = None, max_seq_length = 4096, load_in_4bit = False)
is None
)
def test_connect_unverified_loopback_without_cached_key_refuses_to_mint(
fake_studio, tmp_path, monkeypatch
):
# With no saved key, the next step would auto-mint; an unverified loopback
# server (port squatter) must be refused, with nothing sent.
monkeypatch.setattr(start, "verify_studio_identity", lambda base: False)
result = CliRunner().invoke(start.start_app, ["claude", "--no-launch"])
assert result.exit_code == 1
assert "--api-key" in result.output
assert not any(c[1].endswith("/api/auth/api-keys") for c in fake_studio) # never minted
def test_connect_replays_saved_key_without_identity_check(fake_studio, tmp_path, monkeypatch):
# A "saved" key (e.g. for an SSH-tunnelled Studio the handshake can't match)
# replays on keyless runs without the handshake, scoped to its own base.
cache = tmp_path / "agent_api_key.json"
cache.write_text(json.dumps({"servers": {BASE: {"saved": ["sk-unsloth-deadbeefdeadbeef"]}}}))
monkeypatch.setattr(start, "verify_studio_identity", lambda base: False)
result = CliRunner().invoke(start.start_app, ["claude", "--no-launch"])
assert result.exit_code == 0, result.output
_assert_env_set(result.output, "ANTHROPIC_AUTH_TOKEN", "sk-unsloth-deadbeefdeadbeef")
assert not any(c[1].endswith("/api/auth/api-keys") for c in fake_studio) # reused, not minted
def test_connect_minted_cache_requires_identity_check(fake_studio, tmp_path, monkeypatch):
# A "minted" key is NOT replayed to an unverified loopback server: minting and
# minted-key replay both sit behind the handshake, so a squatter can't grab it.
cache = tmp_path / "agent_api_key.json"
cache.write_text(json.dumps({"servers": {BASE: {"minted": ["sk-unsloth-feedfacefeedface"]}}}))
monkeypatch.setattr(start, "verify_studio_identity", lambda base: False)
result = CliRunner().invoke(start.start_app, ["claude", "--no-launch"])
assert result.exit_code == 1
assert "--api-key" in result.output
assert not any(c[1].endswith("/v1/models") for c in fake_studio) # minted key never sent
def test_connect_explicit_key_skips_identity_check(fake_studio, monkeypatch):
# An explicit key is the user's deliberate choice, so it does not require
# the automatic identity handshake.
monkeypatch.setattr(start, "verify_studio_identity", lambda base: False)
result = CliRunner().invoke(
start.start_app,
["claude", "--no-launch", "--api-key", "sk-unsloth-deadbeefdeadbeef"],
)
assert result.exit_code == 0, result.output
_assert_env_set(result.output, "ANTHROPIC_AUTH_TOKEN", "sk-unsloth-deadbeefdeadbeef")
def _serve_identity(proof_for):
"""Start a localhost HTTP server answering /api/auth/identity with
proof_for(nonce_bytes). Returns (base_url, shutdown)."""
import base64
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path != "/api/auth/identity":
self.send_response(404)
self.end_headers()
return
nonce = base64.urlsafe_b64decode(parse_qs(parsed.query)["nonce"][0])
host, port = self.server.server_address[0], self.server.server_address[1]
body = json.dumps({"proof": proof_for(nonce, host, port)}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
def log_message(self, *a):
pass
server = HTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target = server.serve_forever, daemon = True).start()
base = f"http://127.0.0.1:{server.server_address[1]}"
return base, server.shutdown
def test_verify_studio_identity_end_to_end(tmp_path, monkeypatch):
# Real crypto end to end: verify_studio_identity reads the install secret from
# an isolated DB; a "good" server proves the same secret, a spoofing one can't.
import unsloth_cli._inference as inference
inference.ensure_studio_backend_path()
try:
from studio.backend.auth import storage
except Exception as exc: # backend not importable here (e.g. missing deps)
pytest.skip(f"studio backend not importable: {exc}")
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "auth.db")
monkeypatch.setattr(storage, "_identity_secret_cache", None)
good = lambda nonce, host, port: storage.compute_identity_proof(
nonce, host, port
) # real secret
bad = lambda nonce, host, port: "00" * 32 # spoofer without the secret
base_ok, stop_ok = _serve_identity(good)
base_bad, stop_bad = _serve_identity(bad)
try:
assert inference.verify_studio_identity(base_ok) is True
assert inference.verify_studio_identity(base_bad) is False
finally:
stop_ok()
stop_bad()
def _serve_redirect(target):
"""Start a localhost server that 302-redirects every GET to target+path."""
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(302)
self.send_header("Location", target + self.path)
self.end_headers()
def log_message(self, *a):
pass
server = HTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target = server.serve_forever, daemon = True).start()
base = f"http://127.0.0.1:{server.server_address[1]}"
return base, server.shutdown
def test_verify_studio_identity_rejects_redirect(tmp_path, monkeypatch):
# A squatter could 302 /api/auth/identity to the real Studio and relay its
# proof; redirects must be refused so the squatter's base isn't accepted.
import unsloth_cli._inference as inference
inference.ensure_studio_backend_path()
try:
from studio.backend.auth import storage
except Exception as exc:
pytest.skip(f"studio backend not importable: {exc}")
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "auth.db")
monkeypatch.setattr(storage, "_identity_secret_cache", None)
real_base, stop_real = _serve_identity(
lambda nonce, host, port: storage.compute_identity_proof(nonce, host, port)
)
squatter_base, stop_squatter = _serve_redirect(real_base)
try:
assert inference.verify_studio_identity(real_base) is True # direct: ok
assert inference.verify_studio_identity(squatter_base) is False # relayed: refused
finally:
stop_real()
stop_squatter()
def test_verify_studio_identity_rejects_relayed_proof(tmp_path, monkeypatch):
# A squatter that proxies the nonce to the real Studio on another port gets a
# proof bound to *that* port; the client expects one bound to the port it
# connected to, so the relayed proof is rejected.
import unsloth_cli._inference as inference
inference.ensure_studio_backend_path()
try:
from studio.backend.auth import storage
except Exception as exc:
pytest.skip(f"studio backend not importable: {exc}")
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "auth.db")
monkeypatch.setattr(storage, "_identity_secret_cache", None)
real_base, stop_real = _serve_identity(
lambda nonce, host, port: storage.compute_identity_proof(nonce, host, port)
)
real_port = int(real_base.rsplit(":", 1)[1])
# The squatter answers on its own port but returns the proof for the real port.
squatter_base, stop_squatter = _serve_identity(
lambda nonce, host, port: storage.compute_identity_proof(nonce, host, real_port)
)
try:
assert inference.verify_studio_identity(real_base) is True
assert inference.verify_studio_identity(squatter_base) is False
finally:
stop_real()
stop_squatter()
@pytest.mark.parametrize(
"url, loopback",
[
("http://127.0.0.1:8888", True),
("http://localhost:8888", True),
("http://[::1]:8888", True),
("http://127.0.0.5:9001", True), # SSH tunnels can land anywhere in 127/8
("http://0.0.0.0:8888", False),
("http://10.0.0.5:8888", False),
("http://studio.evil.example:8888", False),
("https://studio.example.com", False),
],
)
def test_is_loopback_url(url, loopback):
assert start.is_loopback_url(url) is loopback
def test_connect_no_studio_errors(fake_studio, monkeypatch):
monkeypatch.setattr(start, "find_studio_server", lambda: None)
result = CliRunner().invoke(start.start_app, ["claude", "--no-launch"])
assert result.exit_code == 1
assert "No running Studio server" in result.output
@pytest.fixture(autouse = True)
def _reset_auto_served():
# Never let a test leave a fake server in the module slot (an atexit backstop would
# otherwise try to signal it at interpreter shutdown).
yield
start._auto_served_server = None
def test_start_studio_server_builds_command_and_waits(monkeypatch):
captured = {}
class FakePopen:
def __init__(self, command, **kwargs):
captured["command"] = command
captured["kwargs"] = kwargs
self.pid = 4321
def poll(self):
return None
monkeypatch.setattr(start.subprocess, "Popen", FakePopen)
monkeypatch.setattr(start, "_studio_healthy", lambda base, timeout = 3.0: True)
monkeypatch.setattr(start, "_log_tail", lambda path, lines = 20: "API Key: sk-unsloth-abc123")
monkeypatch.setattr(start.time, "sleep", lambda _s: None)
server = start._start_studio_server(
"http://127.0.0.1:8888",
"unsloth/Qwen3-1.7B-GGUF:UD-Q4_K_XL",
start.LoadOptions(
gguf_variant = "UD-Q4_K_XL", max_seq_length = 8192, load_in_4bit = True, tensor_parallel = True
),
)
cmd = captured["command"]
assert cmd[1] == "run"
assert "--disable-tools" in cmd and "--no-cloudflare" in cmd
assert cmd[cmd.index("--model") + 1] == "unsloth/Qwen3-1.7B-GGUF:UD-Q4_K_XL"
assert cmd[cmd.index("--gguf-variant") + 1] == "UD-Q4_K_XL"
assert cmd[cmd.index("--context-length") + 1] == "8192"
assert "--tensor-parallel" in cmd
assert cmd[cmd.index("-p") + 1] == "8888"
assert start.LoadOptions().load_in_4bit is True and "--no-load-in-4bit" not in cmd
assert captured["kwargs"].get("start_new_session") is True # own process group
assert server.pid == 4321
def test_auto_serves_when_no_server_then_tears_down(fake_studio, monkeypatch):
monkeypatch.setattr(start, "find_studio_server", lambda: None)
started = {}
fake = SimpleNamespace(pid = 999, poll = lambda: None)
def fake_start(base, model, load):
started.update(base = base, model = model, load = load)
start._auto_served_server = fake
return fake
monkeypatch.setattr(start, "_start_studio_server", fake_start)
monkeypatch.setattr(
start, "_shutdown_server", lambda server: started.__setitem__("down", server)
)
monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/claude")
monkeypatch.setattr(start.subprocess, "run", lambda command, env: SimpleNamespace(returncode = 0))
result = CliRunner().invoke(
start.start_app, ["claude", "--model", "unsloth/Qwen3-1.7B-GGUF:UD-Q4_K_XL"]
)
assert result.exit_code == 0, result.output
# The `:QUANT` suffix is split off into the gguf_variant so `unsloth run` gets a valid
# repo id plus `--gguf-variant`, mirroring how `unsloth run` accepts either form.
assert started["model"] == "unsloth/Qwen3-1.7B-GGUF"
assert started["load"].gguf_variant == "UD-Q4_K_XL"
assert started["base"] == BASE
# Torn down after the agent session ended.
assert started.get("down") is fake
def test_codex_preflight_failure_tears_down_auto_served(fake_studio, monkeypatch):
# The Codex GGUF preflight runs after _connect may have auto-started a server but
# before _run's teardown finally, so a preflight rejection must not leave the server
# holding the port/GPU (waiting on the atexit backstop).
monkeypatch.setattr(start, "find_studio_server", lambda: None)
started = {}
fake = SimpleNamespace(pid = 999, poll = lambda: None)
def fake_start(base, model, load):
started.update(base = base, model = model)
start._auto_served_server = fake
return fake
monkeypatch.setattr(start, "_start_studio_server", fake_start)
monkeypatch.setattr(
start, "_shutdown_server", lambda server: started.__setitem__("down", server)
)
inner = start._http_json
def http_json(
method,
url,
token,
payload = None,
timeout = 30,
error = None,
):
if url.endswith("/api/inference/status"):
return {"is_gguf": False, "model_identifier": "transformers-model"}
return inner(method, url, token, payload, timeout, error)
monkeypatch.setattr(start, "_http_json", http_json)
result = CliRunner().invoke(
start.start_app, ["codex", "--model", "unsloth/Qwen3-1.7B", "--launch"]
)
assert result.exit_code != 0, result.output
assert "GGUF" in result.output
# Torn down at the point the preflight rejected the model, not only via atexit.
assert started.get("down") is fake
def test_no_serve_preserves_error(fake_studio, monkeypatch):
monkeypatch.setattr(start, "find_studio_server", lambda: None)
started = {"called": False}
monkeypatch.setattr(
start, "_start_studio_server", lambda *a, **k: started.__setitem__("called", True)
)
result = CliRunner().invoke(
start.start_app, ["claude", "--model", "unsloth/Qwen3-1.7B-GGUF", "--no-serve"]
)
assert result.exit_code == 1
assert "No running Studio server" in result.output
assert started["called"] is False
def test_no_launch_never_serves(fake_studio, monkeypatch):
monkeypatch.setattr(start, "find_studio_server", lambda: None)
started = {"called": False}
monkeypatch.setattr(
start, "_start_studio_server", lambda *a, **k: started.__setitem__("called", True)
)
result = CliRunner().invoke(
start.start_app, ["claude", "--model", "unsloth/Qwen3-1.7B-GGUF", "--no-launch"]
)
assert result.exit_code == 1
assert "No running Studio server" in result.output
assert started["called"] is False
def test_no_server_no_model_hints_model_flag(fake_studio, monkeypatch):
monkeypatch.setattr(start, "find_studio_server", lambda: None)
result = CliRunner().invoke(start.start_app, ["claude"])
assert result.exit_code == 1
assert "--model" in result.output
@pytest.mark.parametrize(
"base, expected",
[
("http://127.0.0.1", "http://127.0.0.1:8888"), # portless -> unsloth run's :8888
("http://127.0.0.1:8888", "http://127.0.0.1:8888"), # explicit port kept
("http://127.0.0.1:9000", "http://127.0.0.1:9000"),
("http://localhost", "http://localhost:8888"),
("http://[::1]", "http://[::1]:8888"), # IPv6 literal stays bracketed
("http://[::1]:8888", "http://[::1]:8888"),
# Paths are stripped: unsloth run serves at the root, so /studio would make the
# health poll hit /studio/api/health (404) until the startup timeout.
("http://127.0.0.1:8888/studio", "http://127.0.0.1:8888"),
("http://127.0.0.1/studio", "http://127.0.0.1:8888"),
],
)
def test_effective_base(base, expected):
assert start._effective_base(base) == expected
def test_auto_serve_normalizes_portless_url(fake_studio, monkeypatch):
# A portless UNSLOTH_STUDIO_URL must launch AND poll :8888 (what unsloth run binds),
# not port 80, or readiness never matches and we hit the startup timeout.
monkeypatch.setenv("UNSLOTH_STUDIO_URL", "http://127.0.0.1")
monkeypatch.setattr(start, "find_studio_server", lambda: None)
started = {}
fake = SimpleNamespace(pid = 999, poll = lambda: None)
def fake_start(base, model, load):
started["base"] = base
start._auto_served_server = fake
return fake
monkeypatch.setattr(start, "_start_studio_server", fake_start)
monkeypatch.setattr(start, "_shutdown_server", lambda server: None)
monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/claude")
monkeypatch.setattr(start.subprocess, "run", lambda command, env: SimpleNamespace(returncode = 0))
result = CliRunner().invoke(start.start_app, ["claude", "--model", "unsloth/Qwen3-1.7B-GGUF"])
assert result.exit_code == 0, result.output
assert started["base"] == "http://127.0.0.1:8888"
def test_connect_explicit_api_key_skips_mint(fake_studio):
result = CliRunner().invoke(
start.start_app,
["claude", "--no-launch", "--api-key", "sk-unsloth-deadbeefdeadbeef"],
)
assert result.exit_code == 0, result.output
_assert_env_set(result.output, "ANTHROPIC_AUTH_TOKEN", "sk-unsloth-deadbeefdeadbeef")
assert not any(c[1].endswith("/api/auth/api-keys") for c in fake_studio)
# ── OpenClaw (Anthropic /v1/messages) ────────────────────────────────
def test_write_openclaw_config_fresh(tmp_path):
path = tmp_path / "openclaw.json"
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path)
config = json.loads(path.read_text())
provider = config["models"]["providers"]["unsloth"]
assert provider["baseUrl"] == f"{BASE}/v1"
assert provider["apiKey"] == "sk-unsloth-abc"
assert provider["api"] == "openai-completions"
assert provider["models"] == [
{"id": MODEL["id"], "name": MODEL["id"], "contextWindow": MODEL["context_length"]}
]
# The default model must be pinned or OpenClaw has nothing active.
assert config["agents"]["defaults"]["model"]["primary"] == f"unsloth/{MODEL['id']}"
assert config["gateway"]["mode"] == "local"
assert config["gateway"]["auth"]["mode"] == "none" # unauth loopback gateway
if os.name != "nt": # the file holds an API key
assert path.stat().st_mode & 0o777 == 0o600
def test_write_openclaw_config_preserves_and_idempotent(tmp_path):
path = tmp_path / "openclaw.json"
path.write_text(
json.dumps(
{
"theme": "dark",
"agents": {"defaults": {"temperature": 0.5}},
"models": {"mode": "replace", "providers": {"openrouter": {"baseUrl": "x"}}},
}
)
)
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path)
config = json.loads(path.read_text())
assert config["theme"] == "dark"
assert config["agents"]["defaults"]["temperature"] == 0.5 # other agent defaults kept
assert config["agents"]["defaults"]["model"]["primary"] == f"unsloth/{MODEL['id']}"
assert config["models"]["mode"] == "replace" # user's mode is left as-is
assert config["models"]["providers"]["openrouter"]["baseUrl"] == "x"
assert config["models"]["providers"]["unsloth"]["baseUrl"] == f"{BASE}/v1"
before = path.read_text()
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path)
assert path.read_text() == before
def test_write_openclaw_config_corrupt_left_alone(tmp_path, capsys):
path = tmp_path / "openclaw.json"
path.write_text("{not json")
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path)
assert path.read_text() == "{not json"
assert "couldn't parse" in capsys.readouterr().err
def test_connect_openclaw_no_launch(fake_studio, tmp_path):
result = CliRunner().invoke(start.start_app, ["openclaw", "--no-launch"])
assert result.exit_code == 0, result.output
assert "openclaw" in result.output
config_path = tmp_path / "agents" / "openclaw" / "openclaw.json"
# Config + state are scoped to the session dir, not the user's ~/.openclaw.
_assert_env_set(result.output, "OPENCLAW_CONFIG_PATH", str(config_path))
_assert_env_set(result.output, "OPENCLAW_STATE_DIR", str(tmp_path / "agents" / "openclaw"))
config = json.loads(config_path.read_text())
assert config["models"]["providers"]["unsloth"]["apiKey"] == "sk-unsloth-feedfacefeedface"
assert config["agents"]["defaults"]["model"]["primary"] == f"unsloth/{MODEL['id']}"
# OpenAI /v1/chat/completions works on either backend — no GGUF gate.
assert not any(c[1].endswith("/api/inference/status") for c in fake_studio)
# ── OpenCode (OpenAI /v1/chat/completions) ───────────────────────────
def test_write_opencode_config_fresh(tmp_path):
path = tmp_path / "opencode.json"
start.write_opencode_config(BASE, "sk-unsloth-abc", MODEL, path)
config = json.loads(path.read_text())
provider = config["provider"]["unsloth"]
assert provider["npm"] == "@ai-sdk/openai-compatible"
assert provider["options"] == {"baseURL": f"{BASE}/v1", "apiKey": "sk-unsloth-abc"}
# Context limit must be declared, or OpenCode treats it as 0 and disables compaction.
assert provider["models"] == {
MODEL["id"]: {"name": MODEL["id"], "limit": {"context": 131072, "output": 8192}}
}
assert config["model"] == f"unsloth/{MODEL['id']}"
# Compaction buffer scaled to ~10% of the window (compact near 90%).
assert config["compaction"] == {"auto": True, "reserved": 131072 // 10}
def test_write_opencode_config_preserves_and_idempotent(tmp_path):
path = tmp_path / "opencode.json"
path.write_text(
json.dumps({"theme": "tokyonight", "provider": {"anthropic": {"name": "Anthropic"}}})
)
start.write_opencode_config(BASE, "sk-unsloth-abc", MODEL, path)
config = json.loads(path.read_text())
assert config["theme"] == "tokyonight"
assert config["provider"]["anthropic"]["name"] == "Anthropic"
assert config["provider"]["unsloth"]["options"]["baseURL"] == f"{BASE}/v1"
before = path.read_text()
start.write_opencode_config(BASE, "sk-unsloth-abc", MODEL, path)
assert path.read_text() == before
def test_connect_opencode_no_launch(fake_studio, tmp_path):
result = CliRunner().invoke(start.start_app, ["opencode", "--no-launch"])
assert result.exit_code == 0, result.output
assert "opencode" in result.output
config_path = tmp_path / "agents" / "opencode" / "opencode.json"
# OPENCODE_CONFIG overlay points at the session file, not the user's global config.
_assert_env_set(result.output, "OPENCODE_CONFIG", str(config_path))
config = json.loads(config_path.read_text())
assert config["provider"]["unsloth"]["options"]["apiKey"] == "sk-unsloth-feedfacefeedface"
assert config["model"] == f"unsloth/{MODEL['id']}"
assert not any(c[1].endswith("/api/inference/status") for c in fake_studio)
# ── Hermes (OpenAI /v1/chat/completions, key via env) ────────────────
@pytest.fixture()
def hermes_config(tmp_path):
return tmp_path / "config.yaml"
def test_write_hermes_config_fresh(hermes_config):
yaml = pytest.importorskip("yaml")
start.write_hermes_config(BASE, MODEL, hermes_config)
config = yaml.safe_load(hermes_config.read_text())
# Hermes only honors the key for a *named* custom provider, so the endpoint
# is registered under providers.* and model.provider points at it.
assert config["model"]["provider"] == "custom:unsloth"
assert config["model"]["default"] == MODEL["id"]
assert config["model"]["api_mode"] == "openai"
# Pin the real context window (top-level override) and compact at 90% of it.
assert config["model"]["context_length"] == MODEL["context_length"]
assert config["compression"] == {"enabled": True, "threshold": 0.9}
# Windows at or above Hermes' floor need no auxiliary compression override.
assert "auxiliary" not in config
provider = config["providers"]["unsloth"]
assert provider["base_url"] == f"{BASE}/v1"
assert provider["api_mode"] == "openai"
assert provider["key_env"] == "UNSLOTH_API_KEY"
# The key is resolved from the launch env, never written to disk.
assert "sk-unsloth" not in hermes_config.read_text()
def test_write_hermes_config_small_window_claims_floor(hermes_config):
yaml = pytest.importorskip("yaml")
small = {"id": "unsloth/Qwen3-1.7B-GGUF", "context_length": 40960}
start.write_hermes_config(BASE, small, hermes_config)
config = yaml.safe_load(hermes_config.read_text())
# Hermes refuses to initialize below its 64,000-token floor, so the recipe
# claims the floor and scales the compaction threshold so it still fires at
# 90% of the REAL window: 0.9 * 40960 / 65536.
assert config["model"]["context_length"] == 65536
assert config["compression"] == {"enabled": True, "threshold": 0.5625}
# The same floor check runs against the compression model mid-session.
assert config["auxiliary"]["compression"]["context_length"] == 65536
def test_write_hermes_config_preserves_and_idempotent(hermes_config):
yaml = pytest.importorskip("yaml")
hermes_config.write_text(
yaml.safe_dump(
{
"terminal": {"backend": "local"},
"model": {"temperature": 0.7},
"providers": {"openrouter": {"base_url": "https://openrouter.ai/api/v1"}},
}
)
)
start.write_hermes_config(BASE, MODEL, hermes_config)
config = yaml.safe_load(hermes_config.read_text())
assert config["terminal"] == {"backend": "local"} # unrelated sections kept
assert config["model"]["temperature"] == 0.7 # unrelated model keys kept
assert config["model"]["provider"] == "custom:unsloth"
assert config["providers"]["openrouter"]["base_url"] == "https://openrouter.ai/api/v1"
assert config["providers"]["unsloth"]["base_url"] == f"{BASE}/v1"
before = hermes_config.read_text()
start.write_hermes_config(BASE, MODEL, hermes_config)
assert hermes_config.read_text() == before
def test_write_hermes_config_preserves_non_mapping_file(hermes_config, capsys):
pytest.importorskip("yaml")
original = "- just\n- a\n- list\n" # valid YAML, but not a mapping
hermes_config.write_text(original)
start.write_hermes_config(BASE, MODEL, hermes_config)
assert hermes_config.read_text() == original # user-managed file left untouched
assert "couldn't parse" in capsys.readouterr().err
def test_connect_hermes_no_launch(fake_studio, tmp_path):
yaml = pytest.importorskip("yaml")
result = CliRunner().invoke(start.start_app, ["hermes", "--no-launch"])
assert result.exit_code == 0, result.output
_assert_env_set(result.output, "UNSLOTH_API_KEY", "sk-unsloth-feedfacefeedface")
# HERMES_HOME relocates the whole hermes home, so the user's ~/.hermes is untouched.
home = tmp_path / "agents" / "hermes"
_assert_env_set(result.output, "HERMES_HOME", str(home))
assert "hermes" in result.output
config = yaml.safe_load((home / "config.yaml").read_text())
assert config["model"]["provider"] == "custom:unsloth"
assert config["providers"]["unsloth"]["base_url"] == f"{BASE}/v1"
assert config["model"]["default"] == MODEL["id"]
assert not any(c[1].endswith("/api/inference/status") for c in fake_studio)
# ── Pi (OpenAI-compatible /v1, key in config, ~/.pi relocated via HOME) ──
def test_write_pi_config_fresh(tmp_path):
path = tmp_path / ".pi" / "agent" / "models.json"
start.write_pi_config(BASE, "sk-unsloth-abc", MODEL, path)
config = json.loads(path.read_text())
provider = config["providers"]["unsloth"]
assert provider["api"] == "openai-completions"
assert provider["baseUrl"] == f"{BASE}/v1"
assert provider["apiKey"] == "sk-unsloth-abc"
# Pin the loaded window (and a sane output cap) so Pi compacts instead of
# overflowing; without it Pi assumes its 128000 default.
assert provider["models"] == [
{"id": MODEL["id"], "contextWindow": MODEL["context_length"], "maxTokens": 8192}
]
def test_write_pi_config_preserves_and_idempotent(tmp_path):
path = tmp_path / ".pi" / "agent" / "models.json"
path.parent.mkdir(parents = True)
path.write_text(json.dumps({"providers": {"google": {"api": "gemini"}}}))
start.write_pi_config(BASE, "sk-unsloth-abc", MODEL, path)
config = json.loads(path.read_text())
assert config["providers"]["google"] == {"api": "gemini"} # unrelated provider kept
assert config["providers"]["unsloth"]["baseUrl"] == f"{BASE}/v1"
before = path.read_text()
start.write_pi_config(BASE, "sk-unsloth-abc", MODEL, path)
assert path.read_text() == before
def test_connect_pi_no_launch(fake_studio, tmp_path):
result = CliRunner().invoke(start.start_app, ["pi", "--no-launch"])
assert result.exit_code == 0, result.output
# Pi resolves its config dir from PI_CODING_AGENT_DIR first, so pin it at the session
# dir (and relocate HOME) to keep the user's real ~/.pi untouched and their own
# PI_CODING_AGENT_DIR from redirecting Pi away from our provider/key.
home = tmp_path / "agents" / "pi"
_assert_env_set(result.output, "HOME", str(home))
_assert_env_set(result.output, "PI_CODING_AGENT_DIR", str(home / ".pi" / "agent"))
# Provider/model pinned on the command (Pi defaults to google otherwise).
assert f"pi --provider unsloth --model {MODEL['id']}" in result.output
config = json.loads((home / ".pi" / "agent" / "models.json").read_text())
assert config["providers"]["unsloth"]["apiKey"] == "sk-unsloth-feedfacefeedface"
assert config["providers"]["unsloth"]["models"] == [
{"id": MODEL["id"], "contextWindow": MODEL["context_length"], "maxTokens": 8192}
]
assert not any(c[1].endswith("/api/inference/status") for c in fake_studio)
def test_connect_pi_no_launch_windows_relocates_userprofile(fake_studio, tmp_path, monkeypatch):
# On native Windows Node resolves ~/.pi via USERPROFILE, not HOME, so the session
# must point USERPROFILE at the relocated home or Pi reads the user's real ~/.pi.
monkeypatch.setattr(start.os, "name", "nt")
result = CliRunner().invoke(start.start_app, ["pi", "--no-launch"])
assert result.exit_code == 0, result.output
home = tmp_path / "agents" / "pi"
assert f'$env:HOME = "{home}"' in result.output
assert f'$env:USERPROFILE = "{home}"' in result.output
# ── WSLENV path translation + PowerShell quoting (helper units) ──
def test_wsl_bridge_names_flags_paths_not_scalars():
# WSLENV only translates a var to a Windows path when its entry carries /p.
# Path-valued vars must get it; scalar knobs and URLs must not, or WSLENV would
# mangle them when handing off to a Windows shim under /mnt.
env = {
"CODEX_HOME": "/tmp/sess/codex",
"HOME": "/tmp/sess/pi",
"CLAUDE_CODE_AUTO_COMPACT_WINDOW": "4096",
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8888",
"USERPROFILE": r"C:\Users\x",
}
names = start._wsl_bridge_names(env, ("ANTHROPIC_API_KEY",))
assert "CODEX_HOME/p" in names
assert "HOME/p" in names
assert "USERPROFILE/p" in names # drive-qualified Windows path
assert "CLAUDE_CODE_AUTO_COMPACT_WINDOW" in names # scalar: no /p
assert "CLAUDE_CODE_AUTO_COMPACT_WINDOW/p" not in names
assert "ANTHROPIC_BASE_URL" in names # URL is not a filesystem path
assert "ANTHROPIC_API_KEY" in names # cleared var carries no value to translate
def test_merge_wslenv_dedups_on_base_name():
# An already-shared var must not be appended again just because the flag differs.
merged = start._merge_wslenv("CODEX_HOME/p:FOO", ("CODEX_HOME/p", "BAR/p"))
parts = merged.split(":")
assert parts.count("CODEX_HOME/p") == 1
assert "FOO" in parts and "BAR/p" in parts
def test_merge_wslenv_upgrades_existing_unflagged_entry():
# A user's pre-existing bare "HOME" must be upgraded to "HOME/p" (not left bare or
# duplicated), or the Windows shim gets the path without WSL translation.
merged = start._merge_wslenv("HOME:FOO", ("HOME/p", "CODEX_HOME/p"))
parts = merged.split(":")
assert "HOME/p" in parts and "HOME" not in parts # upgraded in place
assert parts.count("HOME/p") == 1
assert "FOO" in parts # untouched user var preserved
assert "CODEX_HOME/p" in parts
def test_powershell_quote_single_quotes_json():
# Bare flags/paths pass through; JSON payloads get single-quoted so PowerShell
# keeps the embedded double quotes literal (list2cmdline's backslashes would not).
assert start._powershell_quote("--settings") == "--settings"
assert start._powershell_quote("unsloth/gemma-4-26B") == "unsloth/gemma-4-26B"
quoted = start._powershell_quote(start._CLAUDE_SETTINGS_OVERLAY)
assert quoted == "'" + start._CLAUDE_SETTINGS_OVERLAY + "'"
assert "\\" not in quoted # no cmd.exe backslash escaping
assert start._powershell_quote("a'b") == "'a''b'" # embedded quote doubled
# ── --yolo: one switch routed to each agent's own auto-approve form ──
# The native "run tools without prompting" CLI flag each agent should receive.
_NATIVE_YOLO = {
"claude": "--dangerously-skip-permissions",
"codex": "--dangerously-bypass-approvals-and-sandbox",
"hermes": "--yolo",
"pi": "--approve",
}
@pytest.mark.parametrize("agent, native", sorted(_NATIVE_YOLO.items()))
def test_yolo_routes_to_native_flag(fake_studio, agent, native):
result = CliRunner().invoke(start.start_app, [agent, "--yolo", "--no-launch"])
assert result.exit_code == 0, result.output
assert native in result.output
@pytest.mark.parametrize("agent, native", sorted(_NATIVE_YOLO.items()))
def test_no_yolo_omits_native_flag(fake_studio, agent, native):
result = CliRunner().invoke(start.start_app, [agent, "--no-launch"])
assert result.exit_code == 0, result.output
# pi's --approve is a real flag only added under --yolo; assert it's absent here.
command = _launch_command(result.output)
assert command and command[0] == agent, result.output
assert native not in command
@pytest.mark.parametrize(
"alias",
["--yolo", "--dangerously-skip-permissions", "--dangerously-bypass-approvals-and-sandbox"],
)
def test_yolo_aliases_are_interchangeable(fake_studio, alias):
# Any spelling on any agent routes to that agent's own flag, even the "wrong" one.
claude = CliRunner().invoke(start.start_app, ["claude", alias, "--no-launch"])
assert claude.exit_code == 0, claude.output
assert "--dangerously-skip-permissions" in claude.output
# The codex spelling must not leak through to Claude's command line.
assert "--dangerously-bypass-approvals-and-sandbox" not in claude.output
codex = CliRunner().invoke(start.start_app, ["codex", alias, "--no-launch"])
assert codex.exit_code == 0, codex.output
assert "--dangerously-bypass-approvals-and-sandbox" in codex.output
assert "--dangerously-skip-permissions" not in codex.output
def test_yolo_opencode_writes_permission_block(fake_studio, tmp_path):
result = CliRunner().invoke(start.start_app, ["opencode", "--yolo", "--no-launch"])
assert result.exit_code == 0, result.output
config = json.loads((tmp_path / "agents" / "opencode" / "opencode.json").read_text())
assert config["permission"] == {"edit": "allow", "bash": "allow", "webfetch": "allow"}
def test_no_yolo_opencode_has_no_permission_block(fake_studio, tmp_path):
result = CliRunner().invoke(start.start_app, ["opencode", "--no-launch"])
assert result.exit_code == 0, result.output
config = json.loads((tmp_path / "agents" / "opencode" / "opencode.json").read_text())
assert "permission" not in config
def test_yolo_openclaw_writes_exec_policy(fake_studio, tmp_path):
result = CliRunner().invoke(start.start_app, ["openclaw", "--yolo", "--no-launch"])
assert result.exit_code == 0, result.output
state = tmp_path / "agents" / "openclaw"
config = json.loads((state / "openclaw.json").read_text())
assert config["tools"]["exec"] == {"host": "gateway", "security": "full", "ask": "off"}
# Both layers: the host approvals file in OPENCLAW_STATE_DIR must also be set, or
# OpenClaw can still prompt/deny despite the config.
approvals = json.loads((state / "exec-approvals.json").read_text())
assert approvals["defaults"] == {"security": "full", "ask": "off", "askFallback": "full"}
def test_no_yolo_openclaw_has_no_exec_policy(fake_studio, tmp_path):
result = CliRunner().invoke(start.start_app, ["openclaw", "--no-launch"])
assert result.exit_code == 0, result.output
state = tmp_path / "agents" / "openclaw"
config = json.loads((state / "openclaw.json").read_text())
assert "exec" not in config.get("tools", {}) # no auto-approve policy without --yolo
assert not (state / "exec-approvals.json").exists()
def test_write_opencode_config_yolo_unit(tmp_path):
path = tmp_path / "opencode.json"
start.write_opencode_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = True)
config = json.loads(path.read_text())
assert config["permission"] == {"edit": "allow", "bash": "allow", "webfetch": "allow"}
def test_write_openclaw_config_yolo_unit(tmp_path):
path = tmp_path / "openclaw.json"
start.write_openclaw_config(BASE, "sk-unsloth-abc", MODEL, path, yolo = True)
config = json.loads(path.read_text())
assert config["tools"]["exec"] == {"host": "gateway", "security": "full", "ask": "off"}
approvals = json.loads((path.parent / "exec-approvals.json").read_text())
assert approvals == {
"version": 1,
"defaults": {"security": "full", "ask": "off", "askFallback": "full"},
}
def test_yolo_command_flags_unmapped_agent_is_empty():
# Config-based agents (and any typo) must yield no flag, not a KeyError.
assert start._yolo_command_flags("opencode", True) == []
assert start._yolo_command_flags("openclaw", True) == []
assert start._yolo_command_flags("claude", True) == ["--dangerously-skip-permissions"]
assert start._yolo_command_flags("claude", False) == []
def test_yolo_config_agents_add_no_command_flag(fake_studio):
# opencode/openclaw auto-approve is config-only; nothing should leak onto argv.
for agent in ("opencode", "openclaw"):
result = CliRunner().invoke(start.start_app, [agent, "--yolo", "--no-launch"])
assert result.exit_code == 0, result.output
command = _launch_command(result.output)
assert command and command[0] == agent, result.output
assert not any("--yolo" in arg or "--dangerous" in arg for arg in command)
def test_pi_launch_clears_screen_first(fake_studio, monkeypatch):
# Pi paints inline from the current cursor position (no alternate screen, no
# clear on its first render), so the launcher hands it a clean screen. The
# clear must come BEFORE the exec, and only on the launch path.
calls = []
monkeypatch.setattr(start.click, "clear", lambda: calls.append("clear"))
monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/pi")
def run(command, env):
calls.append("exec")
return SimpleNamespace(returncode = 0)
monkeypatch.setattr(start.subprocess, "run", run)
result = CliRunner().invoke(start.start_app, ["pi"])
assert result.exit_code == 0, result.output
assert calls == ["clear", "exec"]
def test_pi_no_launch_does_not_clear(fake_studio, monkeypatch):
# The --no-launch recipe is meant to be read (and piped); never wipe it.
calls = []
monkeypatch.setattr(start.click, "clear", lambda: calls.append("clear"))
result = CliRunner().invoke(start.start_app, ["pi", "--no-launch"])
assert result.exit_code == 0, result.output
assert calls == []
def test_claude_launch_does_not_clear(fake_studio, monkeypatch):
# Alternate-screen agents manage the terminal themselves; leave it alone.
calls = []
monkeypatch.setattr(start.click, "clear", lambda: calls.append("clear"))
monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/claude")
monkeypatch.setattr(start, "_claude_flags", lambda: [])
monkeypatch.setattr(start.subprocess, "run", lambda command, env: SimpleNamespace(returncode = 0))
result = CliRunner().invoke(start.start_app, ["claude"])
assert result.exit_code == 0, result.output
assert calls == []
@pytest.mark.skipif(
os.name == "nt",
reason = "WSL-from-Linux scenario: a Windows pi shim under /mnt called from WSL "
"(os.name is 'posix' under WSL), so this can't run on a native Windows runner.",
)
def test_connect_pi_wsl_windows_shim_relocates_userprofile(fake_studio, monkeypatch):
captured = {}
monkeypatch.setenv("WSL_DISTRO_NAME", "Ubuntu")
monkeypatch.setattr(start.shutil, "which", lambda _: "/mnt/c/Users/x/AppData/Roaming/npm/pi")
def run(command, env):
captured["env"] = env
return SimpleNamespace(returncode = 0)
monkeypatch.setattr(start.subprocess, "run", run)
result = CliRunner().invoke(start.start_app, ["pi"])
assert result.exit_code == 0, result.output
home = captured["env"]["HOME"]
# A Windows pi shim resolves ~/.pi via USERPROFILE, so it must match the session
# HOME and ride the WSLENV bridge (with /p) so the path is translated for Windows.
assert captured["env"]["USERPROFILE"] == home
wslenv = captured["env"]["WSLENV"].split(":")
assert "HOME/p" in wslenv
assert "USERPROFILE/p" in wslenv
def test_agent_api_key_auto_started_rejected_env_key_falls_back(fake_studio, tmp_path, monkeypatch):
# UNSLOTH_API_KEY exported for some OTHER server must not fail the launch
# against a server this run just auto-started: validate, then fall back to
# the local mint path, and never remember the foreign key for this base.
inner = start._http_json
def http_json(
method,
url,
token,
payload = None,
timeout = 30,
error = None,
):
if url.endswith("/v1/models") and token == "sk-unsloth-other-server":
raise urllib.error.HTTPError(url, 401, "Unauthorized", None, None)
return inner(method, url, token, payload, timeout, error)
monkeypatch.setattr(start, "_http_json", http_json)
key = start._agent_api_key(BASE, "sk-unsloth-other-server", auto_started = True)
assert key == "sk-unsloth-feedfacefeedface" # minted for the fresh server
cached = json.loads((tmp_path / "agent_api_key.json").read_text())
assert "sk-unsloth-other-server" not in json.dumps(cached["servers"].get(BASE, {}))
def test_agent_api_key_auto_started_accepted_key_is_honored(fake_studio, tmp_path):
# An explicit key the fresh server accepts (e.g. persisted in this Studio
# home's auth db across restarts) keeps working exactly as before.
key = start._agent_api_key(BASE, "sk-unsloth-deadbeefdeadbeef", auto_started = True)
assert key == "sk-unsloth-deadbeefdeadbeef"
cached = json.loads((tmp_path / "agent_api_key.json").read_text())
assert cached["servers"][BASE]["saved"] == ["sk-unsloth-deadbeefdeadbeef"]
def test_session_config_no_launch_preserves_existing_state(fake_studio, tmp_path):
# A previously printed recipe may still be running an agent whose sessions
# or sqlite state live in the stable home; a re-run must not wipe it.
with start._session_config("codex", launch = False) as home:
marker = home / "sessions" / "live.sqlite"
marker.parent.mkdir(parents = True)
marker.write_text("state")
with start._session_config("codex", launch = False) as home2:
assert home2 == home
assert (home2 / "sessions" / "live.sqlite").read_text() == "state"