@nofendian17/opencode-relay-plugin 2.0.3 → 2.0.6
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/README.md +33 -10
- package/index.ts +62 -30
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
# @nofendian17/opencode-relay-plugin (v2)
|
|
2
2
|
|
|
3
|
-
Route OpenCode **v2**
|
|
4
|
-
|
|
5
|
-
(`@opencode-ai/plugin` → `@opencode/plugin`).
|
|
3
|
+
Route OpenCode **v2** opencode.ai traffic through your relay endpoint(s),
|
|
4
|
+
ported from the v1 `relay.js` (`@opencode-ai/plugin` → `@opencode/plugin`).
|
|
6
5
|
|
|
7
|
-
|
|
6
|
+
## Scope
|
|
7
|
+
|
|
8
|
+
Matched by **origin** (`https://opencode.ai`), not by path prefix. This covers
|
|
9
|
+
`/zen/*`, `/inference/*`, and any future path on the same origin. (Before
|
|
10
|
+
v2.0.4 the match was prefix-based on `/zen/*`, so `/inference/*` traffic
|
|
11
|
+
silently went direct — same rate-limit symptom, no relay in the log.)
|
|
12
|
+
|
|
13
|
+
Only opencode.ai requests are relayed; other providers already point at direct
|
|
8
14
|
gateways and are left untouched (no rewrite, no forced retries).
|
|
9
15
|
|
|
10
16
|
V1 patched `provider.options.fetch` + `globalThis.fetch`. V2 providers no longer expose `fetch`,
|
|
@@ -16,7 +22,7 @@ so this plugin uses the documented v2 interception points:
|
|
|
16
22
|
- `ctx.session.hook("retry")` — relay network errors never reach `http.response`, so force a
|
|
17
23
|
retry (next attempt picks the next relay); after the pool is exhausted the next attempt goes direct
|
|
18
24
|
- `ctx.session.hook("experimental.ws.handshake")` — no-op (relay is HTTP-only, kept direct)
|
|
19
|
-
- `globalThis.fetch` patch — only for out-of-session `https://opencode.ai
|
|
25
|
+
- `globalThis.fetch` patch — only for out-of-session `https://opencode.ai/*` traffic (v1 parity)
|
|
20
26
|
- `ctx.provider.transform` — re-enable the built-in `opencode` (Zen) provider if disabled
|
|
21
27
|
|
|
22
28
|
Reference: https://opencode.ai/v2/docs/plugins/ + migration guide
|
|
@@ -59,17 +65,21 @@ Local file (auto-discovered):
|
|
|
59
65
|
| Option | Env fallback | Description |
|
|
60
66
|
| --- | --- | --- |
|
|
61
67
|
| `url: string \| string[]` | `RELAY_URL` (comma-separated) | Relay endpoint(s), `http(s)` only. Invalid entries are skipped with a warning log; if none remain valid the plugin disables itself. |
|
|
62
|
-
| `debug: boolean` | `RELAY_DEBUG=1` | Verbose per-request logging.
|
|
68
|
+
| `debug: boolean` | `RELAY_DEBUG=1` | Verbose per-request logging. **Ini opsi plugin** (`plugins[].options.debug`), **bukan** top-level `debug` di `opencode.jsonc` — top-level `debug` tidak mengaktifkan log plugin ini. Baris penting (`init`, `disabled`, `fallback`) selalu ditulis ke file log; baris verbose per-request hanya saat `debug` on. Override path dengan `RELAY_DEBUG_LOG` (dipakai tests untuk isolasi). |
|
|
63
69
|
|
|
64
70
|
## Relay protocol (unchanged from v1)
|
|
65
71
|
|
|
66
72
|
- `<relay-url>` with original method/body/headers plus:
|
|
67
|
-
- `x-relay-target: https://
|
|
68
|
-
- `x-relay-path: /v1/
|
|
73
|
+
- `x-relay-target: https://opencode.ai` (origin)
|
|
74
|
+
- `x-relay-path: /inference/v1/chat/completions` (path + query)
|
|
69
75
|
- Relay returns the provider response verbatim (status/body/headers).
|
|
70
76
|
- `429` or `>= 500` → try next relay → fall back to direct.
|
|
71
77
|
- Network error → try next relay → fall back to direct.
|
|
72
78
|
|
|
79
|
+
Retry accounting: attempts `1..N` (pool size) each try the next relay in
|
|
80
|
+
round-robin order; attempt `N+1` goes direct once (per-session flag, consumed
|
|
81
|
+
by the next `http.request`), then the pool is used again.
|
|
82
|
+
|
|
73
83
|
## Security
|
|
74
84
|
|
|
75
85
|
The relay terminates TLS and sees everything it forwards: request bodies and
|
|
@@ -79,10 +89,23 @@ access logs as secret-bearing.
|
|
|
79
89
|
|
|
80
90
|
## Verify
|
|
81
91
|
|
|
82
|
-
1. `
|
|
92
|
+
1. Set plugin option `debug: true` + `url` ke local echo server, run prompt, confirm
|
|
83
93
|
`x-relay-target` / `x-relay-path` headers arrive.
|
|
84
|
-
2. Stop the relay → request still succeeds via direct fallback (check
|
|
94
|
+
2. Stop the relay → request still succeeds via direct fallback (check
|
|
95
|
+
`[relay] relay fallback to direct` in the log file — path is printed in the
|
|
96
|
+
`[relay] init` line as `logFile`).
|
|
85
97
|
3. Unset `url`/`RELAY_URL` → `[relay] disabled` log, traffic goes direct.
|
|
98
|
+
4. Confirm scope: a request to `https://opencode.ai/inference/...` must show
|
|
99
|
+
`via relay` in the log. If the log only shows
|
|
100
|
+
`direct fallback (all relays failed previously)` with an
|
|
101
|
+
`https://opencode.ai/...` URL and zero `via relay` lines, the request never
|
|
102
|
+
entered the pool — check the origin match in `index.ts`.
|
|
103
|
+
|
|
104
|
+
Note: the default file is `os.tmpdir()/opencode-relay.log` — on macOS that is
|
|
105
|
+
`$TMPDIR/opencode-relay.log` (e.g. `/var/folders/.../T/`), NOT `/tmp/`.
|
|
106
|
+
Check the `logFile` field in the `[relay] init` line for the exact path.
|
|
107
|
+
Concurrent opencode processes append to the same file. Plugin `console.log`
|
|
108
|
+
is not forwarded to opencode logs, so always check the file, not the TUI.
|
|
86
109
|
|
|
87
110
|
## V1 vs V2 notes
|
|
88
111
|
|
package/index.ts
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
import { Plugin } from "@opencode/plugin"
|
|
2
|
-
import {
|
|
2
|
+
import { appendFileSync } from "node:fs"
|
|
3
3
|
import { tmpdir } from "node:os"
|
|
4
4
|
import { join } from "node:path"
|
|
5
5
|
|
|
6
|
+
// Bun runtime: node:fs/promises appendFile fails silently. Use sync API.
|
|
7
|
+
function safeAppendFile(path: string, data: string): void {
|
|
8
|
+
try {
|
|
9
|
+
appendFileSync(path, data)
|
|
10
|
+
} catch {
|
|
11
|
+
// Fallback: try Bun.write if available
|
|
12
|
+
try {
|
|
13
|
+
// @ts-ignore - Bun global may not exist in Node
|
|
14
|
+
if (typeof Bun !== "undefined") {
|
|
15
|
+
// @ts-ignore
|
|
16
|
+
Bun.write(path, data, { mode: "append" })
|
|
17
|
+
}
|
|
18
|
+
} catch {
|
|
19
|
+
// All failed - ignore
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
6
24
|
interface RelayOptions {
|
|
7
25
|
url?: string | string[]
|
|
8
26
|
debug?: boolean
|
|
@@ -10,7 +28,7 @@ interface RelayOptions {
|
|
|
10
28
|
|
|
11
29
|
const DEBUG_LOG = join(tmpdir(), "opencode-relay.log")
|
|
12
30
|
|
|
13
|
-
const
|
|
31
|
+
const OPENCODE_ORIGIN = "https://opencode.ai"
|
|
14
32
|
|
|
15
33
|
type Logger = (message: string, extra?: Record<string, unknown>) => void
|
|
16
34
|
|
|
@@ -149,7 +167,7 @@ type RelayFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Resp
|
|
|
149
167
|
* `x-relay-target` / `x-relay-path` (original method preserved),
|
|
150
168
|
* round-robin across the pool, fail over on 429/5xx + network errors,
|
|
151
169
|
* fall back to direct.
|
|
152
|
-
* Used for out-of-session traffic (global fetch patch for
|
|
170
|
+
* Used for out-of-session traffic (global fetch patch for opencode.ai).
|
|
153
171
|
*/
|
|
154
172
|
function createRelayFetch(
|
|
155
173
|
relays: readonly string[],
|
|
@@ -198,15 +216,28 @@ function createRelayFetch(
|
|
|
198
216
|
}
|
|
199
217
|
}
|
|
200
218
|
|
|
219
|
+
function parseDebug(value: unknown): boolean | undefined {
|
|
220
|
+
if (value === undefined) return undefined
|
|
221
|
+
if (typeof value === "boolean") return value
|
|
222
|
+
if (typeof value === "number") return value !== 0
|
|
223
|
+
if (typeof value === "string") {
|
|
224
|
+
const v = value.trim().toLowerCase()
|
|
225
|
+
if (["1", "true", "yes", "on", "debug"].includes(v)) return true
|
|
226
|
+
if (["0", "false", "no", "off", ""].includes(v)) return false
|
|
227
|
+
return true
|
|
228
|
+
}
|
|
229
|
+
return Boolean(value)
|
|
230
|
+
}
|
|
231
|
+
|
|
201
232
|
function parseOptions(options: Record<string, unknown> | undefined): {
|
|
202
233
|
relays: string[]
|
|
203
234
|
debug: boolean
|
|
204
235
|
} {
|
|
205
236
|
const url = options?.["url"] as string | string[] | undefined
|
|
206
|
-
const
|
|
237
|
+
const envDebug = process.env.RELAY_DEBUG
|
|
207
238
|
return {
|
|
208
239
|
relays: normalizeRelays(url),
|
|
209
|
-
debug:
|
|
240
|
+
debug: parseDebug(options?.["debug"]) ?? parseDebug(envDebug) ?? false,
|
|
210
241
|
}
|
|
211
242
|
}
|
|
212
243
|
|
|
@@ -219,26 +250,26 @@ export default Plugin.define({
|
|
|
219
250
|
const debug = parsed.debug
|
|
220
251
|
const prefix = "[relay]"
|
|
221
252
|
// console output from v2 plugins is not forwarded to opencode logs,
|
|
222
|
-
// so
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
253
|
+
// so mirror important events to a file unconditionally and verbose
|
|
254
|
+
// per-request lines only when debug is on.
|
|
255
|
+
// NOTE: this is the plugin option `plugins[].options.debug`
|
|
256
|
+
// (or RELAY_DEBUG=1), NOT the top-level opencode `debug` flag.
|
|
257
|
+
// RELAY_DEBUG_LOG overrides the path (tests use an isolated file so a
|
|
258
|
+
// live opencode process appending to the default never interferes).
|
|
259
|
+
// Default path is os.tmpdir() — on macOS that is NOT /tmp but
|
|
260
|
+
// $TMPDIR (e.g. /var/folders/.../T/opencode-relay.log).
|
|
261
|
+
// Logging never breaks requests.
|
|
262
|
+
const logFile = process.env.RELAY_DEBUG_LOG ?? DEBUG_LOG
|
|
263
|
+
const fileLog: Logger = (message, extra) => {
|
|
264
|
+
const line =
|
|
265
|
+
`${new Date().toISOString()} ${prefix} ${message}` +
|
|
266
|
+
(extra ? ` ${JSON.stringify(extra)}` : "") +
|
|
267
|
+
"\n"
|
|
268
|
+
safeAppendFile(logFile, line)
|
|
269
|
+
}
|
|
239
270
|
const emit: Logger = (message, extra) => {
|
|
240
271
|
console.log(prefix, message, extra ? JSON.stringify(extra) : "")
|
|
241
|
-
fileLog
|
|
272
|
+
fileLog(message, extra)
|
|
242
273
|
}
|
|
243
274
|
const log: Logger = (message, extra) => {
|
|
244
275
|
if (debug) emit(message, extra)
|
|
@@ -259,14 +290,14 @@ export default Plugin.define({
|
|
|
259
290
|
}
|
|
260
291
|
}
|
|
261
292
|
|
|
262
|
-
emit("init", { debug, relayCount: relays.length })
|
|
293
|
+
emit("init", { debug, relayCount: relays.length, logFile })
|
|
263
294
|
|
|
264
295
|
if (relays.length === 0) {
|
|
265
296
|
emit("disabled: no relays configured (set plugin option `url` or RELAY_URL)", {})
|
|
266
297
|
return
|
|
267
298
|
}
|
|
268
299
|
|
|
269
|
-
log("relay pool loaded", { relays })
|
|
300
|
+
log("relay pool loaded", { relays, logFile })
|
|
270
301
|
|
|
271
302
|
const rawFetch = globalThis.fetch
|
|
272
303
|
const g = globalThis as Record<string, unknown>
|
|
@@ -280,15 +311,15 @@ export default Plugin.define({
|
|
|
280
311
|
const relayOrigins = buildRelayOrigins(relays)
|
|
281
312
|
const relayFetch = createRelayFetch(relays, pickIndex, directFetch, debug, log, emit)
|
|
282
313
|
|
|
283
|
-
// --- Global fetch patch for out-of-session
|
|
314
|
+
// --- Global fetch patch for out-of-session opencode.ai traffic (v1 parity) ---
|
|
284
315
|
// Session model traffic is covered by hooks below; this covers anything
|
|
285
|
-
// else that calls fetch("https://opencode.ai
|
|
316
|
+
// else that calls fetch("https://opencode.ai/...") directly.
|
|
286
317
|
if (!g[PATCH_MARKER]) {
|
|
287
318
|
g[PATCH_MARKER] = originalForPatch
|
|
288
319
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
289
320
|
const urlStr =
|
|
290
321
|
typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
|
291
|
-
if (typeof urlStr === "string" && urlStr.startsWith(
|
|
322
|
+
if (typeof urlStr === "string" && urlStr.startsWith(OPENCODE_ORIGIN)) {
|
|
292
323
|
try {
|
|
293
324
|
if (!relayOrigins.has(new URL(urlStr).origin)) return relayFetch(input, init)
|
|
294
325
|
} catch {
|
|
@@ -347,9 +378,10 @@ export default Plugin.define({
|
|
|
347
378
|
// Avoid relaying the relay itself (loop).
|
|
348
379
|
if (relayOrigins.has(url.origin)) return
|
|
349
380
|
|
|
350
|
-
// Scope: only
|
|
381
|
+
// Scope: only opencode.ai endpoints go through the relay (zen,
|
|
382
|
+
// inference, and any future path on the same origin). Other
|
|
351
383
|
// providers already point at direct gateways and are left untouched.
|
|
352
|
-
if (
|
|
384
|
+
if (url.origin !== OPENCODE_ORIGIN) return
|
|
353
385
|
|
|
354
386
|
// Buffer body (streams are one-shot).
|
|
355
387
|
let bodyBuf: ArrayBuffer | undefined
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nofendian17/opencode-relay-plugin",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.6",
|
|
4
4
|
"description": "Route OpenCode v2 Zen (opencode.ai) traffic through relay endpoint(s) with round-robin, failover, and direct fallback. Other providers are left untouched.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"@opencode/plugin": "^2"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
|
-
"@opencode/plugin": "^2.0.
|
|
30
|
-
"@types/node": "^26.6.
|
|
29
|
+
"@opencode/plugin": "^2.0.28",
|
|
30
|
+
"@types/node": "^26.6.5",
|
|
31
31
|
"typescript": "^5.9.3"
|
|
32
32
|
}
|
|
33
33
|
}
|