Repository navigation
feat: add direct upstream passthrough - #123
Conversation
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.
Review: not ready to mergeI ran the branch locally ( 1. Upstream can never authenticate — caller credentials are stripped, nothing replaces them
Verified against a live mock upstream — it receives no auth at all: Consequences:
Suggested fix: accept an upstream credential in the config ( 2. Caller-supplied hop-by-hop headers are forwarded verbatim → 502
Confirmed in isolation: undici throws Suggested fix: strip hop-by-hop headers ( 3. Upstream model list silently shadows local OpenCode models
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. Lower severity, worth folding into the same PR
What looks good
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.
|
Thanks for the thorough review — all three blockers and the lower-severity items are addressed in
Lower severity:
|
- 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
Review update — all findings verified fixed in the merged commitWhile I was writing the review above, the PR was squash-merged as Before → after, re-running my original reproductions: Mapping to the findings in my review:
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.
One nit still standing: the Published as |
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, andPOST /v1/messagesare eligible.prefixis forwarded with the prefix removed. Prefix matching always wins."unprefixed": true): a model listed by the upstream'sGET /v1/modelsis forwarded unchanged. It is off by default so an upstream model cannot silently shadow a local OpenCode model with the same id.Authorization,x-api-key, andanthropic-api-keyheaders are removed before forwarding.apiKeyis set, it replaces the caller's credentials.authHeaderdefaults toauthorization(Bearer <apiKey>); any other name (for examplex-api-key) receives the raw key. The same credential is used for the/v1/modelsprobe.connection,keep-alive,proxy-authenticate,proxy-authorization,te,trailer,transfer-encoding,upgrade), any header named inConnection,host,content-length, and caller auth headers are stripped before forwarding.OPENCODE_LLM_PROXY_MAX_REQUEST_BYTEScap before forwarding (HTTP 413 when exceeded), including for requests that fall back to the normal OpenCode-backed path.x-opencode-passthrough: true.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_BYTESdefault 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.mdnow documents the passthrough as a separate operator-supplied egress path, the credential handling, and the local-config expectations (local file, mode600, least-privilege key)..gitignorecoversllm-proxy-passthrough.json.Tests
npm test— 338 passednpm run lint— cleannpm run build—distin sync