Skip to content

feat: add direct upstream passthrough - #123

Merged
KochC merged 4 commits into
mainfrom
feat/upstream-passthrough
Nov 11, 2026
Merged

KochC merged 4 commits into
mainfrom
feat/upstream-passthrough

Conversation

@KochC

@KochC KochC commented Nov 11, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds an optional direct-upstream passthrough for clients that already speak an upstream's native API, and raises the default request-body cap so whole-session compaction prompts are not rejected.

Direct upstream passthrough

Matching requests are forwarded verbatim, without an OpenCode session or tool bridge. Configure it in a local JSON file (OPENCODE_LLM_PROXY_PASSTHROUGH_CONFIG, default ~/.config/opencode/llm-proxy-passthrough.json):

{
  "baseUrl": "https://upstream.example.com",
  "prefix": "upstream/",
  "unprefixed": false,
  "apiKey": "optional-upstream-key",
  "authHeader": "authorization"
}
  • POST /v1/chat/completions, POST /v1/responses, and POST /v1/messages are eligible.
  • A model starting with prefix is forwarded with the prefix removed. Prefix matching always wins.
  • Unprefixed discovery is opt-in ("unprefixed": true): a model listed by the upstream's GET /v1/models is forwarded unchanged. It is off by default so an upstream model cannot silently shadow a local OpenCode model with the same id.
  • The proxy's bearer auth and CORS checks still apply. Caller Authorization, x-api-key, and anthropic-api-key headers are removed before forwarding.
  • If apiKey is set, it replaces the caller's credentials. authHeader defaults to authorization (Bearer <apiKey>); any other name (for example x-api-key) receives the raw key. The same credential is used for the /v1/models probe.
  • Hop-by-hop headers (connection, keep-alive, proxy-authenticate, proxy-authorization, te, trailer, transfer-encoding, upgrade), any header named in Connection, host, content-length, and caller auth headers are stripped before forwarding.
  • The request body is read with the OPENCODE_LLM_PROXY_MAX_REQUEST_BYTES cap before forwarding (HTTP 413 when exceeded), including for requests that fall back to the normal OpenCode-backed path.
  • Passthrough bypasses the proxy concurrency queue and request timeout; the upstream's limits apply.
  • Responses carry x-opencode-passthrough: true.
  • Non-matching models keep the existing OpenCode-backed behavior.

The config file is read at plugin start, is intended to stay local (mode 600), and is git-ignored by default.

Request-body cap

OPENCODE_LLM_PROXY_MAX_REQUEST_BYTES default is raised from 1 MiB to 32 MiB. Whole-session compaction prompts routinely exceed 1 MiB, so the old default rejected otherwise valid requests. This is a deliberate, security-relevant behavior change: the new default is documented in the README, and the remote-media download default stays at 1 MiB so raising the JSON body cap does not silently raise download limits.

Security

docs/security.md now documents the passthrough as a separate operator-supplied egress path, the credential handling, and the local-config expectations (local file, mode 600, least-privilege key). .gitignore covers llm-proxy-passthrough.json.

Tests

  • npm test — 338 passed
  • npm run lint — clean
  • npm run build — dist in sync
  • New coverage: bearer and custom upstream auth, hop-by-hop stripping, unprefixed opt-in, bounded request bodies, and passthrough metrics.

Add an optional passthrough config that forwards matching OpenAI Chat, OpenAI Responses, and Anthropic Messages requests verbatim to an upstream base URL. This preserves the upstream's streaming, tool-call, and error behavior and bypasses the proxy request-body cap.

- config file: OPENCODE_LLM_PROXY_PASSTHROUGH_CONFIG or ~/.config/opencode/llm-proxy-passthrough.json
- model matching by optional prefix or upstream GET /v1/models discovery
- strips the caller's proxy credentials before forwarding
- marks responses with x-opencode-passthrough: true
- keeps non-matching models on the normal OpenCode-backed path

Also raise the default request-body cap from 1 MiB to 32 MiB so whole-session compaction prompts are not rejected, while keeping the remote-media download default at 1 MiB.
@KochC

KochC commented Nov 11, 2026

Copy link
Copy Markdown
Owner Author

Review: not ready to merge

I ran the branch locally (332/332 pass, lint clean, dist verified in sync with a fresh build) and probed the handler against a mock upstream. The plumbing is sound, but I could reproduce three user-visible bugs — the first makes the feature unusable against any authenticated upstream.


1. Upstream can never authenticate — caller credentials are stripped, nothing replaces them

loadPassthroughConfig() (index.js:151) returns only { prefix, baseUrl }, and proxyPassthrough() deletes authorization, x-api-key, and anthropic-api-key without ever adding an upstream credential.

Verified against a live mock upstream — it receives no auth at all:

C upstream auth header: (none) | x-api-key: (none)

