Verified with the installed binaries:
opencode(v1): 1.18.25opencode2(v2): v0.0.0-beta-18743
A reverse proxy mounted under a path prefix works with the v1 client but
fails with the v2 client. The v2 remote client resolves its root-relative
/api/... paths with new URL(path, serverUrl), and a leading / replaces the
whole path component of serverUrl — so the configured prefix is discarded.
new URL("/api/health", "http://127.0.0.1:3001/proxy").toString()
// => http://127.0.0.1:3001/api/health (the /proxy prefix is gone)
repro.sh starts a real v1 server and a real v2 server, then path-proxy.ts
(one bun process on :3000) mounts each under a path prefix, with a non-opencode
root like Forge:
opencode serve :4096 (v1, unsecured) proxy :3000/v1/* -> v1 server
opencode2 serve :4097 (v2, password) proxy :3000/v2/* -> v2 server
proxy :3000/... -> "running" (not opencode)
Each client is then pointed at its server directly (baseline) and through
the path proxy. Run ./repro.sh.
| client (→ its server) | direct | through path proxy |
|---|---|---|
opencode (v1) → v1 server |
✅ | ✅ …/v1/* (prefix kept) |
opencode2 (v2) → v2 server |
✅ | ❌ fails (prefix dropped) |
Both servers are reachable through the proxy (verified by curl:
/v1/global/health and /v2/api/health both return healthy). Both clients work
directly. Only the v2 client fails once its URL carries a path prefix.
$ OPENCODE_SERVER_PASSWORD=… opencode2 api --server http://127.0.0.1:4097 GET /api/health
{"healthy":true,"version":"0.0.0-beta-18743","pid":38601} # direct: OK
$ OPENCODE_SERVER_PASSWORD=… opencode2 api --server http://127.0.0.1:3000/v2 GET /api/health
ERROR: Server at http://127.0.0.1:3000/v2 did not provide a compatible V2 health response
[cause]: ClientError: UnsupportedContentType # path: FAIL
The proxy access log for the failing run shows GET /api/health — the /v2
prefix was dropped, so it hit the non-opencode root, which returns running and
is rejected. (The direct call proves the v2 server, client, and auth are all
fine; only the path prefix differs.)
opencode attach http://127.0.0.1:3000/v1 connects; every request stays under
the prefix:
GET /v1/path
GET /v1/config/providers
GET /v1/global/event <-- even the event stream
...
The v1 client joins base + path by concatenation
(baseUrl + (path.startsWith("/") ? path : "/"+path)), preserving the prefix.
Repository: github.com/sst/opencode
Branch: dev (the v2 line; the -v2 remote branches are feature branches)
Commit: 04284921ac (latest origin/dev at time of writing)
packages/sdk/js/src/gen/core/utils.gen.ts:96-97
const pathUrl = _url.startsWith("/") ? _url : `/${_url}`
let url = (baseUrl ?? "") + pathUrlbaseUrl = "http://127.0.0.1:3001/proxy", _url = "/api/health" →
http://127.0.0.1:3001/proxy/api/health. The prefix survives.
packages/app/src/utils/server-protocol.ts:14
const response = await fetch(new URL(path, server.url), {called with root-relative paths at packages/app/src/utils/server-protocol.ts:28
and :31:
const legacy = await probe(server, fetch, "/global/health")... // line 28
const current = await probe(server, fetch, "/api/health")... // line 31new URL("/api/health", "http://127.0.0.1:3001/proxy") →
http://127.0.0.1:3001/api/health. The leading / replaces the path component,
so /proxy is discarded.
Introduced by commit d03e0c5e547f2bc7ae44e60eb21bfb24dad623fd
("feat(app): add dual-server compatibility", #38462).
There are two v2 client generators, and the app uses both:
@opencode-ai/sdk/v2— gen builder, concatenates → keeps the prefix (packages/sdk/js/src/v2/gen/core/utils.gen.ts:96-97). Used bycreateSdkForServer(packages/app/src/utils/server.ts:34).@opencode-ai/client(OpenCode.make) — resolves withnew URL→ drops the prefix. This is the client used for health checks and the main API.
packages/client/src/generated/client.ts:
144: const url = new URL(descriptor.path, options.baseUrl) // origin-relative
253: health: { … path: `/api/health` … } // all 61 endpoints are /api/...new URL("/api/health", ".../proxy") → .../api/health, and the same for every
endpoint (/api/session, /api/agent, …). When the origin returns non-JSON
(Forge's running), this client raises UnsupportedContentType — the exact
cause in the observed CLI error.
Prefix-losing call sites, all resolving /api/... against the origin:
packages/app/src/utils/server-protocol.ts:14— protocol detection probepackages/app/src/utils/server-health.ts—checkServerHealth, viaOpenCode.makepackages/app/src/utils/server.ts:48—createApiForServer, viaOpenCode.make
The only prefix-preserving client (@opencode-ai/sdk/v2, string concat) is not
the one doing health/detection/API. So the trailing path is dropped fundamentally,
not just during health checks.
- Web UI — add/edit server:
packages/app/src/components/dialog-select-server.tsx:262(checkServerHealth) and:269(detectServerProtocol); edit path at:313/:320. - Connection context (web + v2 CLI/TUI):
packages/app/src/context/server-sdk.tsx:208(detectServerProtocol). Theapppackage is shared by the web front end and the v2 CLI.
An audit of every new URL(), fetch(), WebSocket, and EventSource
request-builder in packages/client and packages/app finds exactly two
that resolve endpoint paths against the origin (dropping the prefix). Both are
new in V2; the V1 equivalents do not have the bug — which is why V1 never broke.
| # | V2 fix site | Scope | V1 status |
|---|---|---|---|
| 1 | packages/httpapi-codegen/src/index.ts:537 (codegen template) → generated packages/client/src/generated/client.ts:144 — new URL(descriptor.path, options.baseUrl) |
All 61 REST + SSE endpoints of @opencode-ai/client (health, sessions, agents, events) |
No bug. V1 uses a different, older client — @opencode-ai/sdk, packages/sdk/js/src/gen/core/utils.gen.ts:97 → (baseUrl ?? "") + pathUrl (concat, keeps prefix; created 2025-08-22). The new URL client is a V2 addition (2026-06-24, #33445). |
| 2 | packages/app/src/utils/server-protocol.ts:14 — fetch(new URL(path, server.url)) |
Protocol-detection probe | Did not exist in V1 — added for V2 (2026-07-23, #38462). |
Site 1 is a generated file, so fix the codegen template (then regenerate);
that one change covers every endpoint of @opencode-ai/client, including the
SSE event stream (which flows through the same prepare() → new URL).
Resolve endpoint paths relative to the configured base instead of against the
origin — normalize the base to end in / and drop the path's leading slash:
function apiUrl(base: string | URL, path: string) {
const b = new URL(base)
if (!b.pathname.endsWith("/")) b.pathname += "/"
return new URL(path.replace(/^\//, ""), b) // relative join, prefix preserved
}
// apiUrl("http://127.0.0.1:3001/proxy", "/api/health") => .../proxy/api/health
// apiUrl("http://127.0.0.1:3001", "/api/health") => .../api/health (no regression)packages/sdk/js/src/v2/gen/core/utils.gen.ts:96-97— v2 gen SDK, concatenates (safe).packages/app/src/utils/terminal-websocket-url.ts:16— concatenates, already handles v1/v2 (safe).server-sdk.tsx:194,entry.tsx:85,terminal.tsx:186/385/620,home-projects-view.tsx:253,deep-links.ts:7,draft-store.ts:157— single-arg URL parsing, display, same-origin checks, or blob URLs; not request construction fromserver.url.