Skip to content

feat(templates/opencode): per-model token limits via LITELLM_MODEL_LIMITS - #488

Draft
mprokopov wants to merge 4 commits into
LiteLLM-Labs:mainfrom
mprokopov:opencode-model-limits
Draft

feat(templates/opencode): per-model token limits via LITELLM_MODEL_LIMITS#488
mprokopov wants to merge 4 commits into
LiteLLM-Labs:mainfrom
mprokopov:opencode-model-limits

Conversation

@mprokopov

Copy link
Copy Markdown

OpenCode reads a model's context/output limits from models.dev. A gateway-specific model name is not there, so opencode falls back to a conservative output budget. A reasoning model then spends that budget thinking and returns a response with no content block, which the gateway surfaces as a 500 (list index out of range) and which ends an agent turn silently — the agent does all of its work and reports nothing.

This adds LITELLM_MODEL_LIMITS="<model>=<context>:<output>,..." so those models can be given explicit limits.

  • only listed models get a limit, so models opencode already knows keep their real ceilings
  • both numbers are required, and a partial limit is never emitted

That last point is the subtle one. OpenCode's schema rejects a limit missing context with ConfigInvalidError, but lazily: the server starts and reports healthy, and then every session create fails with a 400. A partial limit is worse than no limit, so this is enforced rather than defaulted.

Verification

Against gemini-3.6-flash behind a LiteLLM gateway (provider id anthropic):

config POST /session
limit: { context, output } 200
limit: { output } only 400 ConfigInvalidError … limit.context "Missing key"
no limit 200

node --test templates/opencode — 14 pass, including three new cases covering both-keys, partial/invalid rejection, and preservation of ensureProviderModel entries across a restart.

Note: two tests in templates/opencode/test/store.test.mjs fail on the base commit as well; they are unrelated to this change.

Branched from fix/mcp-sse-frame-terminator (#484) rather than main, since that is what the deployed runtime image is built from — happy to rebase onto main if preferred.

`filter_event_stream_tools` rebuilt the event-stream body with
`text.lines()` + `join("\n")`. That is not round-trip safe for trailing
newlines: `"a\nb\n\n".lines()` yields `["a","b",""]`, so joining produces
`"a\nb\n"` and silently drops one newline — exactly the blank line that
terminates an SSE event.

Effect: any MCP server registered WITH a non-empty `allowed_tools` returned a
`tools/list` frame that never closed. Real MCP clients (opencode 1.18.9, and any
client using a spec-compliant SSE parser) wait forever for the frame and report a
generic failure — opencode surfaces `{"status":"failed","error":"Failed to get
tools"}`. Servers with an empty `allowed_tools` were unaffected, because that
path streams the upstream bytes verbatim.

This was easy to misdiagnose: `curl` appears to work fine, since it just prints
bytes until the connection closes and never needs the terminator. The bug is only
visible byte-wise (`}}\n` instead of `}}\n\n`) or via a real client.

Switch to `split_inclusive('\n')`, which keeps each line's terminator, so blank
lines and the trailing newline survive. CRLF is preserved too.

Verified against GitHub's remote MCP server through LAP's proxy with 41
`allowed_tools` configured:
  - before: 109722 bytes, tail `0a`,   opencode -> "failed"
  - after:  109723 bytes, tail `0a0a`, opencode -> "connected"
and an agent then successfully called `github_get_file_contents` end to end.

Adds two regression tests: one asserting the terminator survives filtering, one
asserting newline count is unchanged when nothing is filtered out.
Two problems stopped an opencode agent from using an MCP server that LAP itself
proxies at `/{alias}/mcp`:

1. `writeMcpConfig` wrote only `{type,url,enabled}` for remote servers, dropping
   any `headers`. opencode's remote transport supports a `headers` object, so
   there was no way to authenticate at all.

2. LAP's `normalize_mcp_servers` retains ONLY `{type,name,url}` on each server
   entry, so headers configured on the agent never survive the trip to the
   runtime — meaning (1) alone cannot fix it.

LAP's MCP proxy requires a gateway key, so the net effect was a guaranteed 401.

Forward `headers` when present, and otherwise attach the gateway key we already
hold for model calls — but ONLY when the MCP URL's origin matches
`LITELLM_BASE_URL`, so the key is never sent to a third-party MCP host.

Verified: agent on gemini-3.6-flash called `github_get_file_contents` through
LAP's proxy against GitHub's remote MCP server.
…routes

Addresses both Codex review findings on LiteLLM-Labs#484.

**P1 — origin-only check was too permissive.** Comparing `URL.origin` ignores the
path, so a gateway mounted under a prefix on a shared host
(`LITELLM_BASE_URL=https://co.example/litellm/v1`) made every other path on that
host look LAP-owned — including an unrelated `https://co.example/customer-mcp`,
which would then receive the gateway key (possibly the master key).

Now the URL must sit under LAP's own root *and* match one of LAP's actual MCP
routes:

  {root}/{alias}/mcp              — dynamic MCP proxy
  {root}/mcp/platform/{agent_id}  — platform MCPs

Both enforce `require_any_gateway_key`, so both legitimately need the credential;
anything else on the origin no longer gets it. The `/v1` model-API suffix is
stripped from the base to derive the root, so path-mounted gateways still work.

Note: scoping to only `/{alias}/mcp` was initially too narrow — it silently
dropped auth for platform MCPs (`request_human_approval`, `agent_memory`, …),
which live at `/mcp/platform/{agent_id}`. Caught by live verification, hence both
shapes are allowlisted and covered by tests.

**P2 — a non-auth header suppressed the fallback.** The guard tested whether
`headers` was non-empty, so an entry carrying only e.g. `x-tenant` skipped the
credential and LAP answered 401. It now checks specifically for an existing
`Authorization` or `x-api-key` header (case-insensitive), and an explicit caller
auth header is still never overwritten.

Adds 9 tests to templates/opencode/test/opencode.test.mjs covering: LAP proxy and
platform routes get the key; same-origin non-LAP path does not; other origins
never do; path-mounted gateway still matches; query strings are ignored; non-auth
headers coexist with the injected credential; explicit `Authorization` and
lowercase `x-api-key` are preserved.

Verified: `node --test test/opencode.test.mjs` → 11 passed, 0 failed; and live
against a rebuilt runtime both `github` and `platform` MCP entries receive the
credential while non-LAP URLs receive none.
…MITS

OpenCode looks models up in models.dev to learn their context and output
limits. A gateway-specific model name is not there, so opencode falls back to
a conservative output budget. A reasoning model then spends that budget
thinking and returns a response with no content block at all, which the
gateway surfaces as a 500 ("list index out of range") and which ends an agent
turn silently — the agent does all of its work and reports nothing.

Adds LITELLM_MODEL_LIMITS="<model>=<context>:<output>,..." so such models can
be given explicit limits. Only listed models get one; models opencode already
knows are left untouched so their real ceilings are not capped.

Both numbers are required, and the code refuses to emit a partial limit,
because opencode's schema rejects a `limit` missing `context` with
ConfigInvalidError — and it does so lazily. The server still starts and
reports healthy; every session create then fails with 400. A partial limit is
therefore worse than none, which is why this is enforced rather than defaulted.

Verified against gemini-3.6-flash behind a LiteLLM gateway: session create
returns 200 with both keys and 400 with only `output`.
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