Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions docs/content/docs/opencode-deepseek.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,15 @@ API key to the proxy, and the proxy forwards it to DeepSeek. Make sure

### Models appear but requests fail

You ran `headroom wrap opencode`. That command replaces your config with Claude
and GPT models. **Do not use `headroom wrap`.** Configure OpenCode manually as
shown above, and launch OpenCode directly with `opencode`.
If you ran `headroom wrap opencode` without `--openai-api-url`, OpenAI-compatible
traffic is forwarded to OpenAI's own API by default, so your DeepSeek key is
rejected with an HTTP 401 error. Two fixes:

- Add the flag so the proxy is started with the right upstream:
`headroom wrap opencode --openai-api-url https://api.deepseek.com/v1`
- Or fall back to the manual setup shown above (`headroom proxy --openai-api-url
https://api.deepseek.com/v1` plus the `opencode.json` edits), and launch
OpenCode directly with `opencode`.

### "headroom" command not found

Expand Down Expand Up @@ -223,7 +229,10 @@ shaper is active immediately — the numbers just need calibration.
## What's NOT in this guide

- **Claude or GPT models** — this setup uses DeepSeek exclusively
- **`headroom wrap`** — do not use it; it overrides the config
- **Every moving part of the manual setup** — this guide documents the manual
path deliberately, step by step, so you can see exactly what gets configured.
`headroom wrap opencode --openai-api-url https://api.deepseek.com/v1` is now
also a supported shortcut that automates the same config-injection steps.
- **Deprecated model names** — `deepseek-chat` and `deepseek-reasoner` are
compatibility aliases that will be deprecated on 2026-07-24; use
`deepseek-v4-pro` and `deepseek-v4-flash` instead
Expand Down
8 changes: 8 additions & 0 deletions docs/content/docs/opencode.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ headroom wrap opencode \
--no-serena \
--code-graph \
--no-proxy \
--openai-api-url https://api.deepseek.com/v1 \
--learn \
--memory \
--backend anthropic \
Expand All @@ -49,6 +50,13 @@ headroom wrap opencode \
-- <opencode args>
```

Pass `--openai-api-url` when OpenCode should route through a third-party
OpenAI-compatible provider instead of OpenAI itself — for example DeepSeek's
`https://api.deepseek.com/v1`. Without it, OpenAI-compatible traffic is
forwarded to OpenAI's own API by default, and a non-OpenAI API key is
rejected with an HTTP 401 error. See
[OpenCode + DeepSeek](/docs/opencode-deepseek) for a full walkthrough.

## Provider Model Mapping