Consequences:

  • Passthrough only works against an upstream that requires no authentication.
  • isUpstreamModel()'s GET /v1/models probe is equally unauthenticated, so unprefixed model discovery silently no-ops on every real provider.

Suggested fix: accept an upstream credential in the config (apiKey and/or a header name) and apply it in proxyPassthrough() and the models probe. If the intent really is "unauthenticated upstreams only", that needs to be stated explicitly in the README rather than implied.


2. Caller-supplied hop-by-hop headers are forwarded verbatim → 502

new Headers(request.headers) copies everything; only 4 names are deleted. A client sending transfer-encoding: chunked produces a hard failure:

B transfer-encoding: status= 502 body={"error":{"message":"Passthrough request failed: fetch failed",...}}

Confirmed in isolation: undici throws invalid transfer-encoding header. A stale content-length fails the same way (Request body length does not match content-length header). This means a client can trigger a 502 on demand.

Suggested fix: strip hop-by-hop headers (connection, transfer-encoding, te, upgrade, proxy-authenticate, proxy-authorization, keep-alive) plus content-length, host, and the auth headers before forwarding.


3. Upstream model list silently shadows local OpenCode models

isUpstreamModel() is consulted for every non-prefixed model. If the upstream's /v1/models happens to list gpt-4o, then gpt-4o — a model that genuinely exists in local OpenCode — is rerouted upstream with no prefix and no warning:

A shadowing: status= 200 passthroughHeader= true
   forwarded to upstream? true

Enabling passthrough can therefore silently intercept existing traffic for anyone using the same model names on both sides.

Suggested fix: let prefix matching win, and make unprefixed matching opt-in (e.g. "unprefixed": true). At minimum, document the takeover risk prominently.


Lower severity, worth folding into the same PR

  • The request-body cap is bypassed before it is applied. await request.text() (index.js:2266) buffers the entire body with no limit for every POST whenever passthrough is configured — including non-matching models, which are then re-wrapped and capped by readJsonBody. With OPENCODE_LLM_PROXY_HOST=0.0.0.0 as the documented default, that is a memory-exhaustion window affecting all traffic, not just passthrough.
  • Passthrough skips HTTP metrics. Both return proxyPassthrough(...) paths sit above the recordHttpCompletion calls (index.js:3202, index.js:3228), so /metrics under-reports once the feature is enabled.
  • Every cold-cache request pays an outbound /v1/models call — including requests that will end up on the normal OpenCode path.
  • docs/security.md is not updated. That document carefully scopes outbound-egress behavior for remote media (:59–:69) but says nothing about this new arbitrary-egress path, on a server that defaults to 0.0.0.0 with auth optional. The referenced config file is also not in .gitignore; the README says "keep this file local" but nothing enforces it.
  • Unrelated default change bundled into a feat PR: MAX_REQUEST_BYTES 1 MiB → 32 MiB (index.js:33). Decoupling the remote-media cap from the body cap is a genuine fix and worth keeping, but the 32 MiB default is a separate, security-relevant behavior change that deserves its own release note.

What looks good

  • dist regenerated correctly and matches the source; Headers added to the eslint globals is right.
  • Replaying the consumed body (new Request(...)) preserves each API's native error shape for malformed JSON — good call.
  • Decoupling REMOTE_MEDIA_MAX_BYTES from MAX_REQUEST_BYTES is a real improvement.
  • Happy-path coverage for prefixed/unprefixed forwarding, auth enforcement, invalid config, and a 2 MiB body is solid.

I would not merge with items 1–3 outstanding. Items 4–8 I'd strongly prefer in this PR since they touch the same code paths, but I would not block on them alone.

Happy to push a commit addressing all of the above if you'd like to keep the PR moving.

Address review feedback on the passthrough path:

- accept an upstream apiKey and configurable authHeader, and apply the
  credential to both forwarded requests and the GET /v1/models probe
- strip hop-by-hop headers plus any header named in Connection before
  forwarding, so a client cannot trigger a 502 with transfer-encoding or a
  stale content-length
- make unprefixed model discovery opt-in (unprefixed: true); prefix
  matching still wins, and unprefixed discovery off means a local OpenCode
  model can no longer be shadowed silently
- read the request body with the configured OPENCODE_LLM_PROXY_MAX_REQUEST_BYTES
  cap before forwarding, including for requests that use the normal path
- document passthrough egress, credential handling and the git-ignored
  local config in README and docs/security.md
The response wrapper already records every response returned by the proxy handler, including passthrough. Add a regression test so the behavior is explicit.
@KochC

KochC commented Nov 11, 2026

Copy link
Copy Markdown
Owner Author

