@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.
Files changed (3) hide show
  1. package/README.md +10 -6
  2. package/index.ts +64 -34
  3. 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 → `POST <relay>` with
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. When on, log lines are also appended to `/tmp/opencode-relay.log` (plugin `console.log` is not forwarded to opencode logs). Override the path with `RELAY_DEBUG_LOG` (used by tests for isolation). |
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` to a local echo server, run a prompt, confirm
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 `/tmp/opencode-relay.log`).
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 debug file is the shared `/tmp/opencode-relay.log`; concurrent
104
- opencode processes append to the same file.
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 { appendFile } from "node:fs/promises"
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 debugOpt = options?.["debug"] as boolean | undefined
228
+ const envDebug = process.env.RELAY_DEBUG
207
229
  return {
208
230
  relays: normalizeRelays(url),
209
- debug: debugOpt ?? process.env.RELAY_DEBUG === "1",
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 when debug is on, mirror everything to a file as well.
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
- // Async fire-and-forget: logging never breaks requests.
226
- const debugLogFile = process.env.RELAY_DEBUG_LOG ?? DEBUG_LOG
227
- let warnedLogFile = false
228
- const fileLog: Logger | undefined = debug
229
- ? (message, extra) => {
230
- const line =
231
- `${new Date().toISOString()} ${prefix} ${message}` +
232
- (extra ? ` ${JSON.stringify(extra)}` : "") +
233
- "\n"
234
- appendFile(debugLogFile, line).catch((e: unknown) => {
235
- if (!warnedLogFile) {
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?.(message, extra)
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
- const pickIndex = createRoundRobin(relays.length)
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.5",
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.",
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.4",
30
+ "@types/node": "^26.6.5",
31
31
  "typescript": "^5.9.3"
32
32
  }
33
33
  }