@jacobbd/relay-ai 0.4.0 → 0.4.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 CHANGED
@@ -9,7 +9,11 @@
9
9
 
10
10
  [![npm version](https://img.shields.io/npm/v/@jacobbd/relay-ai)](https://www.npmjs.com/package/@jacobbd/relay-ai)
11
11
  [![License](https://img.shields.io/npm/l/@jacobbd/relay-ai)](LICENSE)
12
- [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=flat-square&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/jacobbd)
12
+
13
+ > ☕ **If you find relay-ai useful, consider [buying me a coffee](https://buymeacoffee.com/jacobbd).**
14
+ > It's free and built in my spare time — but testing every provider runs up a real AI bill. A coffee helps me cover it and keep shipping. Thank you! 🙏
15
+ >
16
+ > <a href="https://buymeacoffee.com/jacobbd"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="42"></a>
13
17
 
14
18
  <p align="center">
15
19
  <a href="https://youtu.be/IvsUPHLhX0o">
@@ -570,6 +574,12 @@ The deprecated `OPENCODE_STARTER_HOME` env var still works as a fallback for `RE
570
574
 
571
575
  Private beta right now. Issues and PRs welcome on GitHub.
572
576
 
577
+ ## Support
578
+
579
+ If relay-ai saves you time or money, you can help cover the AI bills that go into building and testing it against every provider. Any support is hugely appreciated. 🙏
580
+
581
+ <a href="https://buymeacoffee.com/jacobbd"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="42"></a>
582
+
573
583
  ## Disclaimer
574
584
 
575
585
  This project and its creator have **no affiliation** with OpenCode, Anthropic, Claude, Google, GitHub, OpenAI, xAI, or any other vendor named or integrated here. Trademarks belong to their respective owners.
@@ -11,7 +11,7 @@ import { join } from "path";
11
11
  // package.json
12
12
  var package_default = {
13
13
  name: "@jacobbd/relay-ai",
14
- version: "0.4.0",
14
+ version: "0.4.1",
15
15
  publishConfig: {
16
16
  access: "public"
17
17
  },
@@ -81,10 +81,12 @@ var package_default = {
81
81
  picocolors: "^1.1.1",
82
82
  "smol-toml": "^1.6.1",
83
83
  "venice-ai-sdk-provider": "^2.0.2",
84
+ ws: "^8.21.0",
84
85
  zod: "^3.25.76"
85
86
  },
86
87
  devDependencies: {
87
88
  "@types/node": "^22.0.0",
89
+ "@types/ws": "^8.18.1",
88
90
  "@vitest/coverage-v8": "^2.1.9",
89
91
  tsup: "^8.0.0",
90
92
  typescript: "^5.5.0",
@@ -112,6 +114,9 @@ var BACKENDS = {
112
114
  baseUrl: "https://opencode.ai/zen/go"
113
115
  }
114
116
  };
117
+ var CODEX_RESPONSES_LITE_WS_URL = "wss://chatgpt.com/backend-api/codex/responses";
118
+ var CODEX_RESPONSES_LITE_VERSION = "0.144.1";
119
+ var CODEX_RESPONSES_WEBSOCKETS_BETA = "responses_websockets=2026-02-06";
115
120
  var CONFLICTING_ENV_VARS = [
116
121
  "CLAUDE_CODE_USE_VERTEX",
117
122
  "ANTHROPIC_VERTEX_PROJECT_ID",
@@ -312,6 +317,161 @@ async function runOpenAiDeviceCodeFlow(onDeviceCode, opts) {
312
317
  return pollOpenAiDeviceCodeToken(deviceData, opts);
313
318
  }
314
319
 
320
+ // src/oauth/responses-websocket.ts
321
+ var RESPONSES_LITE_HEADER = "x-openai-internal-codex-responses-lite";
322
+ var TERMINAL_EVENT_TYPES = /* @__PURE__ */ new Set(["response.completed", "response.failed", "response.incomplete"]);
323
+ function toHeaderRecord(headers) {
324
+ const out = {};
325
+ if (!headers) return out;
326
+ if (headers instanceof Headers) {
327
+ headers.forEach((value, key) => {
328
+ out[key] = value;
329
+ });
330
+ } else if (Array.isArray(headers)) {
331
+ for (const [key, value] of headers) out[key] = value;
332
+ } else {
333
+ for (const [key, value] of Object.entries(headers)) out[key] = String(value);
334
+ }
335
+ return out;
336
+ }
337
+ function hasResponsesLiteHeader(headers) {
338
+ return Object.entries(headers).some(
339
+ ([k, v]) => k.toLowerCase() === RESPONSES_LITE_HEADER && v.toLowerCase() === "true"
340
+ );
341
+ }
342
+ function bodyToString(body) {
343
+ if (body == null) return "";
344
+ if (typeof body === "string") return body;
345
+ if (body instanceof Uint8Array) return Buffer.from(body).toString("utf8");
346
+ if (body instanceof ArrayBuffer) return Buffer.from(new Uint8Array(body)).toString("utf8");
347
+ return String(body);
348
+ }
349
+ function applyResponsesLiteShape(payload) {
350
+ const reasoning = payload.reasoning && typeof payload.reasoning === "object" ? { ...payload.reasoning } : {};
351
+ reasoning.context = "all_turns";
352
+ return {
353
+ ...payload,
354
+ reasoning,
355
+ parallel_tool_calls: false,
356
+ store: false
357
+ };
358
+ }
359
+ function createResponsesWebSocketFetch(wsUrl, log7) {
360
+ const debug = (msg) => {
361
+ try {
362
+ log7?.(`ws: ${msg}`);
363
+ } catch {
364
+ }
365
+ };
366
+ return async (_input, init) => {
367
+ const { WebSocket } = await import("ws");
368
+ const headers = toHeaderRecord(init?.headers);
369
+ headers["OpenAI-Beta"] = CODEX_RESPONSES_WEBSOCKETS_BETA;
370
+ debug(`connecting ${wsUrl} headers=[${Object.keys(headers).join(", ")}]`);
371
+ let payload = {};
372
+ try {
373
+ payload = JSON.parse(bodyToString(init?.body));
374
+ } catch {
375
+ payload = {};
376
+ }
377
+ if (hasResponsesLiteHeader(headers)) {
378
+ payload = applyResponsesLiteShape(payload);
379
+ }
380
+ const outgoing = JSON.stringify({ type: "response.create", ...payload });
381
+ const encoder = new TextEncoder();
382
+ let socket;
383
+ let frameCount = 0;
384
+ const stream = new ReadableStream({
385
+ start(controller) {
386
+ let closed = false;
387
+ const close = () => {
388
+ if (closed) return;
389
+ closed = true;
390
+ try {
391
+ controller.close();
392
+ } catch {
393
+ }
394
+ try {
395
+ socket.close();
396
+ } catch {
397
+ }
398
+ };
399
+ const fail = (message) => {
400
+ if (closed) return;
401
+ debug(`fail: ${message}`);
402
+ try {
403
+ controller.enqueue(encoder.encode(
404
+ `data: ${JSON.stringify({ type: "error", error: { message } })}
405
+
406
+ `
407
+ ));
408
+ } catch {
409
+ }
410
+ close();
411
+ };
412
+ socket = new WebSocket(wsUrl, { headers });
413
+ socket.on("open", () => {
414
+ debug(`open \u2014 sending ${outgoing.length}B payload`);
415
+ socket.send(outgoing);
416
+ });
417
+ socket.on("unexpected-response", (_req, res) => {
418
+ debug(`unexpected-response status=${res.statusCode}`);
419
+ });
420
+ socket.on("message", (data) => {
421
+ const text4 = Array.isArray(data) ? Buffer.concat(data).toString("utf8") : data.toString("utf8");
422
+ frameCount += 1;
423
+ if (frameCount <= 3) debug(`frame#${frameCount}: ${text4.slice(0, 200)}`);
424
+ let event;
425
+ try {
426
+ event = JSON.parse(text4);
427
+ } catch {
428
+ controller.enqueue(encoder.encode(`data: ${text4.replace(/\r?\n/g, " ")}
429
+
430
+ `));
431
+ return;
432
+ }
433
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}
434
+
435
+ `));
436
+ const type = event.type;
437
+ if (typeof type === "string" && TERMINAL_EVENT_TYPES.has(type)) {
438
+ debug(`terminal event: ${type} (after ${frameCount} frames)`);
439
+ close();
440
+ }
441
+ });
442
+ socket.on("error", (err) => fail(err.message));
443
+ socket.on("close", (code, reason) => {
444
+ debug(`close code=${code} frames=${frameCount}${reason?.length ? ` reason=${reason.toString("utf8").slice(0, 200)}` : ""}`);
445
+ if (closed) return;
446
+ if (code === 1e3 || code === 1005) {
447
+ close();
448
+ return;
449
+ }
450
+ fail(`WebSocket closed (${code})${reason?.length ? `: ${reason.toString("utf8")}` : ""}`);
451
+ });
452
+ const signal = init?.signal;
453
+ if (signal) {
454
+ if (signal.aborted) {
455
+ close();
456
+ return;
457
+ }
458
+ signal.addEventListener("abort", close, { once: true });
459
+ }
460
+ },
461
+ cancel() {
462
+ try {
463
+ socket?.close();
464
+ } catch {
465
+ }
466
+ }
467
+ });
468
+ return new Response(stream, {
469
+ status: 200,
470
+ headers: { "content-type": "text/event-stream; charset=utf-8" }
471
+ });
472
+ };
473
+ }
474
+
315
475
  // src/oauth/claude-identity.ts
316
476
  import { createHash, randomUUID } from "crypto";
317
477
  var CLAUDE_CODE_CLI_VERSION = "2.1.195";
@@ -504,14 +664,21 @@ async function createLanguageModel(spec) {
504
664
  if (npm === "@ai-sdk/openai") {
505
665
  const { createOpenAI } = await import("@ai-sdk/openai");
506
666
  const accountId = spec.authType === "oauth" ? spec.oauthAccountId ?? extractOpenAiAccountId({ access_token: apiKey }) : void 0;
507
- const openai = createOpenAI(spec.authType === "oauth" ? {
667
+ const oauthOptions = spec.authType === "oauth" ? {
508
668
  apiKey,
509
669
  baseURL: "https://chatgpt.com/backend-api/codex",
510
670
  headers: {
511
671
  ...accountId ? { "ChatGPT-Account-Id": accountId } : {},
512
- originator: "relay-ai"
513
- }
514
- } : { apiKey });
672
+ originator: "relay-ai",
673
+ // Responses-Lite models (backend prefer_websockets/use_responses_lite,
674
+ // e.g. gpt-5.6-luna) require these on the request.
675
+ ...spec.useResponsesLite ? { version: CODEX_RESPONSES_LITE_VERSION, "x-openai-internal-codex-responses-lite": "true" } : {}
676
+ },
677
+ // Models the backend flags with prefer_websockets are only served over
678
+ // the WebSocket Responses transport, not HTTP.
679
+ ...spec.preferWebSockets ? { fetch: createResponsesWebSocketFetch(CODEX_RESPONSES_LITE_WS_URL, spec.onDebug) } : {}
680
+ } : { apiKey };
681
+ const openai = createOpenAI(oauthOptions);
515
682
  return shouldUseOpenAiResponsesEndpoint(modelId) ? openai.responses(modelId) : openai.chat(modelId);
516
683
  }
517
684
  if (npm === "@ai-sdk/xai") {
@@ -5490,7 +5657,10 @@ function startProxyCatalog(routes, defaultAliasId, debug = false) {
5490
5657
  authType: route.authType,
5491
5658
  oauthAccountId: route.oauthAccountId,
5492
5659
  providerData: route.providerData,
5493
- headers: route.headers
5660
+ headers: route.headers,
5661
+ useResponsesLite: route.useResponsesLite,
5662
+ preferWebSockets: route.preferWebSockets,
5663
+ onDebug: (msg) => plog(() => msg)
5494
5664
  });
5495
5665
  if (clientWantsStream) {
5496
5666
  res.writeHead(200, {
@@ -5620,7 +5790,9 @@ function startProxy(completionsUrl, modelId, debug = false, contextWindow, sdk,
5620
5790
  providerData: sdk?.providerData,
5621
5791
  supportedParameters: sdk?.supportedParameters,
5622
5792
  reasoning: sdk?.reasoning,
5623
- interleavedReasoningField: sdk?.interleavedReasoningField
5793
+ interleavedReasoningField: sdk?.interleavedReasoningField,
5794
+ useResponsesLite: sdk?.useResponsesLite,
5795
+ preferWebSockets: sdk?.preferWebSockets
5624
5796
  }], clientModelId, debug);
5625
5797
  }
5626
5798
 
@@ -6480,7 +6652,9 @@ function cachedModelToLocal(cached, provider) {
6480
6652
  contextWindow: cached.contextWindow ?? resolveContextWindow(id),
6481
6653
  supportedParameters: cached.supportedParameters,
6482
6654
  reasoning: cached.reasoning ?? modelsDev?.reasoning,
6483
- interleavedReasoningField: cached.interleavedReasoningField ?? modelsDev?.interleaved?.field
6655
+ interleavedReasoningField: cached.interleavedReasoningField ?? modelsDev?.interleaved?.field,
6656
+ useResponsesLite: cached.useResponsesLite,
6657
+ preferWebSockets: cached.preferWebSockets
6484
6658
  };
6485
6659
  }
6486
6660
  function providerAllowsAnonymousFreeModels(provider) {
@@ -6642,6 +6816,8 @@ function localProvidersToServerModels(localProviders) {
6642
6816
  supportedParameters: model.supportedParameters,
6643
6817
  reasoning: model.reasoning,
6644
6818
  interleavedReasoningField: model.interleavedReasoningField,
6819
+ useResponsesLite: model.useResponsesLite,
6820
+ preferWebSockets: model.preferWebSockets,
6645
6821
  headers: provider.headers,
6646
6822
  providerData: provider.providerData
6647
6823
  }))
@@ -7209,7 +7385,9 @@ async function getOrInitLanguageModel(modelCache, model, npm, baseURL, apiKey, v
7209
7385
  authType: model.authType,
7210
7386
  oauthAccountId: model.oauthAccountId,
7211
7387
  vertex,
7212
- headers: model.headers
7388
+ headers: model.headers,
7389
+ useResponsesLite: model.useResponsesLite,
7390
+ preferWebSockets: model.preferWebSockets
7213
7391
  });
7214
7392
  modelCache.set(cacheKey, languageModel);
7215
7393
  }
@@ -8211,7 +8389,9 @@ function parseModelList(body, npm) {
8211
8389
  freeStatus,
8212
8390
  modelFormat: format,
8213
8391
  npm,
8214
- supportedParameters: Array.isArray(row.supported_parameters) ? row.supported_parameters : void 0
8392
+ supportedParameters: Array.isArray(row.supported_parameters) ? row.supported_parameters : void 0,
8393
+ useResponsesLite: typeof row.use_responses_lite === "boolean" ? row.use_responses_lite : void 0,
8394
+ preferWebSockets: typeof row.prefer_websockets === "boolean" ? row.prefer_websockets : void 0
8215
8395
  });
8216
8396
  }
8217
8397
  return models;
@@ -8727,7 +8907,7 @@ var OPENAI_OAUTH_MODEL_SEEDS = [
8727
8907
  // GPT-5.6 family (Sol / Terra / Luna)
8728
8908
  { id: "gpt-5.6-sol", name: "GPT-5.6 Sol", reasoning: true },
8729
8909
  { id: "gpt-5.6-terra", name: "GPT-5.6 Terra", reasoning: true },
8730
- { id: "gpt-5.6-luna", name: "GPT-5.6 Luna", reasoning: true },
8910
+ { id: "gpt-5.6-luna", name: "GPT-5.6 Luna", reasoning: true, useResponsesLite: true, preferWebSockets: true },
8731
8911
  // GPT-5.5 family (Pro)
8732
8912
  { id: "gpt-5.5", name: "GPT-5.5", reasoning: true },
8733
8913
  // GPT-5.4 family
@@ -8754,7 +8934,9 @@ function buildOpenAiOAuthModels() {
8754
8934
  contextWindow: resolveContextWindow(seed.id),
8755
8935
  modelFormat: "openai",
8756
8936
  npm: "@ai-sdk/openai",
8757
- reasoning: seed.reasoning
8937
+ reasoning: seed.reasoning,
8938
+ useResponsesLite: seed.useResponsesLite,
8939
+ preferWebSockets: seed.preferWebSockets
8758
8940
  };
8759
8941
  });
8760
8942
  }
@@ -8882,30 +9064,57 @@ async function refreshOAuthProvider(provider, accessToken) {
8882
9064
  if (tpl === "antigravity") return refreshAntigravityOAuthModels(accessToken);
8883
9065
  throw new Error(`refreshOAuthProvider: unsupported template "${tpl}"`);
8884
9066
  }
9067
+ function readCapabilityFlags(m) {
9068
+ const bool = (v) => typeof v === "boolean" ? v : void 0;
9069
+ return {
9070
+ useResponsesLite: bool(m["use_responses_lite"]),
9071
+ preferWebSockets: bool(m["prefer_websockets"])
9072
+ };
9073
+ }
8885
9074
  function parseOpenAiModelEntries(body) {
8886
9075
  if (!body || typeof body !== "object") return [];
8887
9076
  const b = body;
8888
9077
  if (Array.isArray(b.models)) {
8889
- return b.models.map((m) => ({ id: m.slug ?? "", name: m.title ?? m.name ?? m.slug ?? "", context_window: m.context_window })).filter((m) => m.id.length > 0);
9078
+ return b.models.map((m) => ({
9079
+ id: m.slug ?? "",
9080
+ name: m.title ?? m.name ?? m.slug ?? "",
9081
+ context_window: m.context_window,
9082
+ ...readCapabilityFlags(m)
9083
+ })).filter((m) => m.id.length > 0);
8890
9084
  }
8891
9085
  if (Array.isArray(b.data)) {
8892
- return b.data.map((m) => ({ id: m.id ?? "", name: m.name ?? m.id ?? "", context_window: m.context_window })).filter((m) => m.id.length > 0);
9086
+ return b.data.map((m) => ({
9087
+ id: m.id ?? "",
9088
+ name: m.name ?? m.id ?? "",
9089
+ context_window: m.context_window,
9090
+ ...readCapabilityFlags(m)
9091
+ })).filter((m) => m.id.length > 0);
8893
9092
  }
8894
9093
  return [];
8895
9094
  }
8896
- function buildDynamicOAuthModel(id, name, contextWindow, seedById) {
8897
- if (seedById.has(id)) return seedById.get(id);
9095
+ function buildDynamicOAuthModel(entry, seedById) {
9096
+ const seed = seedById.get(entry.id);
9097
+ if (seed) {
9098
+ return {
9099
+ ...seed,
9100
+ useResponsesLite: entry.useResponsesLite ?? seed.useResponsesLite,
9101
+ preferWebSockets: entry.preferWebSockets ?? seed.preferWebSockets
9102
+ };
9103
+ }
9104
+ const { id } = entry;
8898
9105
  const prefix = id.split("-")[0] ?? id;
8899
9106
  return {
8900
9107
  id,
8901
- name,
9108
+ name: entry.name,
8902
9109
  upstreamModelId: id,
8903
9110
  family: prefix,
8904
9111
  brand: deriveBrand(prefix),
8905
- contextWindow: contextWindow ?? resolveContextWindow(id),
9112
+ contextWindow: entry.context_window ?? resolveContextWindow(id),
8906
9113
  modelFormat: "openai",
8907
9114
  npm: "@ai-sdk/openai",
8908
- reasoning: modelPrefersResponsesApi(id)
9115
+ reasoning: modelPrefersResponsesApi(id),
9116
+ useResponsesLite: entry.useResponsesLite,
9117
+ preferWebSockets: entry.preferWebSockets
8909
9118
  };
8910
9119
  }
8911
9120
  async function fetchJsonWithAuth(url, accessToken, timeoutMs) {
@@ -8932,7 +9141,7 @@ async function fetchJsonWithAuth(url, accessToken, timeoutMs) {
8932
9141
  async function refreshOpenAiOAuthModels(accessToken) {
8933
9142
  const TIMEOUT_MS = 1e4;
8934
9143
  const seedById = new Map(buildOpenAiOAuthModels().map((m) => [m.id, m]));
8935
- const toModels = (entries) => entries.map(({ id, name, context_window }) => buildDynamicOAuthModel(id, name, context_window, seedById));
9144
+ const toModels = (entries) => entries.map((entry) => buildDynamicOAuthModel(entry, seedById));
8936
9145
  const claudeVersion = getInstalledClaudeVersion();
8937
9146
  const codexResult = await fetchJsonWithAuth(
8938
9147
  `https://chatgpt.com/backend-api/codex/models?client_version=${claudeVersion}`,
@@ -9268,7 +9477,9 @@ function modelToCached(model) {
9268
9477
  apiUrl: model.apiBaseUrl,
9269
9478
  supportedParameters: model.supportedParameters,
9270
9479
  reasoning: model.reasoning,
9271
- interleavedReasoningField: model.interleavedReasoningField
9480
+ interleavedReasoningField: model.interleavedReasoningField,
9481
+ useResponsesLite: model.useResponsesLite,
9482
+ preferWebSockets: model.preferWebSockets
9272
9483
  };
9273
9484
  }
9274
9485
  function localProviderToRegistry(provider, opts) {
@@ -10193,4 +10404,4 @@ export {
10193
10404
  quitClaudeAppGracefully,
10194
10405
  launchOrRestartClaudeApp
10195
10406
  };
10196
- //# sourceMappingURL=chunk-RTZ3X3KZ.js.map
10407
+ //# sourceMappingURL=chunk-2GUFTECU.js.map