Thanks for the thorough review — all three blockers and the lower-severity items are addressed in 5076efe and 076ab55.

  1. Upstream auth — the config now accepts apiKey and an optional authHeader (default authorization → Bearer <apiKey>; any other header receives the raw key). It is applied to both the forwarded request and the GET /v1/models probe. Caller credentials are still stripped.

  2. Hop-by-hop headers — connection, keep-alive, proxy-authenticate, proxy-authorization, te, trailer, transfer-encoding, upgrade, any header named in Connection, plus host, content-length, and caller auth headers are removed before forwarding.

  3. Unprefixed shadowing — discovery is now opt-in via "unprefixed": true; prefix matching still wins. With the default (false), an unprefixed model never leaves the local OpenCode path and no /v1/models call is made.

Lower severity:

  • The request body is now read through the bounded reader before forwarding, so OPENCODE_LLM_PROXY_MAX_REQUEST_BYTES applies to every POST, including non-matching models.
  • Passthrough responses were already recorded by the response wrapper at the end of createProxyFetchHandler; I added a regression test (passthrough responses are recorded in HTTP metrics) so that path is covered explicitly.
  • The /v1/models probe only runs when unprefixed discovery is enabled.
  • docs/security.md documents passthrough egress, credential handling, and the local-config requirements; .gitignore covers llm-proxy-passthrough.json.
  • The 32 MiB body-cap default is kept deliberately and is called out as a behavior change in the PR description. Remote-media downloads remain at 1 MiB.

npm test — 338 passed, npm run lint clean, dist rebuilt. CI is rerunning on the new commits.

- strip hop-by-hop headers from the upstream response as well, not only
  from the forwarded request
- reject a reserved authHeader (host, content-length, content-type, or any
  hop-by-hop field) and an unencodable apiKey at config load, instead of
  failing every request
- add regression coverage that the GET /v1/models probe carries the
  configured upstream credential and never the caller's
- remove the stale README claim that passthrough bypasses the request-body
  cap; the configured cap now applies before forwarding
- name the large-body test for what it verifies (above the old 1 MiB
  default) and cover the cap for matching and non-matching models
@KochC
KochC merged commit 8c383e6 into main Nov 11, 2026
4 checks passed
@KochC
KochC deleted the feat/upstream-passthrough branch November 11, 2026 09:00
@KochC

KochC commented Nov 11, 2026

Copy link
Copy Markdown
Owner Author

Review update — all findings verified fixed in the merged commit

While I was writing the review above, the PR was squash-merged as 8c383e6. The merged content differs from 64b96e7 (the commit I reviewed) and already contains fixes for every item I raised. I re-verified each one against main with a live mock upstream.

Before → after, re-running my original reproductions:

BUG1 upstream auth      -> Bearer sk-upstream | caller token leaked? false   (was: (none))
BUG2 transfer-encoding  -> status 200                                        (was: 502)
BUG3 gpt-4o shadowing   -> passthrough header: null | forwarded? false       (was: forwarded upstream)

Mapping to the findings in my review:

  1. Upstream auth — fixed. loadPassthroughConfig() now accepts apiKey/authHeader with validation (reserved header names rejected at load time so a bad config cannot fail every request), and applyUpstreamAuth() is applied to both the forwarded request and the GET /v1/models probe.
  2. Hop-by-hop headers — fixed. HOP_BY_HOP_HEADERS + stripHopByHop() handle the RFC 9110 Connection token list and are applied in both directions.
  3. Model shadowing — fixed. Unprefixed discovery is now opt-in ("unprefixed": true); prefix matching always wins, and an empty prefix with unprefixed disabled disables passthrough entirely.
  4. Body-cap bypass — fixed. request.text() is gone; readTextBody() enforces MAX_REQUEST_BYTES before buffering, returning 413 — including for requests that end up on the normal path. Verified: rawBody = await request.text() count is now 0.
  5. Metrics — fixed. New test passthrough responses are recorded in HTTP metrics.
  6. docs/security.md — fixed. New "Direct upstream passthrough" section covering egress trust, credential scope, header stripping, the body cap, and the shadowing caveat.
  7. Config file hygiene — fixed. llm-proxy-passthrough.json added to .gitignore; README documents mode 600.

Test coverage grew from 332 to 341, with dedicated tests for caller hop-by-hop stripping, upstream response hop-by-hop stripping, upstream bearer and custom auth headers, probe authentication, the body cap, and shadowing opt-in.

npm test 341/341, lint clean, dist verified in sync with a fresh build.

One nit still standing: the MAX_REQUEST_BYTES 1 MiB → 32 MiB default change is not called out in CHANGELOG.md under 1.13.0. It is documented in the README table and is a reasonable change (the remote-media cap was correctly decoupled so raising the JSON body cap doesn't raise download limits), but it is a security-relevant default change riding in a feat release. Worth a release-note line.

Published as opencode-llm-proxy@1.13.0 — tarball shasum verified against the registry (4efe6182…), npm install and module import confirmed working.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant