-
-
Notifications
You must be signed in to change notification settings - Fork 6.9k
Expand file tree
/
Copy pathtest_start.py
More file actions
2949 lines (2474 loc) · 128 KB
/
Copy pathtest_start.py
File metadata and controls
2949 lines (2474 loc) · 128 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# 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.os, "name", "posix")
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_uses_powershell_on_windows(monkeypatch):
monkeypatch.setattr(start.os, "name", "nt")
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),
)
monkeypatch.setattr(start.shutil, "which", lambda _: r"C:\Users\samle\bin\hermes.exe")
install_hint = "& ([scriptblock]::Create((irm https://x/install.ps1))) -SkipSetup"
executable = start._install_agent("hermes", install_hint)
assert executable == r"C:\Users\samle\bin\hermes.exe"
assert ran == [["powershell", "-NoProfile", "-Command", install_hint]]
def test_install_agent_warns_remote_installer_is_unverified_third_party(monkeypatch, capsys):
# Before the confirm, a remote installer must name the URL it fetches so the
# user consents to a specific source rather than blindly accepting.
monkeypatch.setattr(start.os, "name", "nt")
monkeypatch.setattr(start.sys, "stdin", SimpleNamespace(isatty = lambda: True))
monkeypatch.setattr(start.typer, "confirm", lambda *a, **k: False) # decline: nothing runs
hint = "& ([scriptblock]::Create((irm https://hermes-agent.nousresearch.com/install.ps1))) -SkipSetup"
assert start._install_agent("hermes", hint) is None
err = capsys.readouterr().err
assert "Security warning" in err
assert "unverified third-party script" in err
assert "https://hermes-agent.nousresearch.com/install.ps1" in err
assert "Unsloth does not pin or verify the downloaded content" in err
assert "Continue only if you trust this source" in err
def test_install_agent_warns_for_package_installer(monkeypatch, capsys):
# An npm-style installer has no URL to fetch, but still runs with the user's
# privileges, so the warning names the command instead.
monkeypatch.setattr(start.os, "name", "posix")
monkeypatch.setattr(start.sys, "stdin", SimpleNamespace(isatty = lambda: True))
monkeypatch.setattr(start.typer, "confirm", lambda *a, **k: False)
assert start._install_agent("codex", "npm install -g @openai/codex") is None
err = capsys.readouterr().err
assert "npm install -g @openai/codex" in err
assert "with your privileges" in err
def test_hermes_install_hint_is_windows_native_on_windows(monkeypatch):
monkeypatch.setattr(start.os, "name", "nt")
# Scriptblock form so `-SkipSetup` reaches the installer and the interactive
# setup wizard is skipped during the unattended `unsloth start hermes` run.
assert start._hermes_install_hint() == (
"& ([scriptblock]::Create((irm https://hermes-agent.nousresearch.com/install.ps1)))"
" -SkipSetup"
)
def test_hermes_install_hint_is_bash_on_posix(monkeypatch):
monkeypatch.setattr(start.os, "name", "posix")
# `bash -s -- --skip-setup` forwards the skip flag to the piped installer.
assert start._hermes_install_hint() == (
"curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent"
"/main/scripts/install.sh | bash -s -- --skip-setup"
)
def test_refresh_windows_path_noop_off_windows(monkeypatch):
monkeypatch.setattr(start.os, "name", "posix")
before = os.environ.get("PATH", "")
monkeypatch.setenv("PATH", before)
start._refresh_windows_path()
assert os.environ.get("PATH", "") == before
def test_refresh_windows_path_merges_registry_hives(monkeypatch):
# Fake Windows registry PATH values written after this process started.
hkcu, hklm = object(), object()
reg = {
(hkcu, "Environment"): r"C:\existing;C:\Users\me\hermes\bin",
(
hklm,
r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment",
): r"C:\Windows\System32",
}
class _Key:
def __init__(self, value):
self._value = value
def __enter__(self):
return self
def __exit__(self, *a):
return False
def open_key(root, sub):
if (root, sub) in reg:
return _Key(reg[(root, sub)])
raise OSError("missing hive")
fake_winreg = SimpleNamespace(
HKEY_CURRENT_USER = hkcu,
HKEY_LOCAL_MACHINE = hklm,
OpenKey = open_key,
QueryValueEx = lambda key, name: (key._value, 1),
)
monkeypatch.setattr(start.os, "name", "nt")
monkeypatch.setattr(start.os, "pathsep", ";")
monkeypatch.setitem(sys.modules, "winreg", fake_winreg)
monkeypatch.setenv("PATH", r"C:\custom;C:\existing")
start._refresh_windows_path()
assert os.environ["PATH"].split(";") == [
r"C:\custom",
r"C:\existing",
r"C:\Users\me\hermes\bin",
r"C:\Windows\System32",
]
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, monkeypatch):
monkeypatch.setattr(start, "_codex_supports_model_catalog", lambda: True)
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
catalog_path = Path(profile["model_catalog_json"])
assert catalog_path == Path("model-catalog.json")
catalog = json.loads((tmp_path / catalog_path).read_text())
assert catalog["models"][0]["slug"] == MODEL["id"]
assert catalog["models"][0]["context_window"] == 131072
assert catalog["models"][0]["max_context_window"] == 131072
assert catalog["models"][0]["supports_reasoning_summary_parameter"] is False
assert catalog["models"][0]["supports_parallel_tool_calls"] is False
assert catalog["models"][0]["base_instructions"] == start._CODEX_FALLBACK_PROMPT.read_text()
config = _parse_toml((tmp_path / "config.toml").read_text())
assert config["model_providers"]["unsloth_api"]["env_key"] == "UNSLOTH_STUDIO_AUTH_TOKEN"
def test_write_codex_config_catalog_without_context_length(tmp_path, monkeypatch):
monkeypatch.setattr(start, "_codex_supports_model_catalog", lambda: True)
start.write_codex_config(BASE, {"id": "unsloth/no-window"}, tmp_path)
profile = _parse_toml((tmp_path / "unsloth_api.config.toml").read_text())
catalog = json.loads((tmp_path / profile["model_catalog_json"]).read_text())
entry = catalog["models"][0]
assert entry["slug"] == "unsloth/no-window"
assert "context_window" not in entry
assert "max_context_window" not in entry
@pytest.mark.parametrize(
("version", "expected"),
[("codex-cli 0.109.0", False), ("codex-cli 0.110.0", True), ("codex-cli 0.144.4", True)],
)
def test_codex_model_catalog_version_gate(monkeypatch, version, expected):
monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/codex")
monkeypatch.setattr(start.subprocess, "check_output", lambda *args, **kwargs: version)
assert start._codex_supports_model_catalog() is expected
def test_write_codex_config_omits_catalog_for_old_codex(tmp_path, monkeypatch):
monkeypatch.setattr(start, "_codex_supports_model_catalog", lambda: False)
start.write_codex_config(BASE, MODEL, tmp_path)
profile = _parse_toml((tmp_path / "unsloth_api.config.toml").read_text())
assert "model_catalog_json" not in profile
assert not (tmp_path / "model-catalog.json").exists()
@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_matches_requested_model_case_insensitively(fake_studio, tmp_path):
result = CliRunner().invoke(
start.start_app,
[
"codex",
"--no-launch",
"--model",
"unsloth/gemma-4-26b-a4b-it-gguf",
],
)
assert result.exit_code == 0, result.output
home = tmp_path / "agents" / "codex"
profile = _parse_toml((home / "unsloth_api.config.toml").read_text())
assert profile["model"] == MODEL["id"]
def test_resolve_model_matches_loaded_canonical_case_after_load(monkeypatch):
calls = []
state = {"loaded": False}
def http_json(
method,
url,
token,
payload = None,
timeout = 30,
error = None,
):
calls.append((method, url, payload))
if url.endswith("/v1/models"):
return {
"data": [
{
"id": "unsloth/gemma-4-E2B-it-GGUF" if state["loaded"] else "other/model",
"context_length": 131072,
}
]
}
if url.endswith("/api/inference/load"):
state["loaded"] = True
return {"model": "unsloth/gemma-4-E2B-it-GGUF"}
raise AssertionError(f"unexpected request: {method} {url}")
monkeypatch.setattr(start, "_http_json", http_json)
entry = start._resolve_model(
BASE,
"sk-test",
"unsloth/gemma-4-e2b-it-gguf",
start.LoadOptions(gguf_variant = "UD-Q4_K_XL"),
)
assert entry["id"] == "unsloth/gemma-4-E2B-it-GGUF"
assert any(c[1].endswith("/api/inference/load") for c in calls)
def test_resolve_model_loads_when_catalog_hit_is_not_loaded(monkeypatch):
# A cached-but-unloaded catalog entry (loaded == False) that only case-differs must
# not be treated as ready; the load endpoint must still be called so the requested
# model becomes resident instead of the agent preflighting a different backend.
calls = []
state = {"loaded": False}
def http_json(
method,
url,
token,
payload = None,
timeout = 30,
error = None,
):
calls.append((method, url))
if url.endswith("/v1/models"):
return {
"data": [
{
"id": "unsloth/Gemma-4-GGUF",
"loaded": state["loaded"],
"context_length": 131072,
}
]
}
if url.endswith("/api/inference/load"):
state["loaded"] = True
return {"model": "unsloth/Gemma-4-GGUF"}
raise AssertionError(f"unexpected request: {method} {url}")
monkeypatch.setattr(start, "_http_json", http_json)
entry = start._resolve_model(BASE, "sk-test", "unsloth/gemma-4-gguf")
assert entry["id"] == "unsloth/Gemma-4-GGUF"
assert any(u.endswith("/api/inference/load") for _, u in calls)
def test_resolve_model_attaches_to_loaded_catalog_hit_without_reload(monkeypatch):
# The mirror case: a loaded entry (loaded == True) that case-matches attaches with
# no /api/inference/load call.
calls = []
def http_json(
method,
url,
token,
payload = None,
timeout = 30,
error = None,
):
calls.append((method, url))
if url.endswith("/v1/models"):
return {
"data": [{"id": "unsloth/Gemma-4-GGUF", "loaded": True, "context_length": 131072}]
}
raise AssertionError(f"unexpected request: {method} {url}")
monkeypatch.setattr(start, "_http_json", http_json)
entry = start._resolve_model(BASE, "sk-test", "unsloth/gemma-4-gguf")
assert entry["id"] == "unsloth/Gemma-4-GGUF"
assert not any(u.endswith("/api/inference/load") for _, u in calls)
def test_resolve_model_remote_studio_does_not_casefold_attach(monkeypatch):
# Against a remote Studio the local existence probe cannot see server-side paths,
# so a case-variant loaded id must NOT attach without a load: it could be a distinct
# server-side path on a case-sensitive host. The load endpoint resolves the request.
calls = []
state = {"loaded": False}
def http_json(
method,
url,
token,
payload = None,
timeout = 30,
error = None,
):
calls.append((method, url))
if url.endswith("/v1/models"):
return {
"data": [{"id": "unsloth/Gemma-4-GGUF", "loaded": True, "context_length": 131072}]
}
if url.endswith("/api/inference/load"):
state["loaded"] = True
return {"model": "unsloth/Gemma-4-GGUF"}
raise AssertionError(f"unexpected request: {method} {url}")
monkeypatch.setattr(start, "_http_json", http_json)
entry = start._resolve_model("http://10.0.0.5:8888", "sk-test", "unsloth/gemma-4-gguf")
# The load endpoint was consulted (no casefold shortcut), and we still attach to the
# server's canonical id it reports back.
assert entry["id"] == "unsloth/Gemma-4-GGUF"
assert any(u.endswith("/api/inference/load") for _, u in calls)
def test_model_id_matching_does_not_casefold_local_paths(tmp_path):
existing_local = tmp_path / "Org" / "Foo"
existing_local.mkdir(parents = True)
assert start._model_id_matches("Org/Foo", "org/foo")
assert not start._model_id_matches(str(existing_local), str(existing_local).lower())
assert not start._model_id_matches("./Models/Foo", "./models/foo")
assert not start._model_id_matches(r".\Models\Foo", r".\models\foo")
# A server-side relative path (extra path segments) is not a hub id even when it
# does not exist on the CLI host, so it must not casefold-match a differently
# cased path on a case-sensitive server filesystem.
assert not start._is_hub_model_id("models/Llama/Foo.gguf")
assert not start._model_id_matches("models/Llama/Foo.gguf", "models/llama/foo.gguf")
# A genuine two-segment hub id still matches case-insensitively.
assert start._is_hub_model_id("unsloth/Gemma-3-4b-it-GGUF")
assert start._model_id_matches("unsloth/Gemma-3-4b-it-GGUF", "unsloth/gemma-3-4b-it-gguf")
# Casefolding is gated to loopback studios (allow_casefold). With it disabled (a
# remote studio, where a two-segment string could be a server-side path), even a
# genuine hub-id case variant must not match, so the load endpoint resolves it.
assert not start._model_id_matches(
"unsloth/Gemma-3-4b-it-GGUF", "unsloth/gemma-3-4b-it-gguf", allow_casefold = False
)
assert start._model_id_matches("unsloth/Foo", "unsloth/Foo", allow_casefold = False)
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
inline = _opencode_inline_config(result.output)
assert inline["model"] == f"{start._OPENCODE_PROVIDER}/{MODEL['id']}"
assert inline["permission"] == {
"edit": "allow",
"bash": "allow",
"webfetch": "allow",
"external_directory": {"*": "allow"},
}
assert "sk-unsloth" not in result.output # key stays in the private file, not the env
def test_opencode_inline_config_omits_permission_without_yolo(fake_studio):
# A non-yolo session carries no permission inline. OPENCODE_CONFIG_CONTENT outranks the
# project opencode.json we cannot read, so forcing any value there would override the
# user's project rules; clearing our own config is the fix, and the inline pins the model.
result = CliRunner().invoke(start.start_app, ["opencode", "--no-launch"])
assert result.exit_code == 0, result.output
inline = _opencode_inline_config(result.output)
assert inline["model"] == f"{start._OPENCODE_PROVIDER}/{MODEL['id']}"
assert "permission" not in inline
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(