The generated `headroom` provider exposes these models through the proxy:
Expand Down
28 changes: 27 additions & 1 deletion headroom/cli/wrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -7214,6 +7214,16 @@ def openclaw(
help="Enable code graph indexing via codebase-memory-mcp (optional)",
)
@click.option("--no-proxy", is_flag=True, help="Skip proxy startup (use existing proxy)")
@click.option(
"--openai-api-url",
metavar="URL",
help=(
"Base URL of the actual OpenAI-compatible provider you are using, for example "
"DeepSeek's https://api.deepseek.com/v1. Set this when OpenCode is configured for "
"a provider other than OpenAI; without it, requests are forwarded to OpenAI's own "
"API by default and a non-OpenAI API key is rejected with an HTTP 401 error."
),
)
@click.option(
"--copilot-subscription",
is_flag=True,
Expand All @@ -7235,6 +7245,7 @@ def opencode(
no_serena: bool,
code_graph: bool,
no_proxy: bool,
openai_api_url: str | None,
copilot_subscription: bool,
learn: bool,
memory: bool,
Expand All @@ -7252,6 +7263,13 @@ def opencode(
Headroom. Configures a headroom provider via @ai-sdk/openai-compatible.
Also sets OPENAI_BASE_URL and ANTHROPIC_BASE_URL as fallbacks.

\b
By default, OpenAI-compatible traffic is forwarded to OpenAI's own API.
If OpenCode is configured to use a different OpenAI-compatible provider
(for example DeepSeek), pass that provider's base URL via
--openai-api-url so requests and your existing API key reach the right
upstream instead of being rejected by OpenAI with an HTTP 401 error.

\b
Examples:
headroom wrap opencode # Start proxy + opencode
Expand All @@ -7261,6 +7279,7 @@ def opencode(
headroom wrap opencode --port 9999 # Custom proxy port
headroom wrap opencode --backend anyllm --anyllm-provider groq
headroom wrap opencode --copilot-subscription # Use a GitHub Copilot subscription
headroom wrap opencode --openai-api-url https://api.deepseek.com/v1
"""
subscription_resolution = None
if copilot_subscription:
Expand All @@ -7280,6 +7299,11 @@ def opencode(
"--copilot-subscription cannot be combined with --prepare-only because "
"it requires a running private seeded proxy."
)
if openai_api_url:
raise click.ClickException(
"--copilot-subscription cannot be combined with --openai-api-url because "
"the Copilot subscription flow already selects its own upstream API URL."
)
subscription_resolution = _require_copilot_subscription_resolution()

# Verify the opencode binary exists BEFORE mutating any config. Otherwise a
Expand Down Expand Up @@ -7357,7 +7381,9 @@ def opencode(
backend=backend,
anyllm_provider=anyllm_provider,
region=region,
openai_api_url=(subscription_resolution.api_url if subscription_resolution else None),
openai_api_url=(
openai_api_url or (subscription_resolution.api_url if subscription_resolution else None)
),
copilot_api_token=(subscription_resolution.token if subscription_resolution else None),
copilot_refresh_oauth_token=(
subscription_resolution.refresh_oauth_token if subscription_resolution else None
Expand Down
108 changes: 108 additions & 0 deletions tests/test_cli/test_wrap_opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -1055,6 +1055,114 @@ def test_wrap_opencode_with_no_proxy(
assert result.exit_code == 0, result.output


def test_wrap_opencode_with_openai_api_url(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""--openai-api-url is forwarded to _ensure_proxy so third-party OpenAI-compatible
providers (e.g. DeepSeek) are reached instead of the default OpenAI upstream."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
captured: dict[str, object] = {}

def fake_ensure_proxy(*args, **kwargs): # noqa: ANN002, ANN003
captured.update(kwargs)
return None, 9000

with (
patch.object(wrap_mod.shutil, "which", return_value="opencode"),
patch.object(wrap_mod, "_ensure_proxy", side_effect=fake_ensure_proxy),
patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)),
):
result = runner.invoke(
main,
[
"wrap",
"opencode",
"--port",
"9000",
"--openai-api-url",
"https://api.deepseek.com/v1",
"--no-mcp",
],
)

assert result.exit_code == 0, result.output
assert captured["openai_api_url"] == "https://api.deepseek.com/v1"


def test_wrap_opencode_without_openai_api_url_defaults_to_none(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Omitting --openai-api-url leaves the upstream selection to _ensure_proxy's
default (OpenAI), matching behavior before this flag existed."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
_set_test_home(monkeypatch, tmp_path)
captured: dict[str, object] = {}

def fake_ensure_proxy(*args, **kwargs): # noqa: ANN002, ANN003
captured.update(kwargs)
return None, 9000

with (
patch.object(wrap_mod.shutil, "which", return_value="opencode"),
patch.object(wrap_mod, "_ensure_proxy", side_effect=fake_ensure_proxy),
patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)),
):
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])

assert result.exit_code == 0, result.output
assert captured["openai_api_url"] is None


def test_wrap_opencode_help_lists_openai_api_url(runner: CliRunner) -> None:
"""--openai-api-url is scoped to `wrap opencode`, not other wrap subcommands."""
opencode_help = runner.invoke(main, ["wrap", "opencode", "--help"])
codex_help = runner.invoke(main, ["wrap", "codex", "--help"])

assert opencode_help.exit_code == 0, opencode_help.output
assert "--openai-api-url URL" in opencode_help.output
assert codex_help.exit_code == 0, codex_help.output
assert "--openai-api-url" not in codex_help.output


def test_wrap_opencode_openai_api_url_conflicts_with_copilot_subscription(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""--openai-api-url and --copilot-subscription pick different, incompatible
upstream-selection strategies, so combining them must fail fast."""
monkeypatch.chdir(tmp_path)
_set_test_home(monkeypatch, tmp_path)
_clear_copilot_route_config(monkeypatch)
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
config_file.parent.mkdir(parents=True)
config_file.write_text("{}", encoding="utf-8")

with patch.object(wrap_mod, "_ensure_proxy", side_effect=AssertionError("proxy launched")):
result = runner.invoke(
main,
[
"wrap",
"opencode",
"--copilot-subscription",
"--openai-api-url",
"https://api.deepseek.com/v1",
"--no-mcp",
],
)

assert result.exit_code == 1
assert "--openai-api-url" in result.output
assert not config_file.with_name("opencode.json.headroom-backup").exists()


def test_wrap_opencode_with_verbose_flag(
runner: CliRunner,
tmp_path: Path,
Expand Down
Loading