@nofendian17/opencode-relay-plugin 0.0.0-stage → 2.0.0

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 +95 -2
  2. package/index.ts +478 -0
  3. package/package.json +31 -4
package/README.md CHANGED
@@ -1,3 +1,96 @@
1
- # Temporary Holding Version
1
+ # @nofendian17/opencode-relay-plugin (v2)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.0",
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
+ }