# OpenCode Zen free-tier gate: reverse-engineering notes

Everything below was established empirically on 2026-10-11/12 by capturing
the official OpenCode CLI's traffic (MITM) and bisecting the endpoint's
behavior with controlled request mutations from Node.js. Where a claim
comes from source code rather than a probe, it says so.

## 1. Endpoint topology (from public source: sst/opencode, anomalyco/opencode)

- `https://opencode.ai/zen/v1/*` is the "Console" — a SolidStart app on
  Cloudflare (`packages/console/app/src/routes/zen/util/handler.ts`).
- The handler's first action is `proxyInference()`
  (`packages/console/app/src/lib/inference-proxy.ts`):
  - Key read from `authorization` (or `x-api-key` / `x-goog-api-key`
    depending on path). `anonymous = !key || key === "public"`.
  - Anonymous requests for `allowAnonymous` models (all `-free` ids) are
    forwarded **without credentials** to a private inference service
    behind Cloudflare Access; the console holds the service token.
  - `oc_sk_…` keys are always forwarded there; legacy `sk-…` keys may
    fall through to a legacy handler with IP rate limiting.
- `FreeTierError` ("OpenCode's free tier can only be used from within
  OpenCode") appears **nowhere in the public repo** — it is generated by
  the private inference service.
- **Free models are not served on `/chat/completions` at all anymore.**
  Chat-native frees (`kimi-k2.5-free`, `minimax-m2.5-free`, …) return
  `ModelError: Model … is not supported`; Responses-only models
  (`muse-spark-*`) return `ModelProtocolUnsupported`. Everything free
  lives on `POST /zen/v1/responses`.
- `GET /zen/v1/models` is an open catalog (no auth needed).

## 2. The official client's wire format (MITM capture, CLI 1.19.0)

```
POST https://opencode.ai/zen/v1/responses
Authorization: Bearer public
User-Agent: opencode/1.19.0 ai-sdk/provider-utils/4.0.40 runtime/bun/1.3.14
x-opencode-client: cli            (value not validated: "cli", "zeron", … pass)
x-opencode-project: global
x-opencode-session: ses_<SortableID-descending>
x-opencode-session-id: ses_<SortableID-descending>
x-opencode-request: msg_<SortableID-ascending>
Content-Type: application/json

{"model": "...-free", "input": [ ... ], "max_output_tokens": ...,
 "store": false, "include": [...], "reasoning": {...}, "stream": true,
 "tools": [ ... ]}
```

SortableID (`packages/schema/src/identifier.ts`) — 26 chars, client-minted
(no server round-trip registers it):

```
value   = timestampMs * 0x1000 + counter      (64-bit)
value   = ~value                                (descending variant)
chars   = hex(value >> ...48 bits).padStart(12) + 14 × random base62
"ses_" uses descending, "msg_" uses ascending.
```

## 3. Gate rules (each verified by probe)

| # | Rule | Evidence |
|---|------|----------|
| 1 | `/zen/v1/responses` only for free models | C1/C2 probes: chat endpoint rejects free ids |
| 2 | UA must be the exact official string | UA `node`, generic, or prefix-only → 403 (S2, early probes) |
| 3 | No `X-Stainless-*` headers | known-good + stainless set → 403 (S1) |
| 4 | `x-opencode-session(-id)` must be structurally valid, fresh SortableIDs | fake nanoid ses → 403 (M2/M4); correctly generated → 200 (Y1); msg id format lax (M1: fake msg, real ses → 200) |
| 5 | A developer message must contain an OpenCode prompt **verbatim** | prefixes of 200/600/2096 chars: 403/403/200 (P-series); marker as `system` role → 403 (P3); marker as first or later developer both pass (O1) |
| 6 | Arbitrary extra developer/user content after the marker is unchecked | custom second developer prompt → 200 (Z2); any user text → 200 (Y3) |
| 7 | `input` must be a structured array; `stream: true` required | flat-string input → 403 (W6); `stream:false` → 403 (W7) |
| 8 | `store`, `include`, `reasoning`, `max_output_tokens`, `prompt_cache_key` all optional | W1–W5, T2 all 200 |
| 9 | `tools`: absent = fine; official set verbatim = fine; **anything else = rejected** | one char changed in one tool description → 400 (Xb); subset → 400 (Xc); own valid tools → 400/403 (V2, B-series) — exact (prompt, tools) signature match against known builds |
| 10 | With `[marker, custom system]` present, at most **2 further input items** | 4-item inputs pass (B4, C1, C3, C4); 5-item fail (B-series, C2) — regardless of roles |
| 11 | Transport-agnostic — no TLS fingerprinting | official CLI passed through a Node/OpenSSL MITM; Node-direct with correct content 200s; curl/undici/Bun identical outcomes |
| 12 | Extra junk headers harmless | `session_id`, `x-client-request-id`, `sec-fetch-mode`, `accept-language` → 200 (S3) |
| 13 | `input_image` blocks pass and reach the model | user content `[input_text, input_image(data URI)]` in the 3-item shape → 200 with an accurate description of the actual image (verified 2026-10-12, 1024px JPEG ≈ 74 KB base64); content blocks do not count toward the 2-item budget |

Enforcement/ops observations:

- After ~60 probes the endpoint intermittently hangs sockets and 504s —
  per-IP throttling/flagging. Retries usually succeed.
- Paid models with `Bearer public` → `402 Insufficient account funds`
  (the gateway's wording).
- Free-tier quota is per IP + client class (community: ~200 req/day).

## 4. Dead ends (don't re-tread)

- **Header impersonation alone.** Every combination — Sept-era official
  UA set, current CLI shape (`opencode/stable/x/y/cli` +
  `x-opencode-session-id` + `ses_`+64-hex), full browser header sets —
  403s from curl/Node. Headers are necessary but nowhere near sufficient.
- **TLS-stack hypothesis.** A Bun (BoringSSL) sidecar was built on the
  theory that the gate fingerprinted OpenSSL clients; Bun direct +
  minimal/official/browser shapes all 403'd. Falsified by the MITM
  upstream (Node/OpenSSL) returning 200 with the right *content*.
- **Body-dialect theories.** `max_tokens` vs `max_completion_tokens`,
  companion fields (`tool_choice`, `include`, `reasoning`) — none affect
  the gate.
- **chat/completions for free models** — endpoint no longer serves them.
- **Franken requests** (official prompt + own tools, or official tools +
  marker prompt) → 400. The (prompt, tools) pair must be an exact known
  official combination.

## 5. The working recipe

What this extension implements:

1. Wire shape per request: `[developer(marker), system(defused custom
   prompt), user(single consolidated transcript)]` — exactly 3 items
   (rules 5, 6, 10), no `tools` (rule 9), `stream: true` (rule 7),
   official UA, no `X-Stainless-*`, fresh SortableIDs (rules 2–4).
2. Marker defusal: a precedence preamble on the custom system message
   plus a recency reminder at the transcript tail — otherwise the
   marker's title-generator persona bleeds into answers (verified:
   "Yes, it is" with preamble vs "Sky color inquiry" without).
3. Tool calling as a text protocol (rule 9 makes native tools
   impossible): the system prompt teaches fenced
   ```` ```tool {"name","arguments"} ```` blocks, and the SSE response
   stream is rewritten in-flight (`stream.ts`, ported from zenshim's
   stream translator): a fence-holdback state machine keeps candidate
   fence text out of the visible deltas, harvested blocks are emitted
   as native Responses `function_call` events, and unparseable blocks
   are re-emitted as text for self-correction. Pi's stream processor
   builds real `toolCall` blocks from those events — the agent loop
   executes them natively (parallel batches, multi-round loops), and
   the UI renders them exactly like a native-tools provider. A
   `message_end` handler remains as a fallback. Results flow back
   through the transcript (`[tool result: …]`), and every request
   re-consolidates to the 3-item shape.

   Live-upstream facts the rewriter depends on (captured 2026-10-15
   with `PI_ZEN_DEBUG=1`, which tees raw upstream SSE to
   `/tmp/pi-zen-sse-upstream.sse`):
   - a tool-call turn arrives as **several output items**: a
     `reasoning` item, a prose `message` item, and the ```tool fence
     as its **own** `message` item — so text state is tracked
     per output item and each item's done-event is rewritten with
     that item's fence-free text only (otherwise the prose item's
     text is duplicated into the fence item's slot);
   - the fence item closes with `response.content_part.done` and
     `response.output_item.done` but **no** `output_text.done`;
   - normal markdown code fences (answers quoting command output)
   pass through untouched.
4. Session affinity: Pi's session id maps deterministically to one Zen
   `ses_` SortableID (minted fresh on first use) for sticky routing and
   cache warmth.

Verified working through full Pi (print mode): plain chat, `read`,
`bash`, `write`, and multi-round write→read chains; every wire round
inspected as `[developer, system, user]`, all 200s. The stream rewrite
is covered by `test/stream.test.ts` (structural + conformance against
pi-ai's real `processResponsesStream`, including a regression test for
the real multi-item Zen turn shape) and `test/e2e-openai-sdk.test.ts`
(real OpenAI SDK over a mock Zen SSE server), and was re-verified
against the live endpoint: prose rendered once, fences fully
suppressed, native `toolCall` blocks in the session file.

## 6. Maintenance

The gate pins two artifacts to specific official builds:

- the **marker prompt** (`marker.ts`) — captured title-generator prompt,
  must match byte-for-byte;
- the **User-Agent** (`ZEN_USER_AGENT`).

If OpenCode rotates either, requests start 403ing. Re-capture procedure:

1. Run the official CLI (matching version) through an HTTPS MITM with a
   locally trusted CA:
   ```bash
   HTTPS_PROXY=http://127.0.0.1:8443 \
   NODE_EXTRA_CA_CERTS=/path/to/ca.pem \
   opencode run -m opencode/<some-free-model> "hi"
   ```
   (Node-based MITM with `node-forge` certs and `maxVersion: TLSv1.2`
   works; Bun honors `NODE_EXTRA_CA_CERTS`.)
2. Take the **first** (title-generation) request's `input[0].content`
   verbatim → replace `marker.ts`.
3. Copy the CLI's exact `User-Agent` → `ZEN_USER_AGENT`.

Expected rotations to watch: new prompt wording, UA version bumps, the
2-item input budget, marker-role requirements, and per-session
registration (currently absent — ids are client-minted).

## 7. Protocol routing & automatic fallback (probed 2026-10-16)

Rule 1 above is outdated: the free tier is now split across BOTH
endpoints per model — `muse-spark-*-contributor-free` on
`/zen/v1/responses`, every other `-free` id on
`/zen/v1/chat/completions` (glm-5.3-flash-free and
deepseek-v4.1-flash-free verified 200; the v2 console docs publish the
same per-model endpoint table). Requesting the wrong endpoint answers
`400 ModelProtocolUnsupported` — or, when extra Responses fields are
present, a generic `400 Request validation failed`.

No machine-readable endpoint metadata exists (`/zen/v1/models` and
`/inference/v1/models` return bare ids; models.dev has no per-model
endpoint field), so the extension (>= 0.3.0):

- registers chat-native models with api `openai-completions` (provider
  api map; metadata resolves exact id → paid sibling → family rules →
  chat default for unknown frees),
- and self-heals mistakes: a 400 mismatch triggers a retry on the
  other endpoint with the body translated and the stream
  cross-translated (chat↔responses synthesizers); the learned protocol
  is cached per model for the session.

Chat-native deepseek models sometimes ignore the fence protocol and
emit their native **DSML tool markup** as text
(`<｜｜DSML｜｜ calls><｜｜DSML｜｜ invoke name="…">…`, observed live
2026-10-16). The fence pump recognizes and harvests DSML blocks like
fences — earliest marker wins, partial blocks held — so those turns
still execute as tool calls instead of ending in raw markup. Also note
ll-alpha-free answered 422 "Endpoint is unavailable" on chat (its
correct endpoint) on 2026-10-16: listed in the docs but not deployed —
an OpenCode-side gap, surfaced as-is. Two more live findings
(2026-10-17): mimo frees fall back to Qwen-style XML tool calls
(<tool_call><function=NAME><parameter=K>V</parameter></function>…
</tool_call>) instead of fences — harvested like DSML — and deepseek
occasionally drops the "name" from fence JSON, emitting bare bash
arguments ({"command": …}); a name-less fence whose object has a
"command" key is inferred as bash. space-bunny-free is thoroughly
marker-dominated (titles regardless of preamble, recency reminder,
role promotion, or ordering — all tried) and is effectively unusable
through the free gate; the wire shape now carries a fourth item
(developer recency reminder after the transcript, still within rule
10's two-further-items budget) which hardens milder cases. Later that day
`muse-spark-1.3-contributor-free` itself 410'd (no replacement hint;
the paid muse models remain) — leaving zero Responses-native frees —
and `beam-free` appeared. The extension therefore refreshes the live
catalog at every pi start (session_start → modelRegistry.refresh) and
serves the cached catalog only in the offline startup phase.

Watch for: `jev-*-free` (System One, excluded), deprecated ids (410
with a `replacement` hint), and models listed in docs before their
endpoint is live (ll-alpha-free answered 422 "Endpoint is unavailable"
on 2026-10-16).

## 8. Thinking levels / reasoning effort (probed 2026-10-15)

The `/responses` endpoint validates `reasoning.effort` per model and
rejects unsupported values with a 400 that names the allowed set —
for `muse-spark-1.3-contributor-free`:

```
reasoning_effort 'none' is not supported for model
'muse-spark-1.3-contributor'. Supported values:
[minimal, low, medium, high, xhigh, max]
```

So muse has no "off" (requests without a level should omit
`reasoning`, which pi does when the model's `thinkingLevelMap` has
`off: null`), and it does accept `minimal` and `max`. pi-ai's bundled
catalog hides `max` for muse; the extension's `models-meta.ts`
carries the corrected map plus an overrides table in
`scripts/generate-models-meta.mjs` documenting the probes.

Also verified on that sweep: chat-completions-native `-free` models
(`space-bunny-free`, `mimo-*`, `nemotron-*`, …) answer
`ModelProtocolUnsupported` on `/responses`, and `exo-free` is
`410 ModelDeprecated` — only the muse frees (and the paid
Responses-protocol catalog) are usable through this provider.
