@nofendian17/opencode-relay-plugin 2.0.1 → 2.0.5
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 +29 -10
- package/index.ts +75 -61
- package/package.json +2 -2
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. 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. 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). |
|
|
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,19 @@ access logs as secret-bearing.
|
|
|
79
89
|
|
|
80
90
|
## Verify
|
|
81
91
|
|
|
82
|
-
1. `
|
|
92
|
+
1. Set `debug: true` + `url` to a local echo server, run a 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 `/tmp/opencode-relay.log`).
|
|
85
96
|
3. Unset `url`/`RELAY_URL` → `[relay] disabled` log, traffic goes direct.
|
|
97
|
+
4. Confirm scope: a request to `https://opencode.ai/inference/...` must show
|
|
98
|
+
`via relay` in the log. If the log only shows
|
|
99
|
+
`direct fallback (all relays failed previously)` with an
|
|
100
|
+
`https://opencode.ai/...` URL and zero `via relay` lines, the request never
|
|
101
|
+
entered the pool — check the origin match in `index.ts`.
|
|
102
|
+
|
|
103
|
+
Note: the debug file is the shared `/tmp/opencode-relay.log`; concurrent
|
|
104
|
+
opencode processes append to the same file.
|
|
86
105
|
|
|
87
106
|
## V1 vs V2 notes
|
|
88
107
|
|
package/index.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { Plugin } from "@opencode/plugin"
|
|
2
2
|
import { appendFile } from "node:fs/promises"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
3
5
|
|
|
4
6
|
interface RelayOptions {
|
|
5
7
|
url?: string | string[]
|
|
6
8
|
debug?: boolean
|
|
7
9
|
}
|
|
8
10
|
|
|
9
|
-
const DEBUG_LOG = "
|
|
11
|
+
const DEBUG_LOG = join(tmpdir(), "opencode-relay.log")
|
|
10
12
|
|
|
11
|
-
const
|
|
13
|
+
const OPENCODE_ORIGIN = "https://opencode.ai"
|
|
12
14
|
|
|
13
15
|
type Logger = (message: string, extra?: Record<string, unknown>) => void
|
|
14
16
|
|
|
@@ -59,50 +61,78 @@ function withDuplex(init: RequestInit, bodyBuf: ArrayBuffer | undefined): Reques
|
|
|
59
61
|
return bodyBuf ? ({ ...init, duplex: "half" } as RequestInit) : init
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
interface RelayPoolResult {
|
|
65
|
+
response?: Response
|
|
66
|
+
lastError?: unknown
|
|
67
|
+
aborted: boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Try each relay in the pool starting from `start`, up to `count` attempts.
|
|
72
|
+
* Shared by both failover paths (session hooks + global fetch patch).
|
|
73
|
+
*/
|
|
74
|
+
async function tryRelayPool(
|
|
65
75
|
relays: readonly string[],
|
|
66
76
|
start: number,
|
|
67
|
-
|
|
77
|
+
count: number,
|
|
68
78
|
method: string,
|
|
69
|
-
|
|
79
|
+
baseHeaders: Headers,
|
|
80
|
+
orig: { origin: string; path: string },
|
|
70
81
|
bodyBuf: ArrayBuffer | undefined,
|
|
71
82
|
signal: AbortSignal | undefined,
|
|
72
83
|
directFetch: typeof fetch,
|
|
73
84
|
log: Logger,
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
let
|
|
77
|
-
|
|
78
|
-
for (let i = 1; i < relays.length; i++) {
|
|
79
|
-
if (signal?.aborted) return true
|
|
85
|
+
): Promise<RelayPoolResult> {
|
|
86
|
+
let lastError: unknown
|
|
87
|
+
for (let i = 0; i < count; i++) {
|
|
88
|
+
if (signal?.aborted) return { aborted: true }
|
|
80
89
|
const relay = relays[(start + i) % relays.length]
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
log(`retry ${method} ${orig.origin} via
|
|
90
|
+
const headers = new Headers(baseHeaders)
|
|
91
|
+
headers.set("x-relay-target", orig.origin)
|
|
92
|
+
headers.set("x-relay-path", orig.path)
|
|
93
|
+
log(`retry ${method} ${orig.origin} via relay`, { relay, attempt: i + 1 })
|
|
85
94
|
try {
|
|
86
|
-
const res = await directFetch(relay, withDuplex({ method, headers
|
|
87
|
-
if (!isRelayFailureStatus(res.status)) {
|
|
88
|
-
|
|
89
|
-
return false
|
|
90
|
-
}
|
|
91
|
-
lastStatus = res.status
|
|
95
|
+
const res = await directFetch(relay, withDuplex({ method, headers, body: bodyBuf, signal }, bodyBuf))
|
|
96
|
+
if (!isRelayFailureStatus(res.status)) return { response: res, aborted: false }
|
|
97
|
+
lastError = new Error(`relay responded ${res.status}`)
|
|
92
98
|
try {
|
|
93
99
|
await res.body?.cancel()
|
|
94
100
|
} catch {
|
|
95
101
|
// ignore
|
|
96
102
|
}
|
|
97
103
|
} catch (e) {
|
|
98
|
-
if (signal?.aborted) return true
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
if (signal?.aborted) return { aborted: true }
|
|
105
|
+
lastError = e
|
|
106
|
+
log("relay network error", { relay, error: String(e) })
|
|
101
107
|
}
|
|
102
108
|
}
|
|
109
|
+
return { lastError, aborted: false }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Returns true if the signal was aborted and the caller should return early.
|
|
113
|
+
async function failoverToDirect(
|
|
114
|
+
event: { response: Response },
|
|
115
|
+
relays: readonly string[],
|
|
116
|
+
start: number,
|
|
117
|
+
orig: { origin: string; path: string },
|
|
118
|
+
method: string,
|
|
119
|
+
headers: Headers,
|
|
120
|
+
bodyBuf: ArrayBuffer | undefined,
|
|
121
|
+
signal: AbortSignal | undefined,
|
|
122
|
+
directFetch: typeof fetch,
|
|
123
|
+
log: Logger,
|
|
124
|
+
emit: Logger,
|
|
125
|
+
): Promise<boolean> {
|
|
126
|
+
// Try the other relays in the pool (the first was already tried in http.request).
|
|
127
|
+
const result = await tryRelayPool(relays, start + 1, relays.length - 1, method, headers, orig, bodyBuf, signal, directFetch, log)
|
|
128
|
+
if (result.aborted) return true
|
|
129
|
+
if (result.response) {
|
|
130
|
+
event.response = result.response
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
103
133
|
|
|
104
134
|
// All relays failed -> direct fallback (v1 parity).
|
|
105
|
-
emit("relay fallback to direct", { target: orig.origin, path: orig.path, status:
|
|
135
|
+
emit("relay fallback to direct", { target: orig.origin, path: orig.path, status: event.response.status })
|
|
106
136
|
if (signal?.aborted) return true
|
|
107
137
|
try {
|
|
108
138
|
event.response = await directFetch(orig.origin + orig.path, withDuplex({ method, headers, body: bodyBuf, signal }, bodyBuf))
|
|
@@ -119,7 +149,7 @@ type RelayFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Resp
|
|
|
119
149
|
* `x-relay-target` / `x-relay-path` (original method preserved),
|
|
120
150
|
* round-robin across the pool, fail over on 429/5xx + network errors,
|
|
121
151
|
* fall back to direct.
|
|
122
|
-
* Used for out-of-session traffic (global fetch patch for
|
|
152
|
+
* Used for out-of-session traffic (global fetch patch for opencode.ai).
|
|
123
153
|
*/
|
|
124
154
|
function createRelayFetch(
|
|
125
155
|
relays: readonly string[],
|
|
@@ -154,35 +184,15 @@ function createRelayFetch(
|
|
|
154
184
|
}
|
|
155
185
|
}
|
|
156
186
|
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const headers = new Headers(originalHeaders)
|
|
162
|
-
headers.set("x-relay-target", url.origin)
|
|
163
|
-
headers.set("x-relay-path", relayPath(url))
|
|
164
|
-
if (debug) log(`${method} ${url.origin}${url.pathname}`, { relay, attempt: attempt + 1 })
|
|
165
|
-
|
|
166
|
-
try {
|
|
167
|
-
const res = await direct(relay, withDuplex({ method, headers, body: bodyBuf, signal }, bodyBuf))
|
|
168
|
-
if (!isRelayFailureStatus(res.status)) return res
|
|
169
|
-
lastError = new Error(`relay responded ${res.status}`)
|
|
170
|
-
// Consume/cancel relay error body so the socket can be reused.
|
|
171
|
-
try {
|
|
172
|
-
await res.body?.cancel()
|
|
173
|
-
} catch {
|
|
174
|
-
// ignore
|
|
175
|
-
}
|
|
176
|
-
} catch (e) {
|
|
177
|
-
if (signal.aborted) throw e
|
|
178
|
-
lastError = e
|
|
179
|
-
}
|
|
180
|
-
}
|
|
187
|
+
const orig = { origin: url.origin, path: relayPath(url) }
|
|
188
|
+
const result = await tryRelayPool(relays, pickIndex(), relays.length, method, originalHeaders, orig, bodyBuf, signal, direct, log)
|
|
189
|
+
if (result.aborted) throw new Error("The operation was aborted")
|
|
190
|
+
if (result.response) return result.response
|
|
181
191
|
|
|
182
192
|
emit("relay fallback to direct", {
|
|
183
193
|
target: url.origin,
|
|
184
194
|
path: url.pathname,
|
|
185
|
-
error: String(lastError),
|
|
195
|
+
error: String(result.lastError),
|
|
186
196
|
})
|
|
187
197
|
return direct(url.href, withDuplex({ method, headers: originalHeaders, body: bodyBuf, signal }, bodyBuf))
|
|
188
198
|
}
|
|
@@ -209,8 +219,11 @@ export default Plugin.define({
|
|
|
209
219
|
const debug = parsed.debug
|
|
210
220
|
const prefix = "[relay]"
|
|
211
221
|
// console output from v2 plugins is not forwarded to opencode logs,
|
|
212
|
-
// so when debug is on, mirror everything to a file
|
|
222
|
+
// so when debug is on, mirror everything to a file as well.
|
|
223
|
+
// RELAY_DEBUG_LOG overrides the path (tests use an isolated file so a
|
|
224
|
+
// live opencode process appending to the default never interferes).
|
|
213
225
|
// Async fire-and-forget: logging never breaks requests.
|
|
226
|
+
const debugLogFile = process.env.RELAY_DEBUG_LOG ?? DEBUG_LOG
|
|
214
227
|
let warnedLogFile = false
|
|
215
228
|
const fileLog: Logger | undefined = debug
|
|
216
229
|
? (message, extra) => {
|
|
@@ -218,10 +231,10 @@ export default Plugin.define({
|
|
|
218
231
|
`${new Date().toISOString()} ${prefix} ${message}` +
|
|
219
232
|
(extra ? ` ${JSON.stringify(extra)}` : "") +
|
|
220
233
|
"\n"
|
|
221
|
-
appendFile(
|
|
234
|
+
appendFile(debugLogFile, line).catch((e: unknown) => {
|
|
222
235
|
if (!warnedLogFile) {
|
|
223
236
|
warnedLogFile = true
|
|
224
|
-
console.log(prefix, "debug log write failed",
|
|
237
|
+
console.log(prefix, "debug log write failed", debugLogFile, String(e))
|
|
225
238
|
}
|
|
226
239
|
})
|
|
227
240
|
}
|
|
@@ -270,15 +283,15 @@ export default Plugin.define({
|
|
|
270
283
|
const relayOrigins = buildRelayOrigins(relays)
|
|
271
284
|
const relayFetch = createRelayFetch(relays, pickIndex, directFetch, debug, log, emit)
|
|
272
285
|
|
|
273
|
-
// --- Global fetch patch for out-of-session
|
|
286
|
+
// --- Global fetch patch for out-of-session opencode.ai traffic (v1 parity) ---
|
|
274
287
|
// Session model traffic is covered by hooks below; this covers anything
|
|
275
|
-
// else that calls fetch("https://opencode.ai
|
|
288
|
+
// else that calls fetch("https://opencode.ai/...") directly.
|
|
276
289
|
if (!g[PATCH_MARKER]) {
|
|
277
290
|
g[PATCH_MARKER] = originalForPatch
|
|
278
291
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
279
292
|
const urlStr =
|
|
280
293
|
typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
|
281
|
-
if (typeof urlStr === "string" && urlStr.startsWith(
|
|
294
|
+
if (typeof urlStr === "string" && urlStr.startsWith(OPENCODE_ORIGIN)) {
|
|
282
295
|
try {
|
|
283
296
|
if (!relayOrigins.has(new URL(urlStr).origin)) return relayFetch(input, init)
|
|
284
297
|
} catch {
|
|
@@ -337,9 +350,10 @@ export default Plugin.define({
|
|
|
337
350
|
// Avoid relaying the relay itself (loop).
|
|
338
351
|
if (relayOrigins.has(url.origin)) return
|
|
339
352
|
|
|
340
|
-
// Scope: only
|
|
353
|
+
// Scope: only opencode.ai endpoints go through the relay (zen,
|
|
354
|
+
// inference, and any future path on the same origin). Other
|
|
341
355
|
// providers already point at direct gateways and are left untouched.
|
|
342
|
-
if (
|
|
356
|
+
if (url.origin !== OPENCODE_ORIGIN) return
|
|
343
357
|
|
|
344
358
|
// Buffer body (streams are one-shot).
|
|
345
359
|
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.5",
|
|
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,7 +26,7 @@
|
|
|
26
26
|
"@opencode/plugin": "^2"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
|
-
"@opencode/plugin": "^2.0.
|
|
29
|
+
"@opencode/plugin": "^2.0.28",
|
|
30
30
|
"@types/node": "^26.6.4",
|
|
31
31
|
"typescript": "^5.9.3"
|
|
32
32
|
}
|