@nofendian17/opencode-relay-plugin 2.0.5 → 2.0.7
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 +10 -6
- package/index.ts +64 -34
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ gateways and are left untouched (no rewrite, no forced retries).
|
|
|
16
16
|
V1 patched `provider.options.fetch` + `globalThis.fetch`. V2 providers no longer expose `fetch`,
|
|
17
17
|
so this plugin uses the documented v2 interception points:
|
|
18
18
|
|
|
19
|
-
- `ctx.session.hook("http.request")` — rewrite provider HTTP →
|
|
19
|
+
- `ctx.session.hook("http.request")` — rewrite provider HTTP → `<relay>` (original method/body/headers preserved) with
|
|
20
20
|
`x-relay-target: <origin>` + `x-relay-path: <path+query>` (round-robin)
|
|
21
21
|
- `ctx.session.hook("http.response")` — on relay `429 / 5xx`, try remaining relays, then direct
|
|
22
22
|
- `ctx.session.hook("retry")` — relay network errors never reach `http.response`, so force a
|
|
@@ -65,7 +65,7 @@ Local file (auto-discovered):
|
|
|
65
65
|
| Option | Env fallback | Description |
|
|
66
66
|
| --- | --- | --- |
|
|
67
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. |
|
|
68
|
-
| `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). |
|
|
69
69
|
|
|
70
70
|
## Relay protocol (unchanged from v1)
|
|
71
71
|
|
|
@@ -89,10 +89,11 @@ access logs as secret-bearing.
|
|
|
89
89
|
|
|
90
90
|
## Verify
|
|
91
91
|
|
|
92
|
-
1. Set `debug: true` + `url`
|
|
92
|
+
1. Set plugin option `debug: true` + `url` ke local echo server, run prompt, confirm
|
|
93
93
|
`x-relay-target` / `x-relay-path` headers arrive.
|
|
94
94
|
2. Stop the relay → request still succeeds via direct fallback (check
|
|
95
|
-
`[relay] relay fallback to direct` in
|
|
95
|
+
`[relay] relay fallback to direct` in the log file — path is printed in the
|
|
96
|
+
`[relay] init` line as `logFile`).
|
|
96
97
|
3. Unset `url`/`RELAY_URL` → `[relay] disabled` log, traffic goes direct.
|
|
97
98
|
4. Confirm scope: a request to `https://opencode.ai/inference/...` must show
|
|
98
99
|
`via relay` in the log. If the log only shows
|
|
@@ -100,8 +101,11 @@ access logs as secret-bearing.
|
|
|
100
101
|
`https://opencode.ai/...` URL and zero `via relay` lines, the request never
|
|
101
102
|
entered the pool — check the origin match in `index.ts`.
|
|
102
103
|
|
|
103
|
-
Note: the
|
|
104
|
-
opencode
|
|
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.
|
|
105
109
|
|
|
106
110
|
## V1 vs V2 notes
|
|
107
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
|
|
@@ -32,15 +50,6 @@ function normalizeRelays(raw: string | string[] | undefined): string[] {
|
|
|
32
50
|
return [...seen]
|
|
33
51
|
}
|
|
34
52
|
|
|
35
|
-
function createRoundRobin(count: number): () => number {
|
|
36
|
-
let idx = 0
|
|
37
|
-
return () => {
|
|
38
|
-
const cur = idx
|
|
39
|
-
idx = (idx + 1) % count
|
|
40
|
-
return cur
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
53
|
function buildRelayOrigins(relays: readonly string[]): ReadonlySet<string> {
|
|
45
54
|
const origins = new Set<string>()
|
|
46
55
|
for (const relay of relays) {
|
|
@@ -198,19 +207,33 @@ function createRelayFetch(
|
|
|
198
207
|
}
|
|
199
208
|
}
|
|
200
209
|
|
|
210
|
+
function parseDebug(value: unknown): boolean | undefined {
|
|
211
|
+
if (value === undefined) return undefined
|
|
212
|
+
if (typeof value === "boolean") return value
|
|
213
|
+
if (typeof value === "number") return value !== 0
|
|
214
|
+
if (typeof value === "string") {
|
|
215
|
+
const v = value.trim().toLowerCase()
|
|
216
|
+
if (["1", "true", "yes", "on", "debug"].includes(v)) return true
|
|
217
|
+
if (["0", "false", "no", "off", ""].includes(v)) return false
|
|
218
|
+
return true
|
|
219
|
+
}
|
|
220
|
+
return Boolean(value)
|
|
221
|
+
}
|
|
222
|
+
|
|
201
223
|
function parseOptions(options: Record<string, unknown> | undefined): {
|
|
202
224
|
relays: string[]
|
|
203
225
|
debug: boolean
|
|
204
226
|
} {
|
|
205
227
|
const url = options?.["url"] as string | string[] | undefined
|
|
206
|
-
const
|
|
228
|
+
const envDebug = process.env.RELAY_DEBUG
|
|
207
229
|
return {
|
|
208
230
|
relays: normalizeRelays(url),
|
|
209
|
-
debug:
|
|
231
|
+
debug: parseDebug(options?.["debug"]) ?? parseDebug(envDebug) ?? false,
|
|
210
232
|
}
|
|
211
233
|
}
|
|
212
234
|
|
|
213
235
|
const PATCH_MARKER = "__opencodeRelayZenPatched"
|
|
236
|
+
const ROUND_ROBIN_KEY = "__opencodeRelayRoundRobinIdx"
|
|
214
237
|
|
|
215
238
|
export default Plugin.define({
|
|
216
239
|
id: "relay",
|
|
@@ -219,29 +242,26 @@ export default Plugin.define({
|
|
|
219
242
|
const debug = parsed.debug
|
|
220
243
|
const prefix = "[relay]"
|
|
221
244
|
// console output from v2 plugins is not forwarded to opencode logs,
|
|
222
|
-
// so
|
|
245
|
+
// so mirror important events to a file unconditionally and verbose
|
|
246
|
+
// per-request lines only when debug is on.
|
|
247
|
+
// NOTE: this is the plugin option `plugins[].options.debug`
|
|
248
|
+
// (or RELAY_DEBUG=1), NOT the top-level opencode `debug` flag.
|
|
223
249
|
// RELAY_DEBUG_LOG overrides the path (tests use an isolated file so a
|
|
224
250
|
// live opencode process appending to the default never interferes).
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
warnedLogFile = true
|
|
237
|
-
console.log(prefix, "debug log write failed", debugLogFile, String(e))
|
|
238
|
-
}
|
|
239
|
-
})
|
|
240
|
-
}
|
|
241
|
-
: undefined
|
|
251
|
+
// Default path is os.tmpdir() — on macOS that is NOT /tmp but
|
|
252
|
+
// $TMPDIR (e.g. /var/folders/.../T/opencode-relay.log).
|
|
253
|
+
// Logging never breaks requests.
|
|
254
|
+
const logFile = process.env.RELAY_DEBUG_LOG ?? DEBUG_LOG
|
|
255
|
+
const fileLog: Logger = (message, extra) => {
|
|
256
|
+
const line =
|
|
257
|
+
`${new Date().toISOString()} ${prefix} ${message}` +
|
|
258
|
+
(extra ? ` ${JSON.stringify(extra)}` : "") +
|
|
259
|
+
"\n"
|
|
260
|
+
safeAppendFile(logFile, line)
|
|
261
|
+
}
|
|
242
262
|
const emit: Logger = (message, extra) => {
|
|
243
263
|
console.log(prefix, message, extra ? JSON.stringify(extra) : "")
|
|
244
|
-
fileLog
|
|
264
|
+
fileLog(message, extra)
|
|
245
265
|
}
|
|
246
266
|
const log: Logger = (message, extra) => {
|
|
247
267
|
if (debug) emit(message, extra)
|
|
@@ -262,14 +282,14 @@ export default Plugin.define({
|
|
|
262
282
|
}
|
|
263
283
|
}
|
|
264
284
|
|
|
265
|
-
emit("init", { debug, relayCount: relays.length })
|
|
285
|
+
emit("init", { debug, relayCount: relays.length, logFile })
|
|
266
286
|
|
|
267
287
|
if (relays.length === 0) {
|
|
268
288
|
emit("disabled: no relays configured (set plugin option `url` or RELAY_URL)", {})
|
|
269
289
|
return
|
|
270
290
|
}
|
|
271
291
|
|
|
272
|
-
log("relay pool loaded", { relays })
|
|
292
|
+
log("relay pool loaded", { relays, logFile })
|
|
273
293
|
|
|
274
294
|
const rawFetch = globalThis.fetch
|
|
275
295
|
const g = globalThis as Record<string, unknown>
|
|
@@ -279,7 +299,15 @@ export default Plugin.define({
|
|
|
279
299
|
// Bind the pristine fetch, not a possibly already-patched one from a
|
|
280
300
|
// prior setup without cleanup (avoids wrapping the patch recursively).
|
|
281
301
|
const directFetch = originalForPatch.bind(globalThis)
|
|
282
|
-
|
|
302
|
+
// Round-robin index is global (module/process-wide) so that setups created
|
|
303
|
+
// per session/conversation still rotate through the pool instead of every
|
|
304
|
+
// one starting at relay 0. Stored next to the patch marker.
|
|
305
|
+
if (g[ROUND_ROBIN_KEY] === undefined) g[ROUND_ROBIN_KEY] = 0
|
|
306
|
+
const pickIndex = () => {
|
|
307
|
+
const cur = (g[ROUND_ROBIN_KEY] as number) ?? 0
|
|
308
|
+
g[ROUND_ROBIN_KEY] = cur + 1
|
|
309
|
+
return cur % relays.length
|
|
310
|
+
}
|
|
283
311
|
const relayOrigins = buildRelayOrigins(relays)
|
|
284
312
|
const relayFetch = createRelayFetch(relays, pickIndex, directFetch, debug, log, emit)
|
|
285
313
|
|
|
@@ -288,6 +316,7 @@ export default Plugin.define({
|
|
|
288
316
|
// else that calls fetch("https://opencode.ai/...") directly.
|
|
289
317
|
if (!g[PATCH_MARKER]) {
|
|
290
318
|
g[PATCH_MARKER] = originalForPatch
|
|
319
|
+
g[ROUND_ROBIN_KEY] = 0
|
|
291
320
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
292
321
|
const urlStr =
|
|
293
322
|
typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
|
@@ -487,6 +516,7 @@ export default Plugin.define({
|
|
|
487
516
|
// ignore
|
|
488
517
|
}
|
|
489
518
|
delete g[PATCH_MARKER]
|
|
519
|
+
delete g[ROUND_ROBIN_KEY]
|
|
490
520
|
}
|
|
491
521
|
},
|
|
492
522
|
})
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nofendian17/opencode-relay-plugin",
|
|
3
|
-
"version": "2.0.
|
|
4
|
-
"description": "Route OpenCode v2
|
|
3
|
+
"version": "2.0.7",
|
|
4
|
+
"description": "Route OpenCode v2 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",
|
|
7
7
|
"exports": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@opencode/plugin": "^2.0.28",
|
|
30
|
-
"@types/node": "^26.6.
|
|
30
|
+
"@types/node": "^26.6.5",
|
|
31
31
|
"typescript": "^5.9.3"
|
|
32
32
|
}
|
|
33
33
|
}
|