From 393d7e9c2b48b928126008399b0c9443af8a9ec7 Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:22:36 +0100 Subject: [PATCH] Fix opencode Unsloth provider selection (#6906) * fix: force Unsloth provider selection for opencode * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * opencode: pin the model without clobbering the user's disabled providers The session overlay wrote disabled_providers unconditionally and the inline OPENCODE_CONFIG_CONTENT set disabled_providers to an empty list. Since that inline layer outranks the user's global and project config and opencode replaces the array rather than merging it, every provider the user had disabled was silently re-enabled for the session. Only strip 'unsloth' from an existing disable list, and drop disabled_providers from the inline config. Also insert --model only on a bare launch: it is a global flag for the TUI, so placing it before a passthrough subcommand (serve/run) breaks arg parsing; a subcommand takes the model from the pinned config instead. Parse the printed OPENCODE_CONFIG_CONTENT with shlex.split in the test so it round-trips under POSIX shell quoting. * Re-enable a globally disabled opencode unsloth provider for the session A fresh OPENCODE_CONFIG overlay omits disabled_providers, and opencode replaces that array across config layers only when a higher layer sets the key, so a user's global disabled_providers of ['unsloth', ...] survived the merge and left the session provider disabled even though the overlay defines provider.unsloth and pins the model. Consult the user's global opencode config (XDG_CONFIG_HOME/opencode, or %APPDATA%/opencode on Windows) when the overlay has no list of its own, and when the effective list disables unsloth write it back to the overlay minus unsloth. The provider loads while the user's other disabled providers stay disabled. Best-effort read: a missing or unparseable global config is a no-op. * Override opencode disabled_providers in the inline layer; keep model flag for TUI flags Re-enabling a disabled unsloth provider now rides in the inline OPENCODE_CONFIG_CONTENT layer instead of the session overlay. The overlay sits below a project opencode.json, which could re-disable the provider; the inline layer outranks both global and project configs and is recomputed each run, so no-launch reruns never reuse a stale generated list. The effective disabled list is read from the project config if the repo sets one, else the global config, across config.json/opencode.json/opencode.jsonc (JSONC tolerated), and written back minus unsloth only when unsloth is disabled. Also keep the pinned --model when the opencode passthrough starts with a top-level TUI flag such as --dir or --continue; only a real subcommand (serve/run/...) takes the model from config, so a leading '-' now still gets --model injected. * Discover the opencode project config by walking up from the cwd opencode finds a project config by searching ancestor directories, not just the cwd. Walk from the cwd up to the filesystem root and use the nearest directory that sets disabled_providers, so running unsloth start opencode from a subdirectory of a repo whose root config disables unsloth still gets the inline override. * Only inject opencode --model on a bare launch; rely on the inline model pin Injecting --model whenever the passthrough started with a flag could place it before a subcommand (e.g. opencode --print-logs serve), which opencode can misparse. --model is unnecessary for any passthrough because the inline OPENCODE_CONFIG_CONTENT pins the model in the highest-priority layer, so the session model is forced without the flag. Restrict --model to the bare launch and pass any other invocation through untouched. * Register the session provider under a dedicated OpenCode id Selecting the Unsloth model reliably required the wrapper to re-enable a user-disabled unsloth provider, which meant reconstructing OpenCode's full disabled_providers resolution (global, OPENCODE_CONFIG overlay, project config discovered via --dir or an ancestor walk, .opencode directories, OPENCODE_CONFIG_DIR, config.json/opencode.json/opencode.jsonc precedence, and {env:} variable substitution) and overriding it in the inline layer. That is unbounded and cannot be kept correct. Register the session provider under a dedicated id (unsloth-studio) instead. A user's disabled_providers list would never target it, so the session model is always selectable and the overlay no longer reads or writes disabled_providers at all: the user's own disables, in whatever config layer, are left exactly as they are. This removes the JSONC parser, the config-directory scan, and the ancestor/global resolution helpers, and the tests that exercised them. * Scope the opencode session to the Studio provider opencode filters every provider, including a config-defined custom one, through its enabled_providers allowlist and disabled_providers denylist, and pinning the model does not bypass that gate (a filtered provider resolves to a not-found error). The provider arrays are also replaced, not merged, across config layers. So a user with an enabled_providers allowlist that omits the session provider would still have the Studio model filtered out. Set enabled_providers to just the session provider and clear disabled_providers in the inline OPENCODE_CONFIG_CONTENT overlay (the highest-priority layer, which replaces these arrays). This guarantees the Studio model loads regardless of the user's provider filters, without reading or reconstructing their multi-layer config. It is session-only: the overlay lives in the env for this launch and never touches the user's config files, so their normal opencode is unchanged and only this session is limited to the Studio provider. Also drop the redundant --model on --no-launch so the printed command stays append-safe for drivers that append a subcommand (the inline pin forces the model), and parse both POSIX and PowerShell no-launch output in the opencode tests so they are not shell-specific. * Pin opencode small_model to the session provider The session allowlists only the Studio provider, but opencode's separate small_model (used for lightweight tasks) could still point at another provider from the user or project config; under the allowlist that provider is filtered, so the lightweight task would resolve a not-found error mid-session even with the main model pinned. Pin small_model to the session model in the same inline overlay so every model use stays on the enabled provider. The session serves one model, so it is the only valid target, and this stays session-only like the rest of the overlay. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han Co-authored-by: Wasim Yousef Said --- unsloth_cli/commands/start.py | 54 +++++++++++-- unsloth_cli/tests/test_start.py | 132 ++++++++++++++++++++++++++------ 2 files changed, 158 insertions(+), 28 deletions(-) diff --git a/unsloth_cli/commands/start.py b/unsloth_cli/commands/start.py index 1895125b1..d959d20c8 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 affc24626..58888010b 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):