dsh-opencode-free-tier 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,236 @@
1
+ # dsh-opencode-free-tier
2
+
3
+ Free OpenCode Zen models inside DeepSeek Harness (DSH) via the stock `llm-pi-ai` adapter — no API key, no extra provider plugin.
4
+
5
+ ## The problem
6
+
7
+ Since 2026-09-16, OpenCode Zen's anonymous free lane rejects every request that doesn't look like traffic from the OpenCode CLI:
8
+
9
+ ```
10
+ 403: {"type":"FreeTierError","message":"Error from provider (Console): OpenCode's free tier can only be used from within OpenCode"}
11
+ ```
12
+
13
+ Live-probed 2026-09-18, the gate has three parts — **all** must hold:
14
+
15
+ 1. `User-Agent` starts with `opencode/`
16
+ 2. `x-opencode-session` is `ses_` + 26 chars (12 lowercase hex + 14 Base62)
17
+ 3. the chat body streams (`stream: true`) with function tools named `bash` **and** `read`
18
+
19
+ DSH's `llm-pi-ai` `opencode` route sends none of the three (UA `deepseek-harness/...`,
20
+ the DSH session id, no tools on plain chats), so every free-model call fails — while
21
+ OpenCode CLI works keyless. No login and no key are required; the anonymous lane key
22
+ is the literal string `public`.
23
+
24
+ ## What this plugin does
25
+
26
+ A zero-dependency Cordis plugin that fixes all three at the fetch transport layer:
27
+
28
+ - **`llm/stream` waterfall observer** — carries `GenerateOptions.sessionId` in an
29
+ `AsyncLocalStorage` across each adapter stream, so the fetch layer knows which DSH
30
+ conversation a request belongs to (stable session → optimal upstream prompt-cache routing).
31
+ - **Fetch middleware, scoped strictly to `opencode.ai`** (+ subdomains) — every other
32
+ host passes through byte-for-byte untouched:
33
+ - missing/non-CLI `User-Agent` → `opencode/<cli-version> (platform arch; node...)`
34
+ - missing/malformed `x-opencode-session` → canonicalized (`ses_` + 26) from the DSH
35
+ conversation id; an already-canonical id passes through (cache affinity preserved)
36
+ - fills `x-opencode-client: cli`, `x-session-affinity`, `X-Session-Id`,
37
+ `x-opencode-request`, `x-opencode-project` when absent
38
+ - chat-completions bodies missing `bash`/`read` tools get the stubs appended
39
+ (`tool_choice: "none"` when the caller had no tools, so the model never calls them)
40
+
41
+ Already-correct requests pass through untouched (idempotent) — e.g. it coexists with
42
+ `opencode2dsh` instead of breaking it.
43
+
44
+ It supersedes `dsh-opencode-session-header`, which only stamped a **non-canonical**
45
+ session (no UA, no tools) and actively breaks canonical sessions by overwriting them.
46
+ Remove that plugin when installing this one.
47
+
48
+ ## Requirements
49
+
50
+ - DSH (`DeepSeek Harness`) with a `web` profile; Node.js ≥ 20 (already present if DSH runs)
51
+ - Outbound HTTPS to `opencode.ai`
52
+
53
+ ## Install
54
+
55
+ ### Option A — from npm (recommended)
56
+
57
+ ```sh
58
+ dsh plugin --profile web add dsh-opencode-free-tier
59
+ ```
60
+
61
+ then register the bundle in `package.json`:
62
+
63
+ ```json
64
+ {
65
+ "dsh": {
66
+ "profile": {
67
+ "bundles": [
68
+ "@deepseek-ai/dsh-base",
69
+ "@deepseek-ai/dsh-web-app",
70
+ "dsh-opencode-free-tier",
71
+ "dsh-file-upload"
72
+ ]
73
+ }
74
+ }
75
+ }
76
+ ```
77
+
78
+ ### Option B — from git
79
+
80
+ ```sh
81
+ cd ~/.dsh/profiles/web
82
+ pnpm add github:DOCUTEE/dsh-opencode-free-tier
83
+ ```
84
+
85
+ then register the bundle as above.
86
+
87
+ ### Option C — from a local clone
88
+
89
+ ```sh
90
+ git clone https://github.com/DOCUTEE/dsh-opencode-free-tier.git
91
+ cd ~/.dsh/profiles/web
92
+ pnpm add file:/path/to/dsh-opencode-free-tier
93
+ ```
94
+
95
+ then register the bundle as above.
96
+
97
+ Finally:
98
+
99
+ ```sh
100
+ cd ~/.dsh/profiles/web
101
+ pnpm install
102
+ ```
103
+
104
+ Remove `dsh-opencode-session-header` from `dependencies` + `bundles` if present,
105
+ then **restart `dsh web`** once — plugins load at boot.
106
+
107
+ ## Configure the free route
108
+
109
+ No API key needed. The anonymous lane key is the literal string `public`, but
110
+ `llm-pi-ai` still requires the route to *name* a credential — otherwise pi-ai
111
+ refuses the request before it is even sent (`Provider is not configured:
112
+ opencode`). So expose the anonymous key through `$DSH_HOME/.env`:
113
+
114
+ ```sh
115
+ # ~/.dsh/.env (DSH_HOME defaults to ~/.dsh)
116
+ OPENCODE_ANON_KEY=public
117
+ ```
118
+
119
+ In `~/.dsh/settings.yaml`:
120
+
121
+ ```yaml
122
+ llm-pi-ai:
123
+ providers:
124
+ opencode:
125
+ apiKeyEnv: OPENCODE_ANON_KEY
126
+ headers:
127
+ Authorization: Bearer public
128
+ ```
129
+
130
+ **Restart `dsh web`** after editing `.env` — the environment snapshot is taken
131
+ at launch, so a key added while DSH runs is invisible until restart.
132
+
133
+ Then pick any free model from the `opencode` route (e.g. `mimo-v2.5-free`,
134
+ `deepseek-v4-flash-free`, `ling-3.0-flash-fin-free`, `nemotron-3-ultra-free`).
135
+
136
+ ## Verify
137
+
138
+ ```sh
139
+ curl -s -X POST https://opencode.ai/zen/v1/chat/completions \
140
+ -H "Content-Type: application/json" \
141
+ -H "Authorization: Bearer public" \
142
+ -H "User-Agent: deepseek-harness/0.1.0 test" \
143
+ -d '{"model":"mimo-v2.5-free","messages":[{"role":"user","content":"hi"}],"stream":true,"max_completion_tokens":10}' \
144
+ --max-time 20 | head -c 300
145
+ ```
146
+
147
+ - Without the plugin: `FreeTierError`.
148
+ - With the plugin (restart DSH, chat with the model): normal streamed chunks.
149
+
150
+ Or run the plugin's own tests:
151
+
152
+ ```sh
153
+ npm test
154
+ ```
155
+
156
+ ## Runtime switch (no restart needed)
157
+
158
+ State file: `~/.dsh/plugins/dsh-opencode-free-tier.json` (defaults to `~/.dsh`,
159
+ or `$DSH_HOME` when set):
160
+
161
+ ```json
162
+ { "enabled": false }
163
+ ```
164
+
165
+ - `false` → everything passes through untouched
166
+ - `true` or **file missing** → fixing on
167
+ - re-read on every matching request
168
+
169
+ Plugin config in `cordis.patch.yml` also accepts `{ hosts?, enabled? }`.
170
+
171
+ ## How it works
172
+
173
+ ```
174
+ DSH llm-pi-ai (opencode route)
175
+ │ pi-ai openai-completions stream
176
+ ▼ global fetch
177
+ dsh-opencode-free-tier middleware (opencode.ai only)
178
+ │ UA → opencode/… · session → ses_+26 · tools → +bash/+read
179
+
180
+ https://opencode.ai/zen/… Authorization: Bearer public
181
+ ```
182
+
183
+ Session derivation mirrors the CLI: `SHA-256("ses\0" + DSH-session-id)` →
184
+ `ses_` + 6 bytes hex + 10 bytes Base62. The same conversation keeps a stable
185
+ session; different conversations separate (same scheme as `opencode2dsh`, so a
186
+ mixed setup shares cache affinity).
187
+
188
+ ## Testing
189
+
190
+ ```sh
191
+ node --test test/free-tier.test.mjs
192
+ ```
193
+
194
+ Covers: canonical session passthrough/hashing, CLI UA shape, tool injection +
195
+ idempotence, middleware fixing a DSH-like request while preserving an
196
+ already-correct one and leaving foreign hosts untouched.
197
+
198
+ ## Compatibility & retirement
199
+
200
+ - Verified against DSH `0.1.2-rc.1` and `@earendil-works/pi-ai` `0.85.x`.
201
+ - Depends on DSH outbound LLM traffic using the process-global `fetch`. If a future
202
+ DSH build changes its network stack, the plugin silently stops fixing — the symptom
203
+ is simply the `403` returning; uninstall then.
204
+ - If upstream DSH ever ships native CLI disguise for the free lane, retire this plugin:
205
+ remove it from `dependencies` + `bundles`, `pnpm install`, restart.
206
+
207
+ ## Troubleshooting
208
+
209
+ | Symptom | Cause & fix |
210
+ | --- | --- |
211
+ | `Provider is not configured: opencode` | The route names no credential, so pi-ai rejects before sending. Add `OPENCODE_ANON_KEY=public` to `~/.dsh/.env` and `apiKeyEnv: OPENCODE_ANON_KEY` to the route (see Configure), then **restart** `dsh web`. |
212
+ | `403 FreeTierError: free tier can only be used from within OpenCode` | The disguise isn't applied: plugin not installed/enabled, DSH not restarted after install, or the runtime switch disables it. Check `~/.dsh/plugins/dsh-opencode-free-tier.json` is absent or `{"enabled": true}`. |
213
+ | `400 MissingSessionID` | An old `dsh-opencode-session-header` is overwriting the canonical session — remove that plugin. |
214
+ | Key added to `.env` but still `MISSING_CREDENTIAL` | `.env` is snapshotted at launch — restart `dsh web`. |
215
+
216
+ ## License
217
+
218
+ [MIT](./LICENSE)
219
+
220
+ ## Release process (maintainers)
221
+
222
+ Publishing uses [npm trusted publishing (OIDC)](https://docs.npmjs.com/trusted-publishers) —
223
+ no tokens, no OTP. One-time setup on npmjs.com → package → Settings →
224
+ Trusted Publisher: GitHub Actions, user `DOCUTEE`, repository
225
+ `dsh-opencode-free-tier`, workflow `publish.yml`, allowed action `npm publish`.
226
+
227
+ To release:
228
+
229
+ ```sh
230
+ # 1. bump version in package.json (must match the tag below)
231
+ # 2. commit, then:
232
+ git tag v0.1.0 && git push origin v0.1.0
233
+ ```
234
+
235
+ Pushing the tag runs `.github/workflows/publish.yml`, which runs tests and
236
+ `npm publish`es. Provenance is generated automatically.
@@ -0,0 +1,5 @@
1
+ # dsh-opencode-session-header bundle patch: mount the session-header plugin
2
+ # under its package name, resolved through the profile's node_modules.
3
+ - insert:
4
+ - id: dsh-opencode-free-tier
5
+ name: dsh-opencode-free-tier
package/lib/index.d.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './index.js'
package/lib/index.js ADDED
@@ -0,0 +1,414 @@
1
+ /**
2
+ * dsh-opencode-free-tier — make every DSH request to OpenCode Zen look like
3
+ * OpenCode CLI so the anonymous free lane passes its gate.
4
+ *
5
+ * Live-probed 2026-09-18 (see README): Zen free tier returns
6
+ * 403 FreeTierError unless ALL three hold:
7
+ * 1. `User-Agent` starts with `opencode/`
8
+ * 2. `x-opencode-session` is `ses_` + 26 chars (12 lowercase hex + 14 Base62)
9
+ * 3. chat body streams with function tools named `bash` AND `read`
10
+ *
11
+ * DSH's `llm-pi-ai` opencode route sends none of the three (UA is
12
+ * `deepseek-harness/...`, session is the DSH id, plain chats carry no tools),
13
+ * so every free-model call fails. The `opencode2dsh` adapter already spoofs
14
+ * all three — this plugin fixes the generic `llm-pi-ai` path at the fetch
15
+ * transport layer, and leaves already-correct requests (opencode2dsh)
16
+ * byte-for-byte untouched (idempotent).
17
+ *
18
+ * Scope: `opencode.ai` (+ subdomains) only. Every other host passes through.
19
+ */
20
+
21
+ import { AsyncLocalStorage } from 'node:async_hooks'
22
+ import { readFileSync } from 'node:fs'
23
+ import { homedir } from 'node:os'
24
+ import { join } from 'node:path'
25
+ import { createHash, randomBytes } from 'node:crypto'
26
+
27
+ /** Cordis plugin name (the Loader entry). */
28
+ export const name = 'dsh-opencode-free-tier'
29
+
30
+ /** Services required before load: none — llm/stream is an optional observer. */
31
+ export const inject = []
32
+
33
+ /** Carries the DSH session id across one llm/stream call. */
34
+ export const requestSessionContext = new AsyncLocalStorage()
35
+
36
+ const HEADER_SESSION = 'x-opencode-session'
37
+ const DEFAULT_HOSTS = ['opencode.ai']
38
+
39
+ /** Canonical CLI session shape: `ses_` + 12 hex + 14 Base62 (= 26). */
40
+ export const CANONICAL_SESSION_PATTERN = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/
41
+ const BASE62_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
42
+
43
+ function base62Fixed(value, width) {
44
+ const base = 62n
45
+ let n = value
46
+ const out = new Array(width)
47
+ for (let i = width - 1; i >= 0; i--) {
48
+ out[i] = BASE62_ALPHABET.charAt(Number(n % base))
49
+ n /= base
50
+ }
51
+ return out.join('')
52
+ }
53
+
54
+ /**
55
+ * Canonicalize any signal into the CLI session shape. An already-canonical
56
+ * id passes through unchanged (preserves upstream prompt-cache affinity);
57
+ * anything else (DSH session id, fallback, probe) is deterministically hashed.
58
+ */
59
+ export function canonicalSessionID(signal) {
60
+ if (typeof signal === 'string' && CANONICAL_SESSION_PATTERN.test(signal)) return signal
61
+ const sum = createHash('sha256').update('ses\0' + String(signal)).digest()
62
+ return `ses_${sum.subarray(0, 6).toString('hex')}${base62Fixed(BigInt('0x' + sum.subarray(6, 16).toString('hex')), 14)}`
63
+ }
64
+
65
+ function randomID(prefix, size) {
66
+ return `${prefix}_${randomBytes(size).toString('hex')}`
67
+ }
68
+
69
+ function stableID(prefix, value) {
70
+ return `${prefix}_${createHash('sha256').update(prefix + '\0' + value).digest().subarray(0, 12).toString('hex')}`
71
+ }
72
+
73
+ /** Stable default project id (same derivation as opencode2dsh). */
74
+ function defaultProjectID() {
75
+ return stableID('prj', 'opencode2dsh:default-project')
76
+ }
77
+
78
+ /** CLI-identical user agent. */
79
+ export function opencodeUserAgent() {
80
+ return `opencode/1.18.31 (${process.platform} ${process.arch}; node${process.versions.node})`
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // fetch pipeline (own symbol so coexisting plugins never clobber each other)
85
+ // ---------------------------------------------------------------------------
86
+
87
+ const FETCH_PIPELINE_KEY = Symbol.for('dsh-opencode-free-tier.fetch.pipeline.v1')
88
+
89
+ function ensureFetchPipeline() {
90
+ const g = globalThis
91
+ if (g[FETCH_PIPELINE_KEY]) return g[FETCH_PIPELINE_KEY]
92
+ let underlyingFetch = globalThis.fetch
93
+ const state = {
94
+ getUnderlyingFetch: () => underlyingFetch,
95
+ setUnderlyingFetch: (nextFetch) => {
96
+ underlyingFetch = nextFetch
97
+ },
98
+ middlewares: [],
99
+ installed: false,
100
+ patchedFetch: undefined,
101
+ }
102
+ g[FETCH_PIPELINE_KEY] = state
103
+ return state
104
+ }
105
+
106
+ function compose(state) {
107
+ const ordered = [...state.middlewares].sort((a, b) => a.priority - b.priority)
108
+ const callAt = (index, input, init) => {
109
+ if (index >= ordered.length) return state.getUnderlyingFetch()(input, init)
110
+ const current = ordered[index]
111
+ return current.middleware({
112
+ input,
113
+ init,
114
+ next: (nextInput, nextInit) => callAt(index + 1, nextInput, nextInit),
115
+ })
116
+ }
117
+ return (input, init) => callAt(0, input, init)
118
+ }
119
+
120
+ function installFetchPipeline() {
121
+ const state = ensureFetchPipeline()
122
+ if (state.installed) {
123
+ state.patchedFetch = compose(state)
124
+ return
125
+ }
126
+ const prevDesc = Object.getOwnPropertyDescriptor(globalThis, 'fetch')
127
+ state.patchedFetch = compose(state)
128
+ Object.defineProperty(globalThis, 'fetch', {
129
+ configurable: true,
130
+ enumerable: prevDesc?.enumerable ?? true,
131
+ get() {
132
+ return state.patchedFetch
133
+ },
134
+ set(newFetch) {
135
+ if (newFetch === state.patchedFetch) return
136
+ try {
137
+ prevDesc?.set?.call(globalThis, newFetch)
138
+ } catch {}
139
+ state.setUnderlyingFetch(newFetch)
140
+ state.patchedFetch = compose(state)
141
+ },
142
+ })
143
+ state.installed = true
144
+ }
145
+
146
+ export function registerFetchMiddleware(registration) {
147
+ const state = ensureFetchPipeline()
148
+ const existingIndex = state.middlewares.findIndex((m) => m.name === registration.name)
149
+ if (existingIndex >= 0) state.middlewares.splice(existingIndex, 1, registration)
150
+ else state.middlewares.push(registration)
151
+ installFetchPipeline()
152
+ }
153
+
154
+ export function unregisterFetchMiddleware(mwName) {
155
+ const state = ensureFetchPipeline()
156
+ const index = state.middlewares.findIndex((m) => m.name === mwName)
157
+ if (index >= 0) state.middlewares.splice(index, 1)
158
+ if (state.installed) state.patchedFetch = compose(state)
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // request helpers
163
+ // ---------------------------------------------------------------------------
164
+
165
+ function requestUrlOf(input) {
166
+ try {
167
+ if (typeof input === 'string') return new URL(input)
168
+ if (input && typeof input === 'object' && typeof input.url === 'string') return new URL(input.url)
169
+ } catch {}
170
+ return undefined
171
+ }
172
+
173
+ export function hostMatches(hostname, hosts) {
174
+ const h = String(hostname ?? '').toLowerCase()
175
+ return hosts.some((entry) => {
176
+ const target = String(entry).toLowerCase().trim()
177
+ if (!target) return false
178
+ return h === target || h.endsWith(`.${target}`)
179
+ })
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // free-lane body shape (port of opencode2dsh ensureFreeLaneShape)
184
+ // ---------------------------------------------------------------------------
185
+
186
+ const FREE_LANE_GATE_TOOL_NAMES = ['bash', 'read']
187
+
188
+ function freeLaneGateTool(toolName) {
189
+ return {
190
+ type: 'function',
191
+ function: {
192
+ name: toolName,
193
+ description: 'Reserved for the host runtime; do not call it.',
194
+ parameters: {
195
+ type: 'object',
196
+ properties: {},
197
+ },
198
+ },
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Rewrite an outgoing chat-completions payload so it satisfies the free-lane
204
+ * agent-shape gate. Appends only the gate tools the payload is missing; when
205
+ * the context carried no tools at all, `tool_choice: 'none'` keeps the model
206
+ * from ever calling the injected stubs. Returns the new body object, or
207
+ * `undefined` when the payload already satisfies the gate or is not a
208
+ * chat-completions body (caller keeps the original in that case).
209
+ */
210
+ export function ensureFreeLaneShape(payload) {
211
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return undefined
212
+ const body = payload
213
+ if (!Array.isArray(body.messages)) return undefined
214
+ const tools = Array.isArray(body.tools) ? body.tools : []
215
+ const names = new Set(
216
+ tools.map((tool) => {
217
+ const fn = typeof tool === 'object' && tool !== null ? tool.function : undefined
218
+ return typeof fn === 'object' && fn !== null ? fn.name : undefined
219
+ }),
220
+ )
221
+ const missing = FREE_LANE_GATE_TOOL_NAMES.filter((toolName) => !names.has(toolName))
222
+ if (missing.length === 0) return undefined
223
+ const next = { ...body }
224
+ next.tools = [...tools, ...missing.map((toolName) => freeLaneGateTool(toolName))]
225
+ if (tools.length === 0) next.tool_choice = 'none'
226
+ // The gate also has a streaming half; pi-ai always streams, but enforce it
227
+ // for any other client that reaches this layer without it.
228
+ if (next.stream !== true) next.stream = true
229
+ return next
230
+ }
231
+
232
+ async function readBodyText(input, init) {
233
+ if (init && init.body !== undefined) {
234
+ const b = init.body
235
+ if (typeof b === 'string') return b
236
+ // URLSearchParams / FormData / Blob / ArrayBuffer etc: not JSON chat bodies.
237
+ return undefined
238
+ }
239
+ try {
240
+ if (input && typeof input === 'object' && typeof input.clone === 'function' && typeof input.text === 'function') {
241
+ const method = String(input.method ?? 'GET').toUpperCase()
242
+ if (method === 'GET' || method === 'HEAD') return undefined
243
+ const ct = input.headers?.get?.('content-type') ?? ''
244
+ if (ct && !ct.includes('json')) return undefined
245
+ return await input.clone().text()
246
+ }
247
+ } catch {}
248
+ return undefined
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // middleware + llm/stream listener factories (exported for tests)
253
+ // ---------------------------------------------------------------------------
254
+
255
+ /**
256
+ * Build the fetch middleware.
257
+ * Options:
258
+ * - hosts: allowlist (default opencode.ai)
259
+ * - isEnabled: () => boolean, consulted per matching request
260
+ */
261
+ export function createFreeTierMiddleware(options) {
262
+ const hosts = options?.hosts?.length ? options.hosts : DEFAULT_HOSTS
263
+ const isEnabled = options?.isEnabled || (() => true)
264
+ return async function freeTierMiddleware({ input, init, next }) {
265
+ const url = requestUrlOf(input)
266
+ if (!url || !hostMatches(url.hostname, hosts)) return next(input, init)
267
+ if (!isEnabled()) return next(input, init)
268
+
269
+ // Merge whichever header source would actually reach the wire (fetch spec:
270
+ // init.headers replaces Request headers when present).
271
+ const source =
272
+ init && init.headers !== undefined
273
+ ? init.headers
274
+ : input && typeof input === 'object' && input.headers
275
+ ? input.headers
276
+ : undefined
277
+ const headers = new Headers(source ?? undefined)
278
+
279
+ // 1. User-Agent must start with opencode/
280
+ const ua = headers.get('user-agent')
281
+ if (!ua || !ua.toLowerCase().startsWith('opencode/')) {
282
+ headers.set('user-agent', opencodeUserAgent())
283
+ }
284
+
285
+ // 2. x-opencode-session must be canonical ses_+26; preserve a correct one
286
+ // (opencode2dsh already sets a canonical id — keep it for cache affinity).
287
+ const existing = headers.get(HEADER_SESSION)
288
+ let session
289
+ if (existing && CANONICAL_SESSION_PATTERN.test(existing)) {
290
+ session = existing
291
+ } else {
292
+ const store = requestSessionContext.getStore()
293
+ const signal =
294
+ typeof store === 'string' && store.length > 0 ? store : randomID('fallback', 16)
295
+ session = canonicalSessionID(signal)
296
+ headers.set(HEADER_SESSION, session)
297
+ }
298
+ if (!headers.get('x-opencode-client')) headers.set('x-opencode-client', 'cli')
299
+ if (!headers.get('x-session-affinity')) headers.set('x-session-affinity', session)
300
+ if (!headers.get('x-session-id') && !headers.get('X-Session-Id')) headers.set('X-Session-Id', session)
301
+ if (!headers.get('x-opencode-request')) headers.set('x-opencode-request', randomID('req', 16))
302
+ if (!headers.get('x-opencode-project')) headers.set('x-opencode-project', defaultProjectID())
303
+
304
+ // 3. Body must carry bash+read tools (chat-completions shape only).
305
+ let newBodyText
306
+ try {
307
+ const bodyText = await readBodyText(input, init)
308
+ if (typeof bodyText === 'string' && bodyText.length > 0) {
309
+ let parsed
310
+ try {
311
+ parsed = JSON.parse(bodyText)
312
+ } catch {
313
+ parsed = undefined
314
+ }
315
+ const fixed = parsed === undefined ? undefined : ensureFreeLaneShape(parsed)
316
+ if (fixed !== undefined) newBodyText = JSON.stringify(fixed)
317
+ }
318
+ } catch {}
319
+
320
+ if (newBodyText === undefined) {
321
+ return next(input, { ...init, headers })
322
+ }
323
+ // init.body wins over Request body per fetch spec, so stamping the fixed
324
+ // string there covers both string-URL and Request-object call shapes.
325
+ return next(input, { ...init, headers, body: newBodyText })
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Build the `llm/stream` waterfall listener: re-emits the downstream stream
331
+ * with every iterator step executed inside AsyncLocalStorage carrying the
332
+ * call's sessionId, so fetches issued while iterating inherit the context.
333
+ */
334
+ export function createLlmStreamListener(sessionContext) {
335
+ return function llmStreamObserver(llmOptions, next) {
336
+ const sessionKey = String(llmOptions?.sessionId ?? '')
337
+ const inner = next()
338
+ const iterator = inner[Symbol.asyncIterator]()
339
+ const doneResult = () => ({ done: true, value: undefined })
340
+ const runNext = () => sessionContext.run(sessionKey, () => iterator.next())
341
+ const runReturn = () =>
342
+ sessionContext.run(sessionKey, () => (iterator.return ? iterator.return() : Promise.resolve(doneResult())))
343
+ const runThrow = (err) =>
344
+ sessionContext.run(sessionKey, () => (iterator.throw ? iterator.throw(err) : Promise.resolve(doneResult())))
345
+ return {
346
+ [Symbol.asyncIterator]() {
347
+ return {
348
+ next: () => runNext(),
349
+ return: () => runReturn(),
350
+ throw: (err) => runThrow(err),
351
+ }
352
+ },
353
+ }
354
+ }
355
+ }
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // runtime switch (re-read per request; no restart needed)
359
+ // ---------------------------------------------------------------------------
360
+
361
+ export function switchFilePath() {
362
+ const envHome = process.env.DSH_HOME?.trim()
363
+ const home = envHome ? envHome : join(homedir(), '.dsh')
364
+ return join(home, 'plugins', 'dsh-opencode-free-tier.json')
365
+ }
366
+
367
+ /** Missing/unreadable file or non-boolean `enabled` → seed (default true). */
368
+ export function readEnabledSwitch(seed = true) {
369
+ try {
370
+ const parsed = JSON.parse(readFileSync(switchFilePath(), 'utf8'))
371
+ if (parsed && typeof parsed.enabled === 'boolean') return parsed.enabled
372
+ } catch {}
373
+ return seed
374
+ }
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // cordis plugin surface
378
+ // ---------------------------------------------------------------------------
379
+
380
+ /**
381
+ * Mount the plugin.
382
+ * @param ctx - host cordis context.
383
+ * @param config - optional deployment config: { hosts?, enabled? }.
384
+ */
385
+ export function apply(ctx, config) {
386
+ const cfg = config ?? {}
387
+ const hosts = Array.isArray(cfg.hosts) && cfg.hosts.length ? cfg.hosts.map(String) : DEFAULT_HOSTS
388
+ const seedEnabled = typeof cfg.enabled === 'boolean' ? cfg.enabled : true
389
+ const isEnabled = () => readEnabledSwitch(seedEnabled)
390
+
391
+ const log = (level, message) => {
392
+ try {
393
+ ctx.logger?.[level]?.(message)
394
+ } catch {}
395
+ }
396
+
397
+ ctx.effect(() => {
398
+ registerFetchMiddleware({
399
+ name: 'dsh-opencode-free-tier-inject',
400
+ priority: 6,
401
+ middleware: createFreeTierMiddleware({ hosts, isEnabled }),
402
+ })
403
+ return () => {
404
+ unregisterFetchMiddleware('dsh-opencode-free-tier-inject')
405
+ }
406
+ }, 'dsh-opencode-free-tier: fetch middleware')
407
+
408
+ ctx.on('llm/stream', createLlmStreamListener(requestSessionContext))
409
+
410
+ log('info', `[dsh-opencode-free-tier] loaded: hosts=${hosts.join(', ')} ua=${opencodeUserAgent()}`)
411
+ log('info', `[dsh-opencode-free-tier] runtime switch: ${switchFilePath()} ({"enabled":false} disables; missing file = enabled)`)
412
+ }
413
+
414
+ export default apply
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "dsh-opencode-free-tier",
3
+ "version": "0.1.0",
4
+ "description": "Make DSH llm-pi-ai opencode routes pass Zen free-tier gate: opencode User-Agent + canonical ses_ session + bash/read tools. Scoped to opencode.ai only.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "license": "MIT",
9
+ "author": "DOCUTEE",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/DOCUTEE/dsh-opencode-free-tier.git"
13
+ },
14
+ "homepage": "https://github.com/DOCUTEE/dsh-opencode-free-tier#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/DOCUTEE/dsh-opencode-free-tier/issues"
17
+ },
18
+ "keywords": [
19
+ "deepseek-harness",
20
+ "dsh",
21
+ "dsh-plugin",
22
+ "opencode",
23
+ "opencode-zen",
24
+ "free-tier",
25
+ "x-opencode-session"
26
+ ],
27
+ "files": [
28
+ "lib",
29
+ "cordis.patch.yml",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "scripts": {
37
+ "test": "node --test test/*.test.mjs"
38
+ },
39
+ "dsh": {
40
+ "bundle": {
41
+ "patch": "./cordis.patch.yml"
42
+ }
43
+ }
44
+ }