@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.
Files changed (3) hide show
  1. package/README.md +33 -10
  2. package/index.ts +62 -30
  3. 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** Zen traffic (`https://opencode.ai/zen/*`) through your
4
- relay endpoint(s), ported from the v1 `relay.js`
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
- Only Zen requests are relayed; other providers already point at direct
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/zen/*` traffic (v1 parity)
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. When on, log lines are also appended to `/tmp/opencode-relay.log` (plugin `console.log` is not forwarded to opencode logs). |
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://api.anthropic.com` (origin)
68
- - `x-relay-path: /v1/messages?foo=bar` (path + query)
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. `RELAY_DEBUG=1` + set `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
83
93
  `x-relay-target` / `x-relay-path` headers arrive.
84
- 2. Stop the relay → request still succeeds via direct fallback (check `[relay] fallback to direct` log).
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 { 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
@@ -10,7 +28,7 @@ interface RelayOptions {
10
28
 
11
29
  const DEBUG_LOG = join(tmpdir(), "opencode-relay.log")
12
30
 
13
- const ZEN_PREFIX = "https://opencode.ai/zen/"
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 Zen).
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 debugOpt = options?.["debug"] as boolean | undefined
237
+ const envDebug = process.env.RELAY_DEBUG
207
238
  return {
208
239
  relays: normalizeRelays(url),
209
- debug: debugOpt ?? process.env.RELAY_DEBUG === "1",
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 when debug is on, mirror everything to a file in /tmp as well.
223
- // Async fire-and-forget: logging never breaks requests.
224
- let warnedLogFile = false
225
- const fileLog: Logger | undefined = debug
226
- ? (message, extra) => {
227
- const line =
228
- `${new Date().toISOString()} ${prefix} ${message}` +
229
- (extra ? ` ${JSON.stringify(extra)}` : "") +
230
- "\n"
231
- appendFile(DEBUG_LOG, line).catch((e: unknown) => {
232
- if (!warnedLogFile) {
233
- warnedLogFile = true
234
- console.log(prefix, "debug log write failed", DEBUG_LOG, String(e))
235
- }
236
- })
237
- }
238
- : undefined
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?.(message, extra)
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 Zen traffic (v1 parity) ---
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/zen/...") directly.
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(ZEN_PREFIX)) {
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 the Zen endpoint goes through the relay. Other
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 (!url.href.startsWith(ZEN_PREFIX)) return
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",
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.24",
30
- "@types/node": "^26.6.4",
29
+ "@opencode/plugin": "^2.0.28",
30
+ "@types/node": "^26.6.5",
31
31
  "typescript": "^5.9.3"
32
32
  }
33
33
  }