diff --git a/README.md b/README.md index 0b923e940..e4c242c89 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,7 @@ shows an **Output Tokens Saved** card next to input compression, labelled | Copilot CLI | ✅ | starts proxy + launches | | VS Code Copilot | ✅ | transparent proxy; preserves selected model | | OpenClaw | ✅ | installs as ContextEngine plugin | -| OpenCode | ✅ | injects config · starts proxy + launches | +| OpenCode | ✅ | injects config · starts proxy + launches [^third-party-upstream] | | Cline | ✅ | starts proxy + injects config | | Continue | ✅ | starts proxy + injects config | | Goose | ✅ | starts proxy + launches | @@ -246,6 +246,8 @@ shows an **Output Tokens Saved** card next to input compression, labelled | Kimi CLI | ✅ | OAuth bearer forwarded — log in once | | ZCode | ✅ | starts proxy and prints base URLs for ZCode settings | +[^third-party-upstream]: `headroom wrap` routes OpenAI-compatible traffic to the default upstream (`https://api.openai.com/v1`). For a third-party OpenAI-compatible provider (DeepSeek, Together, OpenRouter, a self-hosted gateway), pass `--openai-api-url https://api.deepseek.com/v1` — or set `OPENAI_TARGET_API_URL` before wrapping — otherwise your provider key is sent to OpenAI and rejected with `401 Incorrect API key provided`. See [OpenCode + DeepSeek](docs/content/docs/opencode-deepseek.mdx). + Any OpenAI-compatible client works via `headroom proxy`. MCP-native: `headroom mcp install`. Undo durable wrapping with `headroom unwrap ` (supports: `claude`, `copilot`, `codex`, `grok`, `kimi`, `omp`, `opencode`, `openclaw`, `zcode`). Registry authors can use the canonical [`server.json`](server.json) in the repo root instead of reconstructing the `headroom mcp serve` contract from prose. diff --git a/docs/content/docs/opencode-deepseek.mdx b/docs/content/docs/opencode-deepseek.mdx index a9a3594af..6a8c77860 100644 --- a/docs/content/docs/opencode-deepseek.mdx +++ b/docs/content/docs/opencode-deepseek.mdx @@ -193,9 +193,52 @@ 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`. +You ran `headroom wrap opencode` without telling it about DeepSeek. Wrap +rewrites the `headroom` provider block in your OpenCode config on every run, so +the manual `apiKey` / `models` entries from step 4 are replaced. Either launch +OpenCode directly with `opencode` after configuring it manually as shown above, +or use wrap with an explicit upstream — see the next section. + +### I used `headroom wrap opencode` and get "Incorrect API key provided" + +``` +Incorrect API key provided: sk-13005***********************898d. +You can find your API key at https://platform.openai.com/account/api-keys. +``` + +That 401 comes from **OpenAI**, not DeepSeek. `headroom wrap opencode` starts +the proxy for you, and unless it is told otherwise the proxy forwards +OpenAI-compatible traffic to its default upstream, `https://api.openai.com/v1`. +Your DeepSeek key is then presented to OpenAI, which rejects it and points you +at a dashboard for a key you never had. + +Pass the upstream to wrap, exactly like `headroom proxy --openai-api-url`: + +```bash +headroom wrap opencode --openai-api-url https://api.deepseek.com/v1 +``` + +The equivalent environment variable also works, and is honored by both +commands: + +```bash +OPENAI_TARGET_API_URL=https://api.deepseek.com/v1 headroom wrap opencode +``` + +If a proxy is already running on the port with a different upstream, stop it +first (`headroom unwrap opencode`) — wrap will not silently re-point a proxy +that other clients may be sharing. + +### When is `headroom wrap opencode` safe? + +- **Safe as-is** when your traffic goes to the default upstreams — OpenAI + (`api.openai.com`) or Anthropic (`api.anthropic.com`). +- **Needs `--openai-api-url`** (or `OPENAI_TARGET_API_URL`) for any third-party + OpenAI-compatible provider: DeepSeek, Together, OpenRouter, Groq, a + self-hosted gateway, and so on. +- **Use the manual setup in this guide** when you want to pin specific model + IDs, context limits, or display names in `opencode.json`, since wrap + regenerates the `headroom` provider block each time it runs. ### "headroom" command not found @@ -223,7 +266,11 @@ 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 +- **`headroom wrap` with a custom model list** — wrap regenerates the + `headroom` provider block on every run, so the model entries below are lost. + If you prefer wrap, run + `headroom wrap opencode --openai-api-url https://api.deepseek.com/v1` and skip + the manual config - **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 diff --git a/docs/content/docs/opencode.mdx b/docs/content/docs/opencode.mdx index 6c71d27ff..ca698d377 100644 --- a/docs/content/docs/opencode.mdx +++ b/docs/content/docs/opencode.mdx @@ -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 \ @@ -49,6 +50,14 @@ headroom wrap opencode \ -- ``` +Pass `--openai-api-url` when OpenCode should route through a third-party +OpenAI-compatible provider instead of OpenAI itself — DeepSeek, Together, +OpenRouter, or a self-hosted gateway. Without it, OpenAI-compatible traffic is +forwarded to the default upstream (`https://api.openai.com/v1`), so a +non-OpenAI key is rejected with `401 Incorrect API key provided`. Setting +`OPENAI_TARGET_API_URL` before wrapping has the same effect. See +[OpenCode + DeepSeek](/docs/opencode-deepseek) for a full walkthrough. + ## Provider Model Mapping The generated `headroom` provider exposes these models through the proxy: @@ -69,6 +78,7 @@ The default model is `headroom/claude-sonnet-4-6`. Change it in `opencode.json` |---|---| | `OPENCODE_CONFIG_CONTENT` | JSON payload with provider, plugin, and optional local MCP config injected by `wrap` | | `HEADROOM_PROXY_URL` | Proxy URL passed to Headroom MCP when a non-default port is used, and to the native plugin when configured | +| `OPENAI_TARGET_API_URL` | Upstream for OpenAI-compatible traffic when `--openai-api-url` is not passed; defaults to `https://api.openai.com/v1` | ## Failure Learning diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 966d150ac..06a5144ee 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -7655,6 +7655,16 @@ def openclaw( is_flag=True, help="Route headroom/* models through the authenticated GitHub Copilot subscription", ) +@click.option( + "--openai-api-url", + default=None, + envvar="OPENAI_TARGET_API_URL", + help=( + "Upstream base URL for OpenAI-compatible traffic, e.g. " + "https://api.deepseek.com/v1. Without it the proxy forwards to " + "https://api.openai.com/v1 (env: OPENAI_TARGET_API_URL)." + ), +) @click.option("--learn", is_flag=True, help="Enable live traffic learning") @click.option("--memory", is_flag=True, help="Enable persistent cross-session memory") @click.option( @@ -7672,6 +7682,7 @@ def opencode( code_graph: bool, no_proxy: bool, copilot_subscription: bool, + openai_api_url: str | None, learn: bool, memory: bool, backend: str | None, @@ -7697,9 +7708,25 @@ 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 + +  + Without --openai-api-url the proxy forwards OpenAI-compatible traffic to + https://api.openai.com/v1, so a third-party key (DeepSeek, Together, + OpenRouter, ...) is rejected upstream with OpenAI's 401 "Incorrect API key + provided". Point the proxy at the real upstream instead: + +  + headroom wrap opencode --openai-api-url https://api.deepseek.com/v1 + OPENAI_TARGET_API_URL=https://api.deepseek.com/v1 headroom wrap opencode """ subscription_resolution = None if copilot_subscription: + if openai_api_url: + raise click.ClickException( + "--openai-api-url cannot be combined with --copilot-subscription; " + "the subscription resolves its own upstream." + ) effective_backend = backend or os.environ.get("HEADROOM_BACKEND") if effective_backend not in (None, "", "anthropic"): raise click.ClickException( @@ -7793,7 +7820,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=( + subscription_resolution.api_url if subscription_resolution else openai_api_url + ), 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 diff --git a/tests/test_cli/test_wrap_opencode.py b/tests/test_cli/test_wrap_opencode.py index ee0bea009..2bdec77bf 100644 --- a/tests/test_cli/test_wrap_opencode.py +++ b/tests/test_cli/test_wrap_opencode.py @@ -1140,3 +1140,116 @@ def test_unwrap_opencode_preserves_utf8_user_content( assert "“smart quotes”" in content assert "—" in content assert wrap_mod._PROVIDER_MARKER_START not in content + + +def _capture_ensure_proxy_kwargs( + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + argv: list[str], +) -> dict[str, object]: + """Run `wrap opencode` with a stubbed proxy/launch and return _ensure_proxy kwargs.""" + monkeypatch.chdir(tmp_path) + _set_test_home(monkeypatch, tmp_path) + captured: dict[str, object] = {} + + def fake_ensure_proxy(port: int, no_proxy: bool, **kwargs): # noqa: ANN003 + captured.update(kwargs) + return None, port + + 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"), + ): + result = runner.invoke(main, argv) + + assert result.exit_code == 0, result.output + return captured + + +def test_wrap_opencode_forwards_openai_api_url_to_proxy( + runner: CliRunner, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """--openai-api-url points the proxy at a third-party OpenAI-compatible upstream (#3107).""" + monkeypatch.delenv("OPENAI_TARGET_API_URL", raising=False) + captured = _capture_ensure_proxy_kwargs( + runner, + monkeypatch, + tmp_path, + [ + "wrap", + "opencode", + "--port", + "9000", + "--no-mcp", + "--no-serena", + "--openai-api-url", + "https://api.deepseek.com/v1", + ], + ) + + assert captured["openai_api_url"] == "https://api.deepseek.com/v1" + + +def test_wrap_opencode_honors_openai_target_api_url_env( + runner: CliRunner, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """OPENAI_TARGET_API_URL is honored without the flag, matching `headroom proxy`.""" + monkeypatch.setenv("OPENAI_TARGET_API_URL", "https://api.deepseek.com/v1") + captured = _capture_ensure_proxy_kwargs( + runner, + monkeypatch, + tmp_path, + ["wrap", "opencode", "--port", "9000", "--no-mcp", "--no-serena"], + ) + + assert captured["openai_api_url"] == "https://api.deepseek.com/v1" + + +def test_wrap_opencode_without_openai_api_url_leaves_upstream_unset( + runner: CliRunner, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No override means the proxy keeps its own default upstream resolution.""" + monkeypatch.delenv("OPENAI_TARGET_API_URL", raising=False) + captured = _capture_ensure_proxy_kwargs( + runner, + monkeypatch, + tmp_path, + ["wrap", "opencode", "--port", "9000", "--no-mcp", "--no-serena"], + ) + + assert captured["openai_api_url"] is None + + +def test_wrap_opencode_rejects_openai_api_url_with_copilot_subscription( + runner: CliRunner, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The Copilot subscription resolves its own upstream; a manual override would fight it.""" + monkeypatch.chdir(tmp_path) + _set_test_home(monkeypatch, tmp_path) + _clear_copilot_route_config(monkeypatch) + monkeypatch.delenv("OPENAI_TARGET_API_URL", raising=False) + + 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", + ], + ) + + assert result.exit_code != 0 + assert "cannot be combined with --copilot-subscription" in result.output