@juspay/neurolink 12.21.0 → 12.21.2

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.
@@ -163,9 +163,17 @@ export class MessageBuilder {
163
163
  };
164
164
  }
165
165
  else if (item.type === "image") {
166
+ // Upstream image builders label the resolved format as
167
+ // either `mediaType` (canonical ImagePart field) or
168
+ // `mimeType` (messageBuilder's convertSimpleImagesToProviderFormat).
169
+ // Dropping it here silently reset every transcoded image
170
+ // back to the provider's hardcoded PNG default even
171
+ // though the transcoded bytes themselves were correct.
172
+ const mediaType = (item.mediaType ?? item.mimeType);
166
173
  return {
167
174
  type: "image",
168
175
  image: item.image || "",
176
+ ...(mediaType && { mediaType }),
169
177
  ...(itemProviderOptions && {
170
178
  providerOptions: itemProviderOptions,
171
179
  }),
@@ -287,9 +295,17 @@ export class MessageBuilder {
287
295
  };
288
296
  }
289
297
  else if (item.type === "image") {
298
+ // Upstream image builders label the resolved format as
299
+ // either `mediaType` (canonical ImagePart field) or
300
+ // `mimeType` (messageBuilder's convertSimpleImagesToProviderFormat).
301
+ // Dropping it here silently reset every transcoded image
302
+ // back to the provider's hardcoded PNG default even
303
+ // though the transcoded bytes themselves were correct.
304
+ const mediaType = (item.mediaType ?? item.mimeType);
290
305
  return {
291
306
  type: "image",
292
307
  image: item.image || "",
308
+ ...(mediaType && { mediaType }),
293
309
  ...(itemProviderOptions && {
294
310
  providerOptions: itemProviderOptions,
295
311
  }),
@@ -193,7 +193,14 @@ export const convertContentForOpenAI = (content) => {
193
193
  const data = part.image ??
194
194
  part.data ??
195
195
  part.url;
196
- const url = imageDataToURL(data);
196
+ // ImagePart carries the resolved format as `mediaType` (MessageBuilder
197
+ // canonicalizes to it); `mimeType` is accepted for parts that bypass
198
+ // that normalization. Without this, a raw base64 payload (as opposed to
199
+ // an already-labelled data: URI) always fell back to imageDataToURL's
200
+ // hardcoded "image/png" default, mislabeling real JPEG/WebP/etc bytes.
201
+ const mediaType = part.mediaType ??
202
+ part.mimeType;
203
+ const url = imageDataToURL(data, mediaType);
197
204
  if (url) {
198
205
  out.push({ type: "image_url", image_url: { url } });
199
206
  }
@@ -11,6 +11,15 @@ import { normalizeAnthropicAccountKey } from "./accountSelection.js";
11
11
  import { CODEX_USAGE_URL, CODEX_USER_AGENT, decodeCodexAccessToken, resolveCodexAccountId, } from "../auth/codexOAuth.js";
12
12
  import { logger } from "../utils/logger.js";
13
13
  export const CODEX_ACCOUNT_PREFIX = "codex:";
14
+ /**
15
+ * Native fallback legs (e.g. Claude-on-Vertex) key their usage rows as
16
+ * `vertex/<model>` rather than a provider-qualified `<provider>:<label>`
17
+ * pair — there is no OAuth account behind them, just a served model. They
18
+ * are not served by Codex, so without this prefix they fell through to the
19
+ * generic "other" branch below and lost both their identity and their live
20
+ * status in the CLI's proxy status output.
21
+ */
22
+ const VERTEX_ACCOUNT_PREFIX = "vertex/";
14
23
  /** Keep an account label in the Codex namespace used by its token store. */
15
24
  export function normalizeCodexAccountKey(value) {
16
25
  const trimmed = value.trim();
@@ -32,12 +41,18 @@ export function resolveProxyStatusAccountIdentity(label, type, persistedKey) {
32
41
  if (persistedKey?.startsWith(CODEX_ACCOUNT_PREFIX)) {
33
42
  return { provider: "codex", key: persistedKey };
34
43
  }
44
+ if (persistedKey?.startsWith(VERTEX_ACCOUNT_PREFIX)) {
45
+ return { provider: "vertex", key: persistedKey };
46
+ }
35
47
  if (type === "oauth" || type === "api_key") {
36
48
  return { provider: "anthropic", key: normalizeAnthropicAccountKey(label) };
37
49
  }
38
50
  if (type === "codex-oauth") {
39
51
  return { provider: "codex", key: normalizeCodexAccountKey(label) };
40
52
  }
53
+ if (type === "vertex") {
54
+ return { provider: "vertex", key: persistedKey ?? label };
55
+ }
41
56
  return { provider: "other", key: null };
42
57
  }
43
58
  /** Enumerate Codex OAuth accounts from the token store for usage refresh. */
@@ -739,7 +739,21 @@ export async function createCodexFallbackStream(response, model) {
739
739
  try {
740
740
  yield* serializer.start();
741
741
  while (true) {
742
- const chunk = await reader.read();
742
+ let chunk;
743
+ try {
744
+ chunk = await reader.read();
745
+ }
746
+ catch (err) {
747
+ // A premature stream close after a complete SSE stream (but before
748
+ // the zero-length terminator) rejects here. If response.completed
749
+ // already landed, treat the close as a normal end-of-stream rather
750
+ // than failing a fully successful turn; otherwise this is genuine
751
+ // truncation and must still fail visibly.
752
+ if (completed) {
753
+ break;
754
+ }
755
+ throw err;
756
+ }
743
757
  carry += decoder.decode(chunk.value, { stream: !chunk.done });
744
758
  // Search only new bytes plus the boundary overlap. Long tool payloads
745
759
  // split over many chunks must not rescan their accumulated prefix.
@@ -21,6 +21,17 @@ import type { VertexAccessTokenProvider, UsageContext, VertexPassthroughTerminal
21
21
  * `effort` key, never the sibling settings beside it.
22
22
  */
23
23
  export declare function mapVertexOutputConfig(value: unknown): Record<string, unknown> | undefined;
24
+ /**
25
+ * Strip what Vertex rejects and pin the API version it requires.
26
+ *
27
+ * Prompt caching constrains every rewrite here. Anthropic caches only up to an
28
+ * explicit `cache_control` marker, at most four per request, over a prefix
29
+ * rendered `tools → system → messages`; anything removed or inserted before a
30
+ * marker invalidates it and everything after. So: never drop a marker, never
31
+ * reorder `system`, and keep each rewrite deterministic so consecutive turns
32
+ * share a prefix. Vertex holds its own cache, apart from the first-party one,
33
+ * so nothing here can disturb first-party hits.
34
+ */
24
35
  export declare function buildVertexAnthropicPayload(body: Readonly<Record<string, unknown>>): Record<string, unknown>;
25
36
  /**
26
37
  * Publisher endpoint for a Claude model on Vertex.
@@ -45,6 +56,13 @@ export declare function buildVertexAnthropicUrl(target: VertexAnthropicPassthrou
45
56
  * Pass `undefined` to restore the real credential lookup.
46
57
  */
47
58
  export declare function setVertexAccessTokenProviderForTests(provider: VertexAccessTokenProvider | undefined): void;
59
+ /**
60
+ * Run once more after a short pause when the first attempt hit a transient
61
+ * network failure: anything the shared proxy classifier retries, plus a DNS
62
+ * blip. One retry absorbs a blip without hiding a real outage: a second
63
+ * failure, or any other error, surfaces unchanged.
64
+ */
65
+ export declare function withTransientNetworkRetry<T>(run: () => Promise<T>, delayMs?: number): Promise<T>;
48
66
  /**
49
67
  * Send one Anthropic-shaped request to Vertex and return the upstream response
50
68
  * untouched, so the caller can stream its bytes straight to the client.
@@ -13,6 +13,8 @@
13
13
  * here because nothing reshapes them.
14
14
  */
15
15
  import { GoogleAuth } from "google-auth-library";
16
+ import { logger } from "../utils/logger.js";
17
+ import { isTransientNetworkError } from "./proxyFetch.js";
16
18
  /** Vertex pins the Anthropic API version in the body, not a header. */
17
19
  const VERTEX_ANTHROPIC_VERSION = "vertex-2023-10-16";
18
20
  const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
@@ -68,7 +70,114 @@ export function mapVertexOutputConfig(value) {
68
70
  const { effort: _unsupported, ...rest } = value;
69
71
  return Object.keys(rest).length > 0 ? rest : undefined;
70
72
  }
71
- /** Strip what Vertex rejects and pin the API version it requires. */
73
+ /**
74
+ * Vertex's message schema is exactly `{ role, content }`. Claude Code 2.1.x
75
+ * also puts `output_config` on its system "directive" turn, which the
76
+ * first-party API accepts and Vertex refuses: "messages.1.output_config:
77
+ * Extra inputs are not permitted".
78
+ */
79
+ const VERTEX_MESSAGE_KEYS = new Set(["role", "content"]);
80
+ /**
81
+ * Claude Code stamps its version into a system block as
82
+ * `x-anthropic-billing-header: cc_version=…`. That is first-party billing
83
+ * plumbing; on Vertex it only triggers a client-version check against the
84
+ * fallback model, which the client never asked for ("Claude Code 2.1.278 does
85
+ * not support this model"). It also sits first in `system` and varies with the
86
+ * client build, entrypoint and subagent flag, so identical conversations from
87
+ * different builds could never share a Vertex prompt cache while it stayed.
88
+ *
89
+ * Matched as a prefix, not anywhere in the text: the generator
90
+ * (`buildStableClaudeCodeBillingHeader`) always emits it first, and a looser
91
+ * match would delete a caller's own block that merely mentions the header.
92
+ */
93
+ const BILLING_HEADER_MARKER = "x-anthropic-billing-header";
94
+ /**
95
+ * How each mid-conversation tool change reads once it is plain text: the prefix
96
+ * when the tool is named, and a sentence for a reference with no usable name.
97
+ */
98
+ const TOOL_CHANGE_TEXT = {
99
+ tool_addition: {
100
+ named: "Tool now available: ",
101
+ unnamed: "A tool became available.",
102
+ },
103
+ tool_removal: {
104
+ named: "Tool no longer available: ",
105
+ unnamed: "A tool was withdrawn.",
106
+ },
107
+ };
108
+ /**
109
+ * A change references a tool rather than defining one: `tool_reference` and
110
+ * `mcp_tool_reference` carry a `name`, and an MCP toolset reference carries
111
+ * only its `server_name`.
112
+ */
113
+ function toolChangeText(type, tool) {
114
+ const wording = TOOL_CHANGE_TEXT[type];
115
+ const label = !record(tool)
116
+ ? ""
117
+ : typeof tool.name === "string" && tool.name.trim()
118
+ ? tool.name.trim()
119
+ : typeof tool.server_name === "string"
120
+ ? tool.server_name.trim()
121
+ : "";
122
+ return label ? `${wording.named}${label}` : wording.unnamed;
123
+ }
124
+ /**
125
+ * Vertex rejects the `tool_addition` and `tool_removal` tags that offer or
126
+ * withdraw a tool mid-conversation. Each one becomes a text block in the same
127
+ * position, so the notice survives and the array never empties. Only the
128
+ * message's own content array is touched: nested `tool_result` content stays
129
+ * exactly as sent.
130
+ */
131
+ function rewriteToolChanges(content) {
132
+ if (!Array.isArray(content)) {
133
+ return content;
134
+ }
135
+ let changed = false;
136
+ const mapped = content.map((block) => {
137
+ if (!record(block) ||
138
+ typeof block.type !== "string" ||
139
+ !Object.hasOwn(TOOL_CHANGE_TEXT, block.type)) {
140
+ return block;
141
+ }
142
+ changed = true;
143
+ return {
144
+ type: "text",
145
+ text: toolChangeText(block.type, block.tool),
146
+ ...(block.cache_control !== undefined
147
+ ? { cache_control: block.cache_control }
148
+ : {}),
149
+ };
150
+ });
151
+ return changed ? mapped : content;
152
+ }
153
+ function isEmptyContent(content) {
154
+ return (content === undefined ||
155
+ content === "" ||
156
+ (Array.isArray(content) && content.length === 0));
157
+ }
158
+ /**
159
+ * Move a `cache_control` set on the message itself onto its last content
160
+ * block, which caches the same prefix, since Vertex accepts only `role` and
161
+ * `content` on a message. String content becomes one text block to carry it.
162
+ * A breakpoint with no block to land on, or whose block already has one, is
163
+ * reported rather than dropped silently.
164
+ */
165
+ function carryMessageBreakpoint(content, marker) {
166
+ if (marker === undefined) {
167
+ return content;
168
+ }
169
+ if (typeof content === "string" && content !== "") {
170
+ return [{ type: "text", text: content, cache_control: marker }];
171
+ }
172
+ const last = Array.isArray(content) ? content.at(-1) : undefined;
173
+ if (Array.isArray(content) &&
174
+ record(last) &&
175
+ last.cache_control === undefined) {
176
+ return [...content.slice(0, -1), { ...last, cache_control: marker }];
177
+ }
178
+ warnDroppedBreakpoint("a message-level cache_control");
179
+ return content;
180
+ }
72
181
  /**
73
182
  * Vertex refuses `role: "system"` messages outright — "role 'system' is not
74
183
  * supported on this model" — where the first-party Messages API accepts them
@@ -82,25 +191,156 @@ export function mapVertexOutputConfig(value) {
82
191
  * instead, which keeps both the text and the position. Consecutive same-role
83
192
  * turns are accepted, so this never produces an invalid sequence.
84
193
  */
85
- function normalizeVertexRoles(messages) {
194
+ function normalizeVertexMessage(message) {
195
+ if (!record(message)) {
196
+ return message;
197
+ }
198
+ const role = message.role === "system" ? "user" : message.role;
199
+ const content = carryMessageBreakpoint(rewriteToolChanges(message.content), message.cache_control);
200
+ const onlyKnownKeys = Object.keys(message).every((key) => VERTEX_MESSAGE_KEYS.has(key));
201
+ if (onlyKnownKeys && role === message.role && content === message.content) {
202
+ return message;
203
+ }
204
+ return { role, ...("content" in message ? { content } : {}) };
205
+ }
206
+ /** Index of the last `role: "user"` message, or -1 when there is none. */
207
+ function lastUserIndex(messages) {
208
+ return messages.findLastIndex((message) => record(message) && message.role === "user");
209
+ }
210
+ /**
211
+ * A turn-scoped system message (`clear_at: "next_user_message"`) renders only
212
+ * while no user message comes after it — one carrying only `tool_result`
213
+ * counts — and renders nothing once one does. Sent to Vertex as a user turn it
214
+ * would keep speaking after it should have gone quiet.
215
+ */
216
+ function isClearedTurnScopedMessage(message, index, lastUser) {
217
+ return (record(message) &&
218
+ message.role === "system" &&
219
+ message.clear_at === "next_user_message" &&
220
+ index < lastUser);
221
+ }
222
+ /**
223
+ * A system turn left with no content carries nothing once its extra keys are
224
+ * gone — the directive-only form is `content: []` plus `output_config`, whose
225
+ * effort is resolved at the top level — so it is dropped rather than sent as
226
+ * an empty user turn. So is a turn-scoped message that has already cleared.
227
+ */
228
+ function normalizeVertexMessages(messages) {
86
229
  if (!Array.isArray(messages)) {
87
230
  return messages;
88
231
  }
232
+ const lastUser = lastUserIndex(messages);
89
233
  let changed = false;
90
- const mapped = messages.map((message) => {
91
- if (!record(message) || message.role !== "system") {
92
- return message;
234
+ const normalized = messages.flatMap((message, index) => {
235
+ if (isClearedTurnScopedMessage(message, index, lastUser)) {
236
+ changed = true;
237
+ return [];
93
238
  }
94
- changed = true;
95
- return { ...message, role: "user" };
239
+ const next = normalizeVertexMessage(message);
240
+ if (next !== message) {
241
+ changed = true;
242
+ }
243
+ if (record(message) &&
244
+ message.role === "system" &&
245
+ record(next) &&
246
+ isEmptyContent(next.content)) {
247
+ return [];
248
+ }
249
+ return [next];
96
250
  });
97
- return changed ? mapped : messages;
251
+ return changed ? normalized : messages;
252
+ }
253
+ /**
254
+ * The effort in force for the turn being answered. A per-message directive
255
+ * "takes effect from the next user turn and holds until a later message changes
256
+ * it", so the governing one is the last directive before the final user
257
+ * message; one after it belongs to a turn not yet taken. It overrides only the
258
+ * settings it names. Vertex has no per-message effort, so this becomes the
259
+ * request's top-level `output_config`.
260
+ */
261
+ function resolveOutputConfig(topLevel, messages) {
262
+ if (!Array.isArray(messages)) {
263
+ return topLevel;
264
+ }
265
+ const lastUser = lastUserIndex(messages);
266
+ const directive = messages
267
+ .slice(0, Math.max(lastUser, 0))
268
+ .findLast((message) => record(message) &&
269
+ message.role === "system" &&
270
+ record(message.output_config));
271
+ if (!record(directive) || !record(directive.output_config)) {
272
+ return topLevel;
273
+ }
274
+ return {
275
+ ...(record(topLevel) ? topLevel : {}),
276
+ ...directive.output_config,
277
+ };
278
+ }
279
+ function isBillingBlock(block) {
280
+ return (record(block) &&
281
+ typeof block.text === "string" &&
282
+ block.text.trimStart().startsWith(BILLING_HEADER_MARKER));
283
+ }
284
+ function warnDroppedBreakpoint(source) {
285
+ logger.warn(`[proxy] Vertex fallback could not keep the cache_control breakpoint on ${source}`);
286
+ }
287
+ /**
288
+ * Remove the billing block without losing a cache breakpoint it carried: the
289
+ * marker moves onto the block that takes its place. When there is no such
290
+ * block, or that block already has its own marker, the loss is reported rather
291
+ * than passed over, because this path bills in real currency.
292
+ */
293
+ function stripBillingBlock(system) {
294
+ if (!Array.isArray(system) || !system.some(isBillingBlock)) {
295
+ return system;
296
+ }
297
+ const kept = [];
298
+ let carried;
299
+ for (const block of system) {
300
+ if (isBillingBlock(block)) {
301
+ if (record(block) && block.cache_control !== undefined) {
302
+ if (carried !== undefined) {
303
+ warnDroppedBreakpoint("the Claude Code billing block");
304
+ }
305
+ carried = block.cache_control;
306
+ }
307
+ continue;
308
+ }
309
+ if (carried !== undefined && record(block)) {
310
+ if (block.cache_control === undefined) {
311
+ kept.push({ ...block, cache_control: carried });
312
+ }
313
+ else {
314
+ warnDroppedBreakpoint("the Claude Code billing block");
315
+ kept.push(block);
316
+ }
317
+ carried = undefined;
318
+ continue;
319
+ }
320
+ kept.push(block);
321
+ }
322
+ if (carried !== undefined) {
323
+ warnDroppedBreakpoint("the Claude Code billing block");
324
+ }
325
+ return kept.length > 0 ? kept : undefined;
98
326
  }
327
+ /**
328
+ * Strip what Vertex rejects and pin the API version it requires.
329
+ *
330
+ * Prompt caching constrains every rewrite here. Anthropic caches only up to an
331
+ * explicit `cache_control` marker, at most four per request, over a prefix
332
+ * rendered `tools → system → messages`; anything removed or inserted before a
333
+ * marker invalidates it and everything after. So: never drop a marker, never
334
+ * reorder `system`, and keep each rewrite deterministic so consecutive turns
335
+ * share a prefix. Vertex holds its own cache, apart from the first-party one,
336
+ * so nothing here can disturb first-party hits.
337
+ */
99
338
  export function buildVertexAnthropicPayload(body) {
100
339
  const dropped = new Set(UNSUPPORTED_FIELDS);
101
340
  const payload = Object.fromEntries(Object.entries(body).filter(([key]) => !dropped.has(key)));
102
- if ("output_config" in payload) {
103
- const mapped = mapVertexOutputConfig(payload.output_config);
341
+ const outputConfig = resolveOutputConfig(payload.output_config, payload.messages);
342
+ if (outputConfig !== undefined) {
343
+ const mapped = mapVertexOutputConfig(outputConfig);
104
344
  if (mapped === undefined) {
105
345
  delete payload.output_config;
106
346
  }
@@ -109,7 +349,16 @@ export function buildVertexAnthropicPayload(body) {
109
349
  }
110
350
  }
111
351
  if ("messages" in payload) {
112
- payload.messages = normalizeVertexRoles(payload.messages);
352
+ payload.messages = normalizeVertexMessages(payload.messages);
353
+ }
354
+ if ("system" in payload) {
355
+ const system = stripBillingBlock(payload.system);
356
+ if (system === undefined) {
357
+ delete payload.system;
358
+ }
359
+ else {
360
+ payload.system = system;
361
+ }
113
362
  }
114
363
  return { ...payload, anthropic_version: VERTEX_ANTHROPIC_VERSION };
115
364
  }
@@ -159,12 +408,56 @@ async function vertexAccessToken() {
159
408
  export function setVertexAccessTokenProviderForTests(provider) {
160
409
  accessTokenProviderForTests = provider;
161
410
  }
411
+ const DNS_FAILURE_CODES = new Set([
412
+ "ENOTFOUND",
413
+ "EAI_AGAIN",
414
+ ]);
415
+ const DNS_FAILURE_MESSAGE = /\bgetaddrinfo (ENOTFOUND|EAI_AGAIN)\b/;
416
+ const TOKEN_RETRY_DELAY_MS = 250;
417
+ /**
418
+ * A resolver failure while reaching Google's token endpoint. The shared proxy
419
+ * classifier leaves DNS codes out, since elsewhere one can mean a mistyped
420
+ * host, but this endpoint is fixed and its failures on 2026-09-23 were blips
421
+ * reported only in the message text ("getaddrinfo ENOTFOUND
422
+ * oauth2.googleapis.com").
423
+ */
424
+ function isDnsFailure(error) {
425
+ let current = error;
426
+ for (let depth = 0; depth < 5 && record(current); depth++) {
427
+ if ((typeof current.code === "string" &&
428
+ DNS_FAILURE_CODES.has(current.code)) ||
429
+ (typeof current.message === "string" &&
430
+ DNS_FAILURE_MESSAGE.test(current.message))) {
431
+ return true;
432
+ }
433
+ current = current.cause;
434
+ }
435
+ return false;
436
+ }
437
+ /**
438
+ * Run once more after a short pause when the first attempt hit a transient
439
+ * network failure: anything the shared proxy classifier retries, plus a DNS
440
+ * blip. One retry absorbs a blip without hiding a real outage: a second
441
+ * failure, or any other error, surfaces unchanged.
442
+ */
443
+ export async function withTransientNetworkRetry(run, delayMs = TOKEN_RETRY_DELAY_MS) {
444
+ try {
445
+ return await run();
446
+ }
447
+ catch (error) {
448
+ if (!isTransientNetworkError(error) && !isDnsFailure(error)) {
449
+ throw error;
450
+ }
451
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
452
+ return run();
453
+ }
454
+ }
162
455
  /**
163
456
  * Send one Anthropic-shaped request to Vertex and return the upstream response
164
457
  * untouched, so the caller can stream its bytes straight to the client.
165
458
  */
166
459
  export async function dispatchVertexAnthropicPassthrough(request) {
167
- const token = await vertexAccessToken();
460
+ const token = await withTransientNetworkRetry(() => vertexAccessToken());
168
461
  if (!token) {
169
462
  throw new Error("Vertex passthrough could not obtain a Google access token; " +
170
463
  "check Application Default Credentials for this project");
@@ -143,6 +143,34 @@ function buildCodexErrorResponse(status, message, code) {
143
143
  },
144
144
  }), { status, headers: { "content-type": "application/json" } });
145
145
  }
146
+ /**
147
+ * Build a terminal Codex Responses SSE stream for a quota-exhaustion failure
148
+ * that never reached an upstream request (every pooled account is cooling).
149
+ * A bare non-2xx status with no body reads to the Codex CLI as a dropped
150
+ * connection, so it shows "Reconnecting... waiting for network" forever
151
+ * instead of a real error. Emitting a well-formed `response.failed` event —
152
+ * the same terminal shape the CLI already parses out of a live stream —
153
+ * lets it render the actual failure instead.
154
+ */
155
+ function buildCodexQuotaExhaustedResponse(message, retryAfterSec) {
156
+ const payload = {
157
+ type: "response.failed",
158
+ response: {
159
+ error: {
160
+ type: "insufficient_quota",
161
+ code: "insufficient_quota",
162
+ message,
163
+ },
164
+ },
165
+ };
166
+ const headers = {
167
+ "content-type": "text/event-stream",
168
+ };
169
+ if (retryAfterSec !== undefined) {
170
+ headers["retry-after"] = String(retryAfterSec);
171
+ }
172
+ return new Response(`event: response.failed\ndata: ${JSON.stringify(payload)}\n\n`, { status: 200, headers });
173
+ }
146
174
  /**
147
175
  * Load the Codex account pool from the token store, refreshing expired OAuth
148
176
  * tokens and hydrating cooldown + quota state from disk.
@@ -654,18 +682,10 @@ async function executeCodexResponsesRequest(ctx) {
654
682
  errorType: "all_accounts_cooling",
655
683
  errorMessage: "All Codex accounts are rate-limited",
656
684
  });
657
- return new Response(JSON.stringify({
658
- error: {
659
- type: "rate_limit_error",
660
- message: "All Codex accounts are currently rate-limited",
661
- },
662
- }), {
663
- status: 429,
664
- headers: {
665
- "content-type": "application/json",
666
- "retry-after": String(retryAfterSec),
667
- },
668
- });
685
+ const resetAt = soonest ? new Date(soonest).toISOString() : undefined;
686
+ return buildCodexQuotaExhaustedResponse(resetAt
687
+ ? `Codex quota exhausted: all accounts are rate-limited. Resets at ${resetAt}.`
688
+ : "Codex quota exhausted: all accounts are rate-limited.", retryAfterSec);
669
689
  }
670
690
  let attempt = 0;
671
691
  let lastErrorMessage = "All Codex accounts failed";
@@ -119,6 +119,9 @@ export type CodexProxyStatusAccountIdentity = {
119
119
  } | {
120
120
  provider: "codex";
121
121
  key: string;
122
+ } | {
123
+ provider: "vertex";
124
+ key: string;
122
125
  } | {
123
126
  provider: "other";
124
127
  key: null;
@@ -3221,7 +3221,7 @@ export type StatusStats = {
3221
3221
  accounts?: {
3222
3222
  /** Provider-qualified key; null for explicitly unattributed legacy rows. */
3223
3223
  key?: string | null;
3224
- provider?: "anthropic" | "codex" | "other" | "unknown";
3224
+ provider?: "anthropic" | "codex" | "vertex" | "other" | "unknown";
3225
3225
  label: string;
3226
3226
  type: string;
3227
3227
  attempts?: number;
@@ -187,8 +187,11 @@ export type CliAccountsRow = {
187
187
  /**
188
188
  * Which pool engine owns this login. Absent on plumbing rows. Consumers
189
189
  * that key a list by row must key by `key`, not `label` — see above.
190
+ *
191
+ * `"vertex"` is not a login: native fallback legs are keyed by served
192
+ * model, with no OAuth account behind them, so they carry no token state.
190
193
  */
191
- provider?: "anthropic" | "codex";
194
+ provider?: "anthropic" | "codex" | "vertex";
192
195
  /**
193
196
  * What this row actually is. Only "account" rows are real logins; the proxy
194
197
  * also tracks internal and translation pseudo-accounts, which have no quota