@jacobbd/relay-ai 0.12.4 → 0.12.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.
package/README.md CHANGED
@@ -564,6 +564,19 @@ For agent / alef-agent integration (boot flags, NDJSON): **[docs/AI-AGENTS.md](d
564
564
 
565
565
  When OpenCode Zen is in your registry, `subscriptionFilter` controls which Zen models appear (`free` = free tier only; default = all Zen models). Add or change Zen via `relay-ai providers`.
566
566
 
567
+ Zen, Go, OpenRouter, and Command Code gateways can expose both Chat
568
+ Completions and Anthropic Messages. Relay uses current model metadata first, so
569
+ newly named models are not forced into a provider-wide format. For requests
570
+ Relay translates (OpenAI-format models, and any model used from Codex, Gemini,
571
+ or Antigravity), an early protocol or gateway error makes Relay retry the same
572
+ provider/account/model once on the sibling endpoint before streaming any
573
+ output. Successful choices are remembered for 24 hours; two failed attempts
574
+ pause protocol retries for five minutes. Authentication, quota, context,
575
+ moderation, and cancellation errors do not trigger a retry. Anthropic-format
576
+ models in Claude Code, and the server's direct routes, are forwarded untouched
577
+ so prompt caching, beta headers, and thinking keep working; they do not retry
578
+ on the sibling endpoint.
579
+
567
580
  ### Environment isolation
568
581
 
569
582
  When you launch, relay-ai builds a clean child environment:
@@ -584,7 +597,7 @@ OpenCode exposes models through different API formats. relay-ai handles them whe
584
597
 
585
598
  | Model format | Examples | How it works | Label |
586
599
  |---|---|---|---|
587
- | Anthropic native | Claude, Qwen, MiniMax (Go) | Direct connection | *(none)* |
600
+ | Anthropic native | Claude, Union Alpha, Qwen, MiniMax (Go) | Direct connection | *(none)* |
588
601
  | OpenAI chat completions | DeepSeek, Kimi, MiMo, GLM, Grok, GPT-4o (OpenCode OpenAI provider) | SDK adapter proxy (Vercel AI SDK) | `via proxy` |
589
602
  | OpenAI Responses API | GPT-5.4+, GPT-5.5, Codex, o-series (OpenCode OpenAI provider only) | Same proxy; SDK picks Responses API | `via proxy` |
590
603
  | Gemini native | Gemini (OpenCode Google provider) | SDK adapter, Gemini native API | `via proxy` |
@@ -47,6 +47,7 @@ import {
47
47
  injectClaudeCodeBillingSystemLine,
48
48
  injectClaudeIdentity,
49
49
  isBrowserRedirectOAuth,
50
+ isOpencodeApi,
50
51
  isOpencodeOAuth,
51
52
  isSdkMigratedNpm,
52
53
  isValidProviderId,
@@ -78,6 +79,7 @@ import {
78
79
  silenceSdkWarnings,
79
80
  slugifyProviderId,
80
81
  splitToolUseId,
82
+ sseChunk,
81
83
  streamAnthropicResponse,
82
84
  stripOneMContextSuffix,
83
85
  supportsNativeOAuth,
@@ -85,7 +87,7 @@ import {
85
87
  translateRequest,
86
88
  upstreamHttpStatus,
87
89
  validateCustomEndpointUrl
88
- } from "./chunk-5INR7PKO.js";
90
+ } from "./chunk-VPDX24ZR.js";
89
91
 
90
92
  // src/registry/google-model-id.ts
91
93
  var GOOGLE_MODEL_PREFIX = "models/";
@@ -1901,7 +1903,7 @@ function estimateAnthropicInputTokens(body) {
1901
1903
  }
1902
1904
 
1903
1905
  // src/upstream-forward.ts
1904
- import { Readable } from "stream";
1906
+ import { once } from "events";
1905
1907
 
1906
1908
  // src/server/auth.ts
1907
1909
  function sanitizeCredential(value) {
@@ -1959,7 +1961,7 @@ async function fetchWithOAuthRetry(apiKey, request, refreshToken) {
1959
1961
  response = await request(refreshed);
1960
1962
  return { response, apiKey: refreshed, refreshed: true };
1961
1963
  }
1962
- async function relayAnthropicMessages(res, messagesUrl, body, apiKey, clientWantsStream, inboundBeta, authType, log7, claudeCodeSessionId, extraHeaders, refreshToken, onTokenRefreshed) {
1964
+ async function relayAnthropicMessages(res, messagesUrl, body, apiKey, clientWantsStream, inboundBeta, authType, log7, claudeCodeSessionId, extraHeaders, refreshToken, onTokenRefreshed, retryEmptyStream = false) {
1963
1965
  const doFetch = (key) => fetch(messagesUrl, {
1964
1966
  method: "POST",
1965
1967
  headers: anthropicUpstreamHeaders(key, clientWantsStream, inboundBeta, authType, claudeCodeSessionId, extraHeaders),
@@ -1981,12 +1983,46 @@ async function relayAnthropicMessages(res, messagesUrl, body, apiKey, clientWant
1981
1983
  return;
1982
1984
  }
1983
1985
  if (clientWantsStream && upstreamRes.body) {
1986
+ const reader = upstreamRes.body.getReader();
1987
+ const first = await reader.read().catch(() => ({ done: true, value: void 0 }));
1988
+ if (first.done) {
1989
+ reader.releaseLock();
1990
+ if (retryEmptyStream) {
1991
+ log7?.("anthropic upstream returned an empty stream; retrying without streaming");
1992
+ await replayWithoutStreaming(
1993
+ res,
1994
+ messagesUrl,
1995
+ body,
1996
+ apiKey,
1997
+ inboundBeta,
1998
+ authType,
1999
+ claudeCodeSessionId,
2000
+ extraHeaders,
2001
+ log7
2002
+ );
2003
+ return;
2004
+ }
2005
+ }
1984
2006
  res.writeHead(200, {
1985
2007
  "Content-Type": "text/event-stream",
1986
2008
  "Cache-Control": "no-cache",
1987
2009
  "Connection": "keep-alive"
1988
2010
  });
1989
- Readable.fromWeb(upstreamRes.body).on("error", () => res.destroy()).pipe(res);
2011
+ if (first.done) {
2012
+ res.end();
2013
+ return;
2014
+ }
2015
+ res.write(Buffer.from(first.value));
2016
+ try {
2017
+ while (true) {
2018
+ const next = await reader.read();
2019
+ if (next.done) break;
2020
+ if (!res.write(Buffer.from(next.value))) await once(res, "drain");
2021
+ }
2022
+ res.end();
2023
+ } catch {
2024
+ res.destroy();
2025
+ }
1990
2026
  return;
1991
2027
  }
1992
2028
  if (!upstreamRes.body) {
@@ -2008,6 +2044,85 @@ async function relayAnthropicMessages(res, messagesUrl, body, apiKey, clientWant
2008
2044
  });
2009
2045
  res.end(text4);
2010
2046
  }
2047
+ async function replayWithoutStreaming(res, messagesUrl, body, apiKey, inboundBeta, authType, claudeCodeSessionId, extraHeaders, log7) {
2048
+ let retryRes;
2049
+ try {
2050
+ retryRes = await fetch(messagesUrl, {
2051
+ method: "POST",
2052
+ headers: anthropicUpstreamHeaders(apiKey, false, inboundBeta, authType, claudeCodeSessionId, extraHeaders),
2053
+ body: JSON.stringify({ ...body, stream: false })
2054
+ });
2055
+ } catch (err) {
2056
+ throw new UpstreamUnreachableError(err);
2057
+ }
2058
+ const text4 = await retryRes.text();
2059
+ if (!retryRes.ok) {
2060
+ log7?.(`anthropic upstream ${retryRes.status} on empty-stream retry: ${text4}`);
2061
+ res.writeHead(retryRes.status, { "Content-Type": retryRes.headers.get("content-type") || "application/json" });
2062
+ res.end(text4);
2063
+ return;
2064
+ }
2065
+ let message;
2066
+ try {
2067
+ message = JSON.parse(text4);
2068
+ } catch {
2069
+ res.writeHead(502, { "Content-Type": "application/json" });
2070
+ res.end(JSON.stringify({ type: "error", error: { type: "api_error", message: "Upstream response was not valid JSON" } }));
2071
+ return;
2072
+ }
2073
+ res.writeHead(200, {
2074
+ "Content-Type": "text/event-stream",
2075
+ "Cache-Control": "no-cache",
2076
+ "Connection": "keep-alive"
2077
+ });
2078
+ writeMessageAsSse(res, message);
2079
+ }
2080
+ function writeMessageAsSse(res, message) {
2081
+ const content = Array.isArray(message.content) ? message.content : [];
2082
+ res.write(sseChunk("message_start", {
2083
+ type: "message_start",
2084
+ message: { ...message, content: [], stop_reason: null, stop_sequence: null }
2085
+ }));
2086
+ content.forEach((block, index) => {
2087
+ const type = block.type;
2088
+ const opening = type === "text" ? { type: "text", text: "" } : type === "thinking" ? { type: "thinking", thinking: "", signature: "" } : type === "tool_use" ? { type: "tool_use", id: block.id, name: block.name, input: {} } : block;
2089
+ res.write(sseChunk("content_block_start", { type: "content_block_start", index, content_block: opening }));
2090
+ if (type === "text") {
2091
+ res.write(sseChunk("content_block_delta", {
2092
+ type: "content_block_delta",
2093
+ index,
2094
+ delta: { type: "text_delta", text: block.text ?? "" }
2095
+ }));
2096
+ } else if (type === "thinking") {
2097
+ res.write(sseChunk("content_block_delta", {
2098
+ type: "content_block_delta",
2099
+ index,
2100
+ delta: { type: "thinking_delta", thinking: block.thinking ?? "" }
2101
+ }));
2102
+ if (typeof block.signature === "string") {
2103
+ res.write(sseChunk("content_block_delta", {
2104
+ type: "content_block_delta",
2105
+ index,
2106
+ delta: { type: "signature_delta", signature: block.signature }
2107
+ }));
2108
+ }
2109
+ } else if (type === "tool_use") {
2110
+ res.write(sseChunk("content_block_delta", {
2111
+ type: "content_block_delta",
2112
+ index,
2113
+ delta: { type: "input_json_delta", partial_json: JSON.stringify(block.input ?? {}) }
2114
+ }));
2115
+ }
2116
+ res.write(sseChunk("content_block_stop", { type: "content_block_stop", index }));
2117
+ });
2118
+ res.write(sseChunk("message_delta", {
2119
+ type: "message_delta",
2120
+ delta: { stop_reason: message.stop_reason ?? "end_turn", stop_sequence: message.stop_sequence ?? null },
2121
+ usage: message.usage ?? {}
2122
+ }));
2123
+ res.write(sseChunk("message_stop", { type: "message_stop" }));
2124
+ res.end();
2125
+ }
2011
2126
 
2012
2127
  // src/antigravity/anthropic-to-cloudcode.ts
2013
2128
  import { randomUUID } from "crypto";
@@ -2859,7 +2974,8 @@ function startProxyCatalog(routes, defaultAliasId, debug = false) {
2859
2974
  route.refreshToken,
2860
2975
  (refreshed) => {
2861
2976
  route.apiKey = refreshed;
2862
- }
2977
+ },
2978
+ true
2863
2979
  );
2864
2980
  } catch (err) {
2865
2981
  const message = err instanceof UpstreamUnreachableError ? err.message : String(err);
@@ -3594,8 +3710,7 @@ function readModelsFromModelsDev(backendId, cache = loadModelsDevCache()) {
3594
3710
  const id = entry.id ?? modelKey;
3595
3711
  if (entry.status === "deprecated") continue;
3596
3712
  const isFree = entry.cost !== void 0 && entry.cost.input === 0 && entry.cost.output === 0;
3597
- const rawFormat = classifyModelFormat(id, entry.provider?.npm);
3598
- const modelFormat = backendId === "go" && rawFormat === "anthropic" ? "openai" : rawFormat;
3713
+ const modelFormat = classifyModelFormat(id, entry.provider?.npm);
3599
3714
  result.set(id, {
3600
3715
  id,
3601
3716
  name: entry.name ?? id,
@@ -3630,10 +3745,7 @@ function mergeModels(apiIds, cache, backendId) {
3630
3745
  const uniqueIds = Array.from(new Set(apiIds));
3631
3746
  return uniqueIds.filter((id) => !shouldHideModel({ providerId: backendId, modelId: id, agent: "claude" })).map((id) => {
3632
3747
  const cached = cache?.get(id);
3633
- if (cached) {
3634
- const modelFormat2 = backendId === "go" && cached.modelFormat === "anthropic" ? "openai" : cached.modelFormat;
3635
- return { ...cached, sourceBackend: backendId, modelFormat: modelFormat2 };
3636
- }
3748
+ if (cached) return { ...cached, sourceBackend: backendId };
3637
3749
  const modelFormat = classifyModelFormat(id, void 0);
3638
3750
  return {
3639
3751
  id,
@@ -3799,7 +3911,12 @@ function buildImportProviderList(raw, authEntries) {
3799
3911
  const oauthByProviderId = /* @__PURE__ */ new Map();
3800
3912
  const covered = /* @__PURE__ */ new Set();
3801
3913
  const merged = [];
3802
- for (const provider of normalizeProviders(raw)) {
3914
+ const rawWithLegacyKeys = raw.map((provider) => {
3915
+ const authEntry = authEntries[provider.id];
3916
+ if (provider.key?.trim() || !isOpencodeApi(authEntry)) return provider;
3917
+ return { ...provider, key: authEntry.key };
3918
+ });
3919
+ for (const provider of normalizeProviders(rawWithLegacyKeys)) {
3803
3920
  const normalized = normalizeImportProviderIdentity(provider);
3804
3921
  if (covered.has(normalized.id)) continue;
3805
3922
  merged.push(normalized);
@@ -3856,7 +3973,7 @@ function listCredentialSkippedProviders(raw, authEntries, importedIds, alreadyRe
3856
3973
  if (isOpencodeOAuth(authEntries[provider.id])) continue;
3857
3974
  if (!provider.models || Object.keys(provider.models).length === 0) continue;
3858
3975
  const reason = classifyOpencodeCredentialGap(provider.id);
3859
- if (reason !== "oauth-no-token" && !registryProviderIds.has(provider.id)) continue;
3976
+ if (reason !== "oauth-no-token" && !provider.configured && !registryProviderIds.has(provider.id)) continue;
3860
3977
  skipped.push({ id: provider.id, name: provider.name, reason });
3861
3978
  }
3862
3979
  return skipped;
@@ -3919,7 +4036,30 @@ function buildCatalogRoutes(startingRoute, favorites, resolveRoute, max = MAX_MO
3919
4036
 
3920
4037
  // src/registry/materialize.ts
3921
4038
  init_provider_templates();
3922
- function cachedModelToLocal(cached, provider) {
4039
+
4040
+ // src/registry/model-protocol.ts
4041
+ function reconcileCachedModelProtocol(model, provider, metadata = loadModelsDevCache()) {
4042
+ if (model.source === "manual") return model;
4043
+ const isZenGo = provider.id === "zen" || provider.id === "go" || provider.templateId === "zen" || provider.templateId === "go";
4044
+ if (!isZenGo) return model;
4045
+ const metadataNpm = findModelsDevModel(provider.id, model.id, metadata)?.provider?.npm;
4046
+ if (!metadataNpm) return model;
4047
+ const format = classifyModelFormat(model.id, metadataNpm);
4048
+ if (format === "unsupported") return model;
4049
+ const backendId = provider.id === "go" || provider.templateId === "go" ? "go" : "zen";
4050
+ const apiUrl = model.apiUrl ?? provider.api.url ?? BACKENDS[backendId].baseUrl;
4051
+ return {
4052
+ ...model,
4053
+ modelFormat: format,
4054
+ npm: metadataNpm,
4055
+ apiUrl
4056
+ };
4057
+ }
4058
+
4059
+ // src/registry/materialize.ts
4060
+ function cachedModelToLocal(cached, provider, metadata = loadModelsDevCache()) {
4061
+ const reconciled = reconcileCachedModelProtocol(cached, provider, metadata);
4062
+ cached = reconciled;
3923
4063
  const freeStatus = classifyFreeStatus({
3924
4064
  model: cached,
3925
4065
  providerId: provider.id,
@@ -3941,20 +4081,24 @@ function cachedModelToLocal(cached, provider) {
3941
4081
  interleavedReasoningField: cached.interleavedReasoningField
3942
4082
  };
3943
4083
  }
4084
+ const modelsDev = findModelsDevModel(provider.id, cached.id, metadata);
4085
+ const isZenGo = provider.id === "zen" || provider.id === "go" || provider.templateId === "zen" || provider.templateId === "go";
4086
+ const metadataNpm = !cached.source && isZenGo ? modelsDev?.provider?.npm : void 0;
3944
4087
  const npm = cached.npm ?? provider.api.npm ?? "";
3945
- const apiUrl = cached.apiUrl ?? provider.api.url ?? "";
4088
+ const apiUrl = cached.apiUrl ?? provider.api.url ?? (isZenGo ? BACKENDS[provider.id === "go" || provider.templateId === "go" ? "go" : "zen"].baseUrl : "");
3946
4089
  const endpoint = resolveEndpoint(npm, apiUrl);
3947
4090
  if (endpoint === null) return null;
3948
- const modelsDev = findModelsDevModel(provider.id, cached.id);
3949
4091
  const { id, upstreamModelId: upstreamModelId2 } = normalizeGoogleModelId(cached.id, npm);
3950
4092
  const normalizedUpstream = normalizeGoogleModelId(cached.upstreamModelId ?? cached.id, npm).upstreamModelId;
3951
4093
  const family = npm === "@ai-sdk/google" ? id.split(/[-/:]/)[0] ?? id : cached.family ?? "";
4094
+ const classifiedFormat = classifyModelFormat(cached.id, npm);
4095
+ const resolvedFormat = classifiedFormat === "anthropic" ? classifiedFormat : "openai";
3952
4096
  return {
3953
4097
  id,
3954
4098
  name: npm === "@ai-sdk/google" ? normalizeGoogleDisplayName(cached.name, id) : cached.name,
3955
4099
  family,
3956
4100
  brand: npm === "@ai-sdk/google" ? deriveBrand(family) : cached.brand ?? deriveBrand(cached.family ?? ""),
3957
- modelFormat: cached.modelFormat ?? endpoint.format,
4101
+ modelFormat: metadataNpm ? resolvedFormat : cached.modelFormat ?? endpoint.format,
3958
4102
  upstreamModelId: normalizedUpstream,
3959
4103
  baseUrl: endpoint.baseUrl,
3960
4104
  completionsUrl: endpoint.completionsUrl,
@@ -8292,6 +8436,7 @@ function httpProxyModelId(providerId, modelId) {
8292
8436
  return `${HTTP_PROXY_MODEL_PREFIX}${providerId}:${modelId}`;
8293
8437
  }
8294
8438
  function supportsClaudeTransparentMode(model) {
8439
+ if (model.modelFormat === "anthropic") return Boolean(model.baseUrl);
8295
8440
  return model.modelFormat === "openai" && isSdkMigratedNpm(model.npm);
8296
8441
  }
8297
8442
  function buildHttpProxyRoutes(providers, favorites, selected, max = MAX_MODEL_CATALOG) {
@@ -9661,4 +9806,4 @@ export {
9661
9806
  supportsClaudeTransparentMode,
9662
9807
  buildHttpProxyRoutes
9663
9808
  };
9664
- //# sourceMappingURL=chunk-LPOAO33X.js.map
9809
+ //# sourceMappingURL=chunk-5MEDBKPX.js.map