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 +21 -0
- package/README.md +236 -0
- package/cordis.patch.yml +5 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +414 -0
- package/package.json +44 -0
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.
|
package/cordis.patch.yml
ADDED
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
|
+
}
|