diff --git a/unsloth_cli/commands/start.py b/unsloth_cli/commands/start.py index 1895125b11..d959d20c83 100644 --- a/unsloth_cli/commands/start.py +++ b/unsloth_cli/commands/start.py @@ -50,6 +50,12 @@ _HERMES_PROVIDER = "unsloth" # windows and scales the compaction threshold back down to the real window. _HERMES_MIN_CONTEXT = 65536 _PI_PROVIDER = "unsloth" +# OpenCode selects a model by "/" and honors a user +# disabled_providers list. Register the session provider under a dedicated id a +# user's disable list would never target, so the model is always selectable +# without the wrapper having to reconstruct (and override) OpenCode's full, +# multi-layer disabled_providers resolution. +_OPENCODE_PROVIDER = "unsloth-studio" _PROVIDER_HEADER = f"[model_providers.{_CODEX_PROFILE}]" _PASSTHROUGH = {"allow_extra_args": True, "ignore_unknown_options": True} _CLAUDE_ENV_UNSET = ("ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN") @@ -1116,13 +1122,17 @@ def write_opencode_config( config = _read_json_object(path) if config is None: typer.echo( - f"Warning: couldn't parse {path} — add an 'unsloth' provider there " - "yourself, or move the file aside and re-run.", + f"Warning: couldn't parse {path} — add an '{_OPENCODE_PROVIDER}' provider " + "there yourself, or move the file aside and re-run.", err = True, ) return {} before = json.dumps(config, sort_keys = True) config.setdefault("$schema", "https://opencode.ai/config.json") + # The session provider is registered under a dedicated id (_OPENCODE_PROVIDER) + # that a user's disabled_providers list would never target, so it is always + # selectable without this overlay having to reconstruct or override OpenCode's + # disabled_providers resolution. model_entry = {"name": model["id"]} window = model.get("context_length") or model.get("max_context_length") if window: @@ -1131,14 +1141,14 @@ def write_opencode_config( # disables OpenCode's auto-compaction; declare the real window (and a sane # output cap) so it compacts instead of overflowing the server. model_entry["limit"] = {"context": window, "output": min(window // 4, 8192)} - _subdict(config, "provider")["unsloth"] = { + _subdict(config, "provider")[_OPENCODE_PROVIDER] = { "npm": "@ai-sdk/openai-compatible", "name": "Unsloth Studio", "options": {"baseURL": f"{base}/v1", "apiKey": key}, "models": {model["id"]: model_entry}, } # OpenCode selects a model by "/". - config["model"] = f"unsloth/{model['id']}" + config["model"] = f"{_OPENCODE_PROVIDER}/{model['id']}" if window: # Compact with ~10% headroom (near 90% full). The fixed 20k-token default # buffer over-compacts, or never settles, on a small local context. @@ -1450,7 +1460,20 @@ def opencode( serve = serve, launch = launch, ) - command = ["opencode", *ctx.args] + opencode_model = f"{_OPENCODE_PROVIDER}/{entry['id']}" + # The inline OPENCODE_CONFIG_CONTENT below pins the model in the highest-priority + # layer, so the session model is forced without a --model flag. Only add --model for + # an interactive bare launch (a convenience so the TUI opens on our model). It is + # omitted for passthrough (inserting it before a subcommand can be misparsed) and for + # --no-launch, where the printed command is consumed by drivers that append a + # subcommand such as `run `; a leading --model would land before that + # subcommand and break it. Those paths rely on the inline pin instead. + if ctx.args: + command = ["opencode", *ctx.args] + elif launch: + command = ["opencode", "--model", opencode_model] + else: + command = ["opencode"] with _session_config("opencode", launch) as cfg: config_path = cfg / "opencode.json" # OPENCODE_CONFIG is an overlay (loaded between the user's global and project @@ -1462,7 +1485,26 @@ def opencode( # outranks project config; the API key stays in the private file, never the env. # Only --yolo carries a permission here (its allow must win over a project config); # a non-yolo session returns no permission, so the project's own rules are honored. - inline_config: dict = {"model": f"unsloth/{entry['id']}"} + # opencode filters every provider (a config-defined custom one included) through + # its enabled_providers allowlist and disabled_providers denylist, and a model pin + # does not bypass that gate -- a filtered provider resolves to ModelNotFoundError. + # To guarantee the session model loads without reading or modifying the user's real + # config, scope THIS session to our provider alone: allowlist _OPENCODE_PROVIDER and + # clear the denylist. These arrays are replaced (not merged) by higher layers, so + # setting them in the highest-priority inline overlay neutralizes any user allowlist + # or denylist for the launch. It is session-only: it lives in OPENCODE_CONFIG_CONTENT + # for this invocation and never touches the user's config files, so their normal + # `opencode` is unchanged; only this session is limited to the Studio provider. + # small_model is opencode's separate model for lightweight tasks; pin it to the + # session model too, or a user/project small_model on another (now filtered) + # provider would resolve a not-found error mid-session. The session serves one + # model, so the session model is the only valid target here anyway. + inline_config: dict = { + "model": opencode_model, + "small_model": opencode_model, + "enabled_providers": [_OPENCODE_PROVIDER], + "disabled_providers": [], + } if session_permission: inline_config["permission"] = session_permission env = { diff --git a/unsloth_cli/tests/test_start.py b/unsloth_cli/tests/test_start.py index affc24626e..58888010b3 100644 --- a/unsloth_cli/tests/test_start.py +++ b/unsloth_cli/tests/test_start.py @@ -435,15 +435,10 @@ def test_opencode_inline_config_beats_project_config(fake_studio): # 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']}" + inline = _opencode_inline_config(result.output) + assert inline["model"] == f"{start._OPENCODE_PROVIDER}/{MODEL['id']}" assert inline["permission"] == {"edit": "allow", "bash": "allow", "webfetch": "allow"} - assert "sk-unsloth" not in content_line # key stays in the private file + 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): @@ -452,13 +447,8 @@ def test_opencode_inline_config_omits_permission_without_yolo(fake_studio): # 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 - 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']}" + inline = _opencode_inline_config(result.output) + assert inline["model"] == f"{start._OPENCODE_PROVIDER}/{MODEL['id']}" assert "permission" not in inline @@ -1376,14 +1366,17 @@ 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"] + provider = config["provider"][start._OPENCODE_PROVIDER] 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']}" + assert config["model"] == f"{start._OPENCODE_PROVIDER}/{MODEL['id']}" + # The overlay never writes disabled_providers; the dedicated provider id is one a + # user's disable list would not target, so nothing needs re-enabling. + assert "disabled_providers" not in config # Compaction buffer scaled to ~10% of the window (compact near 90%). assert config["compaction"] == {"auto": True, "reserved": 131072 // 10} @@ -1391,18 +1384,98 @@ def test_write_opencode_config_fresh(tmp_path): 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"}}}) + json.dumps( + { + "theme": "tokyonight", + "disabled_providers": ["ollama", "unsloth"], + "provider": {"anthropic": {"name": "Anthropic"}}, + } + ) ) start.write_opencode_config(BASE, "sk-unsloth-abc", MODEL, path) config = json.loads(path.read_text()) assert config["theme"] == "tokyonight" + # The overlay no longer edits disabled_providers; re-enabling unsloth is done in + # the inline layer, so an existing list here is preserved untouched. + assert config["disabled_providers"] == ["ollama", "unsloth"] assert config["provider"]["anthropic"]["name"] == "Anthropic" - assert config["provider"]["unsloth"]["options"]["baseURL"] == f"{BASE}/v1" + assert config["provider"][start._OPENCODE_PROVIDER]["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_write_opencode_config_keeps_foreign_disabled_providers(tmp_path): + # A user who disabled other providers (but not unsloth) must keep them disabled: + # the overlay must not rewrite disabled_providers, or those providers get silently + # re-enabled for the session. + path = tmp_path / "opencode.json" + path.write_text(json.dumps({"disabled_providers": ["openai", "gemini"]})) + start.write_opencode_config(BASE, "sk-unsloth-abc", MODEL, path) + config = json.loads(path.read_text()) + assert config["disabled_providers"] == ["openai", "gemini"] + + +def _opencode_inline_config(output: str) -> dict: + # --no-launch prints OPENCODE_CONFIG_CONTENT as a POSIX `export NAME=` + # line on Unix/WSL and a PowerShell `$env:NAME = ""` line on native Windows; + # parse whichever the host emitted so the opencode tests are shell-agnostic. + name = "OPENCODE_CONFIG_CONTENT" + for raw in output.splitlines(): + line = raw.strip() + if line.startswith(f"export {name}="): + return json.loads(shlex.split(line.removeprefix(f"export {name}="))[0]) + prefix = f'$env:{name} = "' + if line.startswith(prefix) and line.endswith('"'): + escaped = line[len(prefix) : -1] + # Reverse _print_env's PowerShell escaping (backtick is the escape char). + value = escaped.replace("`$", "$").replace('`"', '"').replace("``", "`") + return json.loads(value) + raise AssertionError(f"{name} not found in:\n{output}") + + +def test_opencode_inline_scopes_session_to_studio_provider(fake_studio): + # opencode filters even config-defined providers through enabled/disabled_providers, + # and a model pin does not bypass that gate. The inline overlay (session-only, highest + # layer, arrays replace) allowlists our provider and clears the denylist so the Studio + # model always loads regardless of the user's config, without reading or editing it. + result = CliRunner().invoke(start.start_app, ["opencode", "--no-launch"]) + assert result.exit_code == 0, result.output + inline = _opencode_inline_config(result.output) + assert inline["enabled_providers"] == [start._OPENCODE_PROVIDER] + assert inline["disabled_providers"] == [] + assert inline["model"] == f"{start._OPENCODE_PROVIDER}/{MODEL['id']}" + # small_model stays on the enabled provider too, so lightweight tasks do not resolve a + # filtered provider mid-session. + assert inline["small_model"] == f"{start._OPENCODE_PROVIDER}/{MODEL['id']}" + + +def test_opencode_passthrough_flags_omit_model_flag(fake_studio): + # Any passthrough (top-level flags that may precede a subcommand, or a subcommand) + # is left untouched; --model is not injected. The model is pinned by the inline + # OPENCODE_CONFIG_CONTENT (highest layer) instead, so it is still forced. + result = CliRunner().invoke(start.start_app, ["opencode", "--no-launch", "--dir", "repo"]) + assert result.exit_code == 0, result.output + command = _launch_command(result.output) + assert command == ["opencode", "--dir", "repo"] + assert "--model" not in command + assert ( + _opencode_inline_config(result.output)["model"] + == f"{start._OPENCODE_PROVIDER}/{MODEL['id']}" + ) + + +def test_opencode_passthrough_subcommand_omits_model_flag(fake_studio): + # A passthrough subcommand (e.g. `serve`) takes the model from the pinned config; + # inserting --model before it would break opencode's arg parsing. + result = CliRunner().invoke(start.start_app, ["opencode", "--no-launch", "serve"]) + assert result.exit_code == 0, result.output + command = _launch_command(result.output) + assert command[0] == "opencode" + assert command[1] == "serve" + assert "--model" not in command + + 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 @@ -1410,9 +1483,24 @@ def test_connect_opencode_no_launch(fake_studio, tmp_path): 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)) + inline_config = _opencode_inline_config(result.output) config = json.loads(config_path.read_text()) - assert config["provider"]["unsloth"]["options"]["apiKey"] == "sk-unsloth-feedfacefeedface" - assert config["model"] == f"unsloth/{MODEL['id']}" + provider = config["provider"][start._OPENCODE_PROVIDER] + assert provider["options"]["apiKey"] == "sk-unsloth-feedfacefeedface" + assert config["model"] == f"{start._OPENCODE_PROVIDER}/{MODEL['id']}" + # The session config file (a throwaway overlay, not the user's real config) does not + # carry provider filters; the session scoping rides in the inline env layer only. + assert "disabled_providers" not in config + assert "enabled_providers" not in config + assert inline_config == { + "model": f"{start._OPENCODE_PROVIDER}/{MODEL['id']}", + "small_model": f"{start._OPENCODE_PROVIDER}/{MODEL['id']}", + "enabled_providers": [start._OPENCODE_PROVIDER], + "disabled_providers": [], + } + # --no-launch prints an append-safe base command (no --model before a subcommand a + # driver may append); the model is forced by the inline pin above. + assert _launch_command(result.output) == ["opencode"] assert not any(c[1].endswith("/api/inference/status") for c in fake_studio) @@ -1765,7 +1853,7 @@ def test_no_launch_rerun_clears_stale_opencode_yolo_permissions(fake_studio, tmp # revert to OpenCode's permissive "allow" default). assert config["permission"] == {"edit": "ask", "bash": "ask", "webfetch": "ask"} # The session provider survives the cleanup. - assert "unsloth" in config["provider"] + assert start._OPENCODE_PROVIDER in config["provider"] def test_no_launch_rerun_clears_stale_openclaw_yolo_state(fake_studio, tmp_path):