@nofendian17/opencode-relay-plugin 0.0.0-stage → 2.0.1
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 +95 -2
- package/index.ts +478 -0
- package/package.json +31 -4
package/README.md
CHANGED
|
@@ -1,3 +1,96 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @nofendian17/opencode-relay-plugin (v2)
|
|
2
2
|
|
|
3
|
-
|
|
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`).
|
|
6
|
+
|
|
7
|
+
Only Zen requests are relayed; other providers already point at direct
|
|
8
|
+
gateways and are left untouched (no rewrite, no forced retries).
|
|
9
|
+
|
|
10
|
+
V1 patched `provider.options.fetch` + `globalThis.fetch`. V2 providers no longer expose `fetch`,
|
|
11
|
+
so this plugin uses the documented v2 interception points:
|
|
12
|
+
|
|
13
|
+
- `ctx.session.hook("http.request")` — rewrite provider HTTP → `POST <relay>` with
|
|
14
|
+
`x-relay-target: <origin>` + `x-relay-path: <path+query>` (round-robin)
|
|
15
|
+
- `ctx.session.hook("http.response")` — on relay `429 / 5xx`, try remaining relays, then direct
|
|
16
|
+
- `ctx.session.hook("retry")` — relay network errors never reach `http.response`, so force a
|
|
17
|
+
retry (next attempt picks the next relay); after the pool is exhausted the next attempt goes direct
|
|
18
|
+
- `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)
|
|
20
|
+
- `ctx.provider.transform` — re-enable the built-in `opencode` (Zen) provider if disabled
|
|
21
|
+
|
|
22
|
+
Reference: https://opencode.ai/v2/docs/plugins/ + migration guide
|
|
23
|
+
`https://opencode.ai/v2/docs/build/plugins/migrate-v1/`.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
# inside your project
|
|
29
|
+
npm i @nofendian17/opencode-relay-plugin
|
|
30
|
+
# or local path
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Configure
|
|
34
|
+
|
|
35
|
+
`opencode.jsonc`:
|
|
36
|
+
|
|
37
|
+
```jsonc
|
|
38
|
+
{
|
|
39
|
+
"$schema": "https://opencode.ai/config.json",
|
|
40
|
+
"plugins": [
|
|
41
|
+
{
|
|
42
|
+
"package": "@nofendian17/opencode-relay-plugin",
|
|
43
|
+
"options": {
|
|
44
|
+
"url": "https://relay.example.com/relay",
|
|
45
|
+
// "url": ["https://r1.example.com", "https://r2.example.com"],
|
|
46
|
+
// "debug": true,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Local file (auto-discovered):
|
|
54
|
+
|
|
55
|
+
```text
|
|
56
|
+
.opencode/plugins/relay/index.ts -> copy index.ts here
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
| Option | Env fallback | Description |
|
|
60
|
+
| --- | --- | --- |
|
|
61
|
+
| `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). |
|
|
63
|
+
|
|
64
|
+
## Relay protocol (unchanged from v1)
|
|
65
|
+
|
|
66
|
+
- `<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)
|
|
69
|
+
- Relay returns the provider response verbatim (status/body/headers).
|
|
70
|
+
- `429` or `>= 500` → try next relay → fall back to direct.
|
|
71
|
+
- Network error → try next relay → fall back to direct.
|
|
72
|
+
|
|
73
|
+
## Security
|
|
74
|
+
|
|
75
|
+
The relay terminates TLS and sees everything it forwards: request bodies and
|
|
76
|
+
all headers, including provider credentials (`Authorization`, `x-api-key`, …).
|
|
77
|
+
Only use relays you operate or fully trust, always over HTTPS, and treat relay
|
|
78
|
+
access logs as secret-bearing.
|
|
79
|
+
|
|
80
|
+
## Verify
|
|
81
|
+
|
|
82
|
+
1. `RELAY_DEBUG=1` + set `url` to a local echo server, run a prompt, confirm
|
|
83
|
+
`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).
|
|
85
|
+
3. Unset `url`/`RELAY_URL` → `[relay] disabled` log, traffic goes direct.
|
|
86
|
+
|
|
87
|
+
## V1 vs V2 notes
|
|
88
|
+
|
|
89
|
+
| V1 (`@opencode-ai/plugin`) | V2 (`@opencode/plugin`) |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| `export default async (ctx, opts) => ({ config })` | `Plugin.define({ id: "relay", setup(ctx) })` |
|
|
92
|
+
| `config.provider[].options.fetch = relayFetch` | `session.hook("http.request"/"http.response")` |
|
|
93
|
+
| `config.provider["opencode"] = { baseURL: ZEN }` | built-in provider + `provider.transform` (enable-only) |
|
|
94
|
+
| `ctx.client.app.log(...)` | `console.log("[relay]", ...)` (no log API in v2 ctx) |
|
|
95
|
+
| plugin options argument | `ctx.options` |
|
|
96
|
+
| returned `{ config }` | registrations disposed on unload + `setup` cleanup restores `fetch` |
|
package/index.ts
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin"
|
|
2
|
+
import { appendFile } from "node:fs/promises"
|
|
3
|
+
|
|
4
|
+
interface RelayOptions {
|
|
5
|
+
url?: string | string[]
|
|
6
|
+
debug?: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const DEBUG_LOG = "/tmp/opencode-relay.log"
|
|
10
|
+
|
|
11
|
+
const ZEN_PREFIX = "https://opencode.ai/zen/"
|
|
12
|
+
|
|
13
|
+
type Logger = (message: string, extra?: Record<string, unknown>) => void
|
|
14
|
+
|
|
15
|
+
function relayPath(url: URL): string {
|
|
16
|
+
const path = url.pathname + url.search
|
|
17
|
+
return path === "" ? "/" : path
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeRelays(raw: string | string[] | undefined): string[] {
|
|
21
|
+
const list = raw ?? process.env.RELAY_URL?.split(",") ?? []
|
|
22
|
+
const arr = Array.isArray(list) ? list : [list]
|
|
23
|
+
const seen = new Set<string>()
|
|
24
|
+
for (const item of arr) {
|
|
25
|
+
if (typeof item !== "string") continue
|
|
26
|
+
const trimmed = item.trim().replace(/\/+$/, "")
|
|
27
|
+
if (!trimmed || seen.has(trimmed)) continue
|
|
28
|
+
seen.add(trimmed)
|
|
29
|
+
}
|
|
30
|
+
return [...seen]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createRoundRobin(count: number): () => number {
|
|
34
|
+
let idx = 0
|
|
35
|
+
return () => {
|
|
36
|
+
const cur = idx
|
|
37
|
+
idx = (idx + 1) % count
|
|
38
|
+
return cur
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildRelayOrigins(relays: readonly string[]): ReadonlySet<string> {
|
|
43
|
+
const origins = new Set<string>()
|
|
44
|
+
for (const relay of relays) {
|
|
45
|
+
try {
|
|
46
|
+
origins.add(new URL(relay).origin)
|
|
47
|
+
} catch {
|
|
48
|
+
origins.add(relay)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return origins
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isRelayFailureStatus(status: number): boolean {
|
|
55
|
+
return status === 429 || status >= 500
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function withDuplex(init: RequestInit, bodyBuf: ArrayBuffer | undefined): RequestInit {
|
|
59
|
+
return bodyBuf ? ({ ...init, duplex: "half" } as RequestInit) : init
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Returns true if the signal was aborted and the caller should return early.
|
|
63
|
+
async function failoverToDirect(
|
|
64
|
+
event: { response: Response },
|
|
65
|
+
relays: readonly string[],
|
|
66
|
+
start: number,
|
|
67
|
+
orig: { origin: string; path: string },
|
|
68
|
+
method: string,
|
|
69
|
+
headers: Headers,
|
|
70
|
+
bodyBuf: ArrayBuffer | undefined,
|
|
71
|
+
signal: AbortSignal | undefined,
|
|
72
|
+
directFetch: typeof fetch,
|
|
73
|
+
log: Logger,
|
|
74
|
+
emit: Logger,
|
|
75
|
+
): Promise<boolean> {
|
|
76
|
+
let lastStatus = event.response.status
|
|
77
|
+
// Try the other relays in the pool (the first was already tried in http.request).
|
|
78
|
+
for (let i = 1; i < relays.length; i++) {
|
|
79
|
+
if (signal?.aborted) return true
|
|
80
|
+
const relay = relays[(start + i) % relays.length]
|
|
81
|
+
const h = new Headers(headers)
|
|
82
|
+
h.set("x-relay-target", orig.origin)
|
|
83
|
+
h.set("x-relay-path", orig.path)
|
|
84
|
+
log(`retry ${method} ${orig.origin} via next relay`, { relay, prevStatus: lastStatus })
|
|
85
|
+
try {
|
|
86
|
+
const res = await directFetch(relay, withDuplex({ method, headers: h, body: bodyBuf, signal }, bodyBuf))
|
|
87
|
+
if (!isRelayFailureStatus(res.status)) {
|
|
88
|
+
event.response = res
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
lastStatus = res.status
|
|
92
|
+
try {
|
|
93
|
+
await res.body?.cancel()
|
|
94
|
+
} catch {
|
|
95
|
+
// ignore
|
|
96
|
+
}
|
|
97
|
+
} catch (e) {
|
|
98
|
+
if (signal?.aborted) return true
|
|
99
|
+
log("relay retry network error", { relay, error: String(e) })
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// All relays failed -> direct fallback (v1 parity).
|
|
105
|
+
emit("relay fallback to direct", { target: orig.origin, path: orig.path, status: lastStatus })
|
|
106
|
+
if (signal?.aborted) return true
|
|
107
|
+
try {
|
|
108
|
+
event.response = await directFetch(orig.origin + orig.path, withDuplex({ method, headers, body: bodyBuf, signal }, bodyBuf))
|
|
109
|
+
} catch (e) {
|
|
110
|
+
log("direct fallback failed, keeping relay response", { error: String(e) })
|
|
111
|
+
}
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type RelayFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Direct port of the v1 relay fetch: forward to the relay endpoint with
|
|
119
|
+
* `x-relay-target` / `x-relay-path` (original method preserved),
|
|
120
|
+
* round-robin across the pool, fail over on 429/5xx + network errors,
|
|
121
|
+
* fall back to direct.
|
|
122
|
+
* Used for out-of-session traffic (global fetch patch for Zen).
|
|
123
|
+
*/
|
|
124
|
+
function createRelayFetch(
|
|
125
|
+
relays: readonly string[],
|
|
126
|
+
pickIndex: () => number,
|
|
127
|
+
direct: typeof fetch,
|
|
128
|
+
debug: boolean,
|
|
129
|
+
log: Logger,
|
|
130
|
+
emit: Logger,
|
|
131
|
+
): RelayFetch {
|
|
132
|
+
return async function (input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
133
|
+
const req = input instanceof Request && !init ? input : new Request(input, init)
|
|
134
|
+
let url: URL
|
|
135
|
+
try {
|
|
136
|
+
url = new URL(req.url)
|
|
137
|
+
} catch {
|
|
138
|
+
return direct(input, init)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const originalHeaders = new Headers(req.headers)
|
|
142
|
+
const method = req.method
|
|
143
|
+
const signal = req.signal
|
|
144
|
+
|
|
145
|
+
// Buffer the body once so relay retries + direct fallback can reuse it
|
|
146
|
+
// (streams can only be consumed once).
|
|
147
|
+
let bodyBuf: ArrayBuffer | undefined
|
|
148
|
+
if (req.body) {
|
|
149
|
+
try {
|
|
150
|
+
bodyBuf = await req.arrayBuffer()
|
|
151
|
+
} catch (e) {
|
|
152
|
+
if (debug) log(`relay body buffer failed, direct fallback`, { error: String(e) })
|
|
153
|
+
return direct(input, init)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const start = pickIndex()
|
|
158
|
+
let lastError: unknown
|
|
159
|
+
for (let attempt = 0; attempt < relays.length; attempt++) {
|
|
160
|
+
const relay = relays[(start + attempt) % relays.length]
|
|
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
|
+
}
|
|
181
|
+
|
|
182
|
+
emit("relay fallback to direct", {
|
|
183
|
+
target: url.origin,
|
|
184
|
+
path: url.pathname,
|
|
185
|
+
error: String(lastError),
|
|
186
|
+
})
|
|
187
|
+
return direct(url.href, withDuplex({ method, headers: originalHeaders, body: bodyBuf, signal }, bodyBuf))
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function parseOptions(options: Record<string, unknown> | undefined): {
|
|
192
|
+
relays: string[]
|
|
193
|
+
debug: boolean
|
|
194
|
+
} {
|
|
195
|
+
const url = options?.["url"] as string | string[] | undefined
|
|
196
|
+
const debugOpt = options?.["debug"] as boolean | undefined
|
|
197
|
+
return {
|
|
198
|
+
relays: normalizeRelays(url),
|
|
199
|
+
debug: debugOpt ?? process.env.RELAY_DEBUG === "1",
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const PATCH_MARKER = "__opencodeRelayZenPatched"
|
|
204
|
+
|
|
205
|
+
export default Plugin.define({
|
|
206
|
+
id: "relay",
|
|
207
|
+
async setup(ctx) {
|
|
208
|
+
const parsed = parseOptions(ctx.options as Record<string, unknown> | undefined)
|
|
209
|
+
const debug = parsed.debug
|
|
210
|
+
const prefix = "[relay]"
|
|
211
|
+
// console output from v2 plugins is not forwarded to opencode logs,
|
|
212
|
+
// so when debug is on, mirror everything to a file in /tmp as well.
|
|
213
|
+
// Async fire-and-forget: logging never breaks requests.
|
|
214
|
+
let warnedLogFile = false
|
|
215
|
+
const fileLog: Logger | undefined = debug
|
|
216
|
+
? (message, extra) => {
|
|
217
|
+
const line =
|
|
218
|
+
`${new Date().toISOString()} ${prefix} ${message}` +
|
|
219
|
+
(extra ? ` ${JSON.stringify(extra)}` : "") +
|
|
220
|
+
"\n"
|
|
221
|
+
appendFile(DEBUG_LOG, line).catch((e: unknown) => {
|
|
222
|
+
if (!warnedLogFile) {
|
|
223
|
+
warnedLogFile = true
|
|
224
|
+
console.log(prefix, "debug log write failed", DEBUG_LOG, String(e))
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
: undefined
|
|
229
|
+
const emit: Logger = (message, extra) => {
|
|
230
|
+
console.log(prefix, message, extra ? JSON.stringify(extra) : "")
|
|
231
|
+
fileLog?.(message, extra)
|
|
232
|
+
}
|
|
233
|
+
const log: Logger = (message, extra) => {
|
|
234
|
+
if (debug) emit(message, extra)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Validate early: an invalid relay URL would otherwise throw inside the
|
|
238
|
+
// http.request hook and fail every model request with no fallback.
|
|
239
|
+
const relays: string[] = []
|
|
240
|
+
for (const candidate of parsed.relays) {
|
|
241
|
+
try {
|
|
242
|
+
const url = new URL(candidate)
|
|
243
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
244
|
+
throw new Error(`unsupported protocol ${url.protocol}`)
|
|
245
|
+
}
|
|
246
|
+
relays.push(candidate)
|
|
247
|
+
} catch (e) {
|
|
248
|
+
emit("ignoring invalid relay url", { url: candidate, error: String(e) })
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
emit("init", { debug, relayCount: relays.length })
|
|
253
|
+
|
|
254
|
+
if (relays.length === 0) {
|
|
255
|
+
emit("disabled: no relays configured (set plugin option `url` or RELAY_URL)", {})
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
log("relay pool loaded", { relays })
|
|
260
|
+
|
|
261
|
+
const rawFetch = globalThis.fetch
|
|
262
|
+
const g = globalThis as Record<string, unknown>
|
|
263
|
+
const previousFetch: typeof fetch | undefined =
|
|
264
|
+
typeof g[PATCH_MARKER] === "function" ? (g[PATCH_MARKER] as typeof fetch) : undefined
|
|
265
|
+
const originalForPatch = previousFetch ?? rawFetch
|
|
266
|
+
// Bind the pristine fetch, not a possibly already-patched one from a
|
|
267
|
+
// prior setup without cleanup (avoids wrapping the patch recursively).
|
|
268
|
+
const directFetch = originalForPatch.bind(globalThis)
|
|
269
|
+
const pickIndex = createRoundRobin(relays.length)
|
|
270
|
+
const relayOrigins = buildRelayOrigins(relays)
|
|
271
|
+
const relayFetch = createRelayFetch(relays, pickIndex, directFetch, debug, log, emit)
|
|
272
|
+
|
|
273
|
+
// --- Global fetch patch for out-of-session Zen traffic (v1 parity) ---
|
|
274
|
+
// Session model traffic is covered by hooks below; this covers anything
|
|
275
|
+
// else that calls fetch("https://opencode.ai/zen/...") directly.
|
|
276
|
+
if (!g[PATCH_MARKER]) {
|
|
277
|
+
g[PATCH_MARKER] = originalForPatch
|
|
278
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
279
|
+
const urlStr =
|
|
280
|
+
typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
|
281
|
+
if (typeof urlStr === "string" && urlStr.startsWith(ZEN_PREFIX)) {
|
|
282
|
+
try {
|
|
283
|
+
if (!relayOrigins.has(new URL(urlStr).origin)) return relayFetch(input, init)
|
|
284
|
+
} catch {
|
|
285
|
+
// fall through to direct fetch
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return (originalForPatch as typeof fetch)(input as Parameters<typeof fetch>[0], init)
|
|
289
|
+
}) as typeof fetch
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Shared state between hooks.
|
|
293
|
+
const pendingBodies = new WeakMap<Request, { body?: ArrayBuffer; start: number }>()
|
|
294
|
+
// Sessions whose next attempt must go direct (relay pool exhausted).
|
|
295
|
+
// Keyed per session so concurrent sessions don't steal each other's flag.
|
|
296
|
+
// Entries are consumed by the next http.request of that session; a stale
|
|
297
|
+
// entry only causes a single direct request and is then removed.
|
|
298
|
+
const forceDirectSessions = new Set<string>()
|
|
299
|
+
const disposables: Array<{ dispose?: () => unknown | Promise<unknown> }> = []
|
|
300
|
+
// Backoff for forced retries: grow with the attempt count so a 429 from
|
|
301
|
+
// the whole pool doesn't hammer the relays on immediate retry.
|
|
302
|
+
const retryDelay = (attempt: number) => Math.min(2000, 250 * attempt)
|
|
303
|
+
|
|
304
|
+
function isRelayedRequest(req: Request): boolean {
|
|
305
|
+
return req.headers.has("x-relay-target") || relayOrigins.has(safeOrigin(req.url))
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function safeOrigin(href: string): string {
|
|
309
|
+
try {
|
|
310
|
+
return new URL(href).origin
|
|
311
|
+
} catch {
|
|
312
|
+
return href
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function originalFromRelayed(req: Request): { origin: string; path: string } | undefined {
|
|
317
|
+
const target = req.headers.get("x-relay-target")
|
|
318
|
+
const path = req.headers.get("x-relay-path")
|
|
319
|
+
if (target && path) return { origin: target, path }
|
|
320
|
+
return undefined
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// --- Rewrite every session HTTP request to go via a relay ---
|
|
324
|
+
disposables.push(
|
|
325
|
+
await ctx.session.hook("http.request", async (event) => {
|
|
326
|
+
if (forceDirectSessions.has(event.sessionID)) {
|
|
327
|
+
forceDirectSessions.delete(event.sessionID)
|
|
328
|
+
log("direct fallback (all relays failed previously)", { url: event.request.url })
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
let url: URL
|
|
332
|
+
try {
|
|
333
|
+
url = new URL(event.request.url)
|
|
334
|
+
} catch {
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
// Avoid relaying the relay itself (loop).
|
|
338
|
+
if (relayOrigins.has(url.origin)) return
|
|
339
|
+
|
|
340
|
+
// Scope: only the Zen endpoint goes through the relay. Other
|
|
341
|
+
// providers already point at direct gateways and are left untouched.
|
|
342
|
+
if (!url.href.startsWith(ZEN_PREFIX)) return
|
|
343
|
+
|
|
344
|
+
// Buffer body (streams are one-shot).
|
|
345
|
+
let bodyBuf: ArrayBuffer | undefined
|
|
346
|
+
try {
|
|
347
|
+
if (event.request.body) bodyBuf = await event.request.arrayBuffer()
|
|
348
|
+
} catch (e) {
|
|
349
|
+
log("body buffer failed, sending direct", { error: String(e), url: url.href })
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const start = pickIndex()
|
|
354
|
+
const relay = relays[start]
|
|
355
|
+
const headers = new Headers(event.request.headers)
|
|
356
|
+
headers.set("x-relay-target", url.origin)
|
|
357
|
+
headers.set("x-relay-path", relayPath(url))
|
|
358
|
+
log(`${event.request.method} ${url.origin}${url.pathname}`, { relay })
|
|
359
|
+
|
|
360
|
+
const rewritten = new Request(relay, withDuplex({ method: event.request.method, headers, body: bodyBuf }, bodyBuf))
|
|
361
|
+
pendingBodies.set(rewritten, { body: bodyBuf, start })
|
|
362
|
+
event.request = rewritten
|
|
363
|
+
}),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
// --- Failover: if the relay answered 429/5xx, try remaining relays + direct ---
|
|
367
|
+
// Mirrors the v1 for-loop, but executed here because v2 hooks edit the
|
|
368
|
+
// Request/Response instead of wrapping fetch.
|
|
369
|
+
disposables.push(
|
|
370
|
+
await ctx.session.hook("http.response", async (event) => {
|
|
371
|
+
if (!isRelayedRequest(event.request)) return
|
|
372
|
+
if (!isRelayFailureStatus(event.response.status)) return
|
|
373
|
+
|
|
374
|
+
const orig = originalFromRelayed(event.request)
|
|
375
|
+
if (!orig) return
|
|
376
|
+
const headers = new Headers(event.request.headers)
|
|
377
|
+
headers.delete("x-relay-target")
|
|
378
|
+
headers.delete("x-relay-path")
|
|
379
|
+
const method = event.request.method
|
|
380
|
+
const signal = event.request.signal
|
|
381
|
+
const pending = pendingBodies.get(event.request)
|
|
382
|
+
let bodyBuf = pending?.body
|
|
383
|
+
// Deterministic order: continue from the relay tried in http.request,
|
|
384
|
+
// so each pool member is tried exactly once even under concurrency.
|
|
385
|
+
const start = pending?.start ?? 0
|
|
386
|
+
if (!pending) {
|
|
387
|
+
// Another plugin rewrote the request after us: the buffered body is
|
|
388
|
+
// gone, try reading the (possibly already-consumed) stream.
|
|
389
|
+
if (event.request.body) {
|
|
390
|
+
try {
|
|
391
|
+
bodyBuf = await event.request.arrayBuffer()
|
|
392
|
+
} catch {
|
|
393
|
+
bodyBuf = undefined
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
log("relay context missing, failover may resend without body", { method })
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
await event.response.body?.cancel()
|
|
400
|
+
} catch {
|
|
401
|
+
// ignore
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (await failoverToDirect(event, relays, start, orig, method, headers, bodyBuf, signal, directFetch, log, emit)) return
|
|
405
|
+
}),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
// --- Retry: relay network errors never produce http.response, so force a retry ---
|
|
409
|
+
// The next attempt's http.request picks the next relay (round-robin).
|
|
410
|
+
// When attempts are exhausted, flag the next attempt to go direct.
|
|
411
|
+
disposables.push(
|
|
412
|
+
await ctx.session.hook("retry", (event) => {
|
|
413
|
+
// Only Zen requests are relayed; leave other providers' retry policy alone.
|
|
414
|
+
if (event.model.providerID !== "opencode") return
|
|
415
|
+
const status = event.error.status
|
|
416
|
+
const retryable = status === undefined || status === 429 || status >= 500
|
|
417
|
+
if (!retryable) return
|
|
418
|
+
if (event.attempt <= relays.length) {
|
|
419
|
+
if (!event.decision.retry) {
|
|
420
|
+
log("forcing retry for relay failover", { attempt: event.attempt, status })
|
|
421
|
+
event.decision = { retry: true, delay: retryDelay(event.attempt) }
|
|
422
|
+
}
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
if (event.attempt === relays.length + 1) {
|
|
426
|
+
log("all relays failed, next attempt goes direct", { attempt: event.attempt })
|
|
427
|
+
forceDirectSessions.add(event.sessionID)
|
|
428
|
+
if (!event.decision.retry) event.decision = { retry: true, delay: retryDelay(event.attempt) }
|
|
429
|
+
}
|
|
430
|
+
}),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
// WebSocket routes bypass the HTTP relay protocol (v1 only patched fetch).
|
|
434
|
+
// Keep them direct so streaming providers don't break; log in debug mode.
|
|
435
|
+
disposables.push(
|
|
436
|
+
await ctx.session.hook("experimental.ws.handshake", (event) => {
|
|
437
|
+
log("ws.handshake direct (relay is HTTP-only)", { url: event.url })
|
|
438
|
+
}),
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
// Light-touch provider touch: ensure the built-in opencode (Zen) provider
|
|
442
|
+
// stays enabled so Zen traffic exists for the hooks/patch above to relay.
|
|
443
|
+
// No baseURL/fetch override here — v2 providers don't expose fetch;
|
|
444
|
+
// routing happens in the session hooks.
|
|
445
|
+
try {
|
|
446
|
+
disposables.push(
|
|
447
|
+
await ctx.provider.transform((editor) => {
|
|
448
|
+
const record = editor.get("opencode")
|
|
449
|
+
if (!record) return
|
|
450
|
+
if (record.provider.activation === "disabled") {
|
|
451
|
+
editor.update("opencode", (provider) => {
|
|
452
|
+
provider.activation = "enabled"
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
}),
|
|
456
|
+
)
|
|
457
|
+
} catch (e) {
|
|
458
|
+
log("provider transform skipped", { error: String(e) })
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return async () => {
|
|
462
|
+
for (const d of disposables.splice(0)) {
|
|
463
|
+
try {
|
|
464
|
+
await d.dispose?.()
|
|
465
|
+
} catch {
|
|
466
|
+
// ignore
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (!g[PATCH_MARKER]) return
|
|
470
|
+
try {
|
|
471
|
+
globalThis.fetch = (g[PATCH_MARKER] as typeof fetch) ?? directFetch
|
|
472
|
+
} catch {
|
|
473
|
+
// ignore
|
|
474
|
+
}
|
|
475
|
+
delete g[PATCH_MARKER]
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nofendian17/opencode-relay-plugin",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "2.0.1",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "./index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.ts",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"opencode",
|
|
16
|
+
"opencode-plugin",
|
|
17
|
+
"relay",
|
|
18
|
+
"proxy"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test test/relay.test.ts",
|
|
23
|
+
"typecheck": "tsc --noEmit"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@opencode/plugin": "^2"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@opencode/plugin": "^2.0.24",
|
|
30
|
+
"@types/node": "^26.6.4",
|
|
31
|
+
"typescript": "^5.9.3"
|
|
32
|
+
}
|
|
33
|
+
}
|