@juspay/neurolink 13.8.13 → 13.8.15

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.
@@ -7,6 +7,7 @@ import { ProviderFactory } from "../../factories/providerFactory.js";
7
7
  import { withSpan } from "../../telemetry/withSpan.js";
8
8
  import { tracers } from "../../telemetry/tracers.js";
9
9
  import { logger } from "../../utils/logger.js";
10
+ import { isContextCarried } from "../utils/carriedContextMark.js";
10
11
  /**
11
12
  * Resolve `request.tools` (an array of tool NAMES shared by the execute and
12
13
  * stream endpoints) into a `toolFilter` whitelist. The field was previously
@@ -78,14 +79,144 @@ async function resolveRequestInput(ctx, requestInput, options) {
78
79
  /**
79
80
  * Create agent routes
80
81
  *
82
+ * `options.perUserAuth` decides where a request's identity comes from; see
83
+ * `ServerAgentRouteOptions`. Authentication middleware that sets `ctx.user`
84
+ * does not change that on its own: one shared API key sets the same user for
85
+ * every caller, so trusting it as a person would give every client the same
86
+ * userId and no conversation memory. The converse is the operator's to act on:
87
+ * a server whose authentication identifies individual people must set
88
+ * `perUserAuth`, or any authenticated caller can name another userId and
89
+ * sessionId in the body.
90
+ *
91
+ * Unset, identity resolves as it did before the adapters carried `ctx.user` to
92
+ * the handler: the body's `userId` and `sessionId`, with one exception. On
93
+ * Hono, a request whose client sent its own `X-Request-ID` header reached the
94
+ * handler with the middleware's context, so an authenticated `ctx.user` (and
95
+ * `ctx.session`) won over the body there, and still does. A context the
96
+ * built-in adapters did not hand the carry to also keeps that rule: a custom
97
+ * adapter that sets `ctx.user` itself, or a handler called directly with a
98
+ * context the caller built. Only `perUserAuth` (with the server carrying
99
+ * middleware state) makes these routes take the user from `ctx.user`, and
100
+ * ignore the body's `userId`, on every built-in adapter and for every request.
101
+ *
102
+ * `perUserAuth` needs the user in the handler, which an adapter hands over on
103
+ * every request only when the server carries middleware state.
104
+ * `registerAllRoutes()` turns that on for the server when it is given
105
+ * `perUserAuth`. A caller that registers these routes itself with `perUserAuth`
106
+ * must create the server with `carryMiddlewareState: true`. Without it, a
107
+ * request has a user only on Hono when the client sent its own `X-Request-ID`
108
+ * (Hono then hands the handler the middleware's context regardless); every
109
+ * other request (Hono without that header, Express, Fastify, Koa) runs as
110
+ * anonymous, with no `userId` and no session.
111
+ *
112
+ * Without `perUserAuth`, and when the server carries middleware state, the
113
+ * routes log one warning per instance the first time a request whose body names
114
+ * a different `userId` than its `ctx.user` is served from the body, unless that
115
+ * user is marked `shared: true` (an identity for every holder of one
116
+ * credential, which the CLI's API-key middleware sets). Such a request is
117
+ * served from the body; the warning only tells the operator that per-person
118
+ * authentication is running without the flag that makes it hold. With the
119
+ * carry off the handler has no `ctx.user` on those requests, so there is
120
+ * nothing to compare and no warning. A request where `ctx.user` already won has
121
+ * nothing to warn about.
122
+ *
81
123
  * @param options - Limits for `input.images` / `input.files`:
82
124
  * `allowedInputRoots` (default: none, every local-path attachment is
83
125
  * refused), `maxInputBytes` per attachment (default 50 MiB),
84
126
  * `maxInputAttachments` per request (default 20, images and files together)
85
127
  * and `maxInputTotalBytes` per request (default 100 MiB, one budget shared by
86
- * images and files).
128
+ * images and files); and `perUserAuth` (described above).
87
129
  */
88
130
  export function createAgentRoutes(basePath = "/api", options) {
131
+ // Truthiness, not `=== true`: a JavaScript caller passing "true" or 1 must get
132
+ // the strict mode, not silently the permissive one.
133
+ const perUser = Boolean(options?.perUserAuth);
134
+ // Without perUserAuth the body's userId wins (except where ctx.user already
135
+ // did), so an authenticated person who names someone else is acting as them.
136
+ // That is expected under one shared key (marked `shared`), where the body is
137
+ // how callers keep separate memory, and a misconfiguration under per-person
138
+ // authentication: say so, once, with no request values in the message.
139
+ // alwaysStderr, not warn: logger.warn is dropped unless debug logging is on,
140
+ // which would hide a security notice from the operator it is for.
141
+ let warnedNamingAnotherUser = false;
142
+ const warnIfNamingAnotherUser = (ctx, request) => {
143
+ if (warnedNamingAnotherUser ||
144
+ !ctx.user ||
145
+ ctx.user.shared === true ||
146
+ !isContextCarried(ctx) ||
147
+ typeof request.userId !== "string" ||
148
+ request.userId === ctx.user.id) {
149
+ return;
150
+ }
151
+ warnedNamingAnotherUser = true;
152
+ logger.alwaysStderr("[agentRoutes] An authenticated user named a different userId in the request body, and the body's userId was used. " +
153
+ "Per-person authentication without perUserAuth lets a caller name another user. " +
154
+ "Set perUserAuth: true, or mark an identity that stands for a shared credential with shared: true. " +
155
+ "This warning is logged once per server.");
156
+ };
157
+ // Who a skill mutation is attributed to. With perUserAuth the authenticated
158
+ // user wins and a caller-supplied name is never trusted. Otherwise the body's
159
+ // `requestedBy` is used, since a shared key's `ctx.user` names no person, for
160
+ // a context that received the adapters' carry; a context that did not (Hono
161
+ // with the caller's X-Request-ID, a custom adapter, a direct call) lets the
162
+ // authenticated user win, as it did before.
163
+ const skillRequester = (ctx, bodyRequestedBy) => {
164
+ if (perUser) {
165
+ return ctx.user?.id;
166
+ }
167
+ return isContextCarried(ctx)
168
+ ? bodyRequestedBy
169
+ : (ctx.user?.id ?? bodyRequestedBy);
170
+ };
171
+ // The identity generate() and stream() run under.
172
+ //
173
+ // perUserAuth: the middleware identifies individual people, so userId, email,
174
+ // roles and the session come only from ctx.user / ctx.session and the body's
175
+ // are ignored (no caller-supplied impersonation). The session is the
176
+ // authenticated user's: a request with no ctx.user gets none, even if a
177
+ // session middleware named one, because with optional authentication every
178
+ // anonymous caller that sends the same session id would otherwise share one
179
+ // conversation.
180
+ //
181
+ // Otherwise the body keeps choosing userId and sessionId, which is what every
182
+ // adapter did before it carried ctx.user to the handler: a context that
183
+ // received the adapters' carry is treated as the fresh one it used to be. The
184
+ // exceptions are the contexts that did not get the carry, where an
185
+ // authenticated ctx.user wins over the body, as it did, and so does the
186
+ // session: Hono with the caller's own X-Request-ID, a custom adapter that sets
187
+ // ctx.user itself, a handler called directly. Per-person authentication
188
+ // without perUserAuth therefore does not stop a caller naming someone else on
189
+ // the other requests.
190
+ const callerContext = (ctx, request) => {
191
+ if (perUser) {
192
+ return {
193
+ sessionId: ctx.user ? ctx.session?.id : undefined,
194
+ userId: ctx.user?.id,
195
+ userEmail: ctx.user?.email,
196
+ userRoles: ctx.user?.roles,
197
+ requestId: ctx.requestId,
198
+ };
199
+ }
200
+ if (!isContextCarried(ctx)) {
201
+ return {
202
+ sessionId: ctx.user
203
+ ? ctx.session?.id
204
+ : (ctx.session?.id ?? request.sessionId),
205
+ userId: ctx.user ? ctx.user.id : request.userId,
206
+ userEmail: ctx.user?.email,
207
+ userRoles: ctx.user?.roles,
208
+ requestId: ctx.requestId,
209
+ };
210
+ }
211
+ warnIfNamingAnotherUser(ctx, request);
212
+ return {
213
+ sessionId: request.sessionId,
214
+ userId: request.userId,
215
+ userEmail: undefined,
216
+ userRoles: undefined,
217
+ requestId: ctx.requestId,
218
+ };
219
+ };
89
220
  return {
90
221
  prefix: `${basePath}/agent`,
91
222
  routes: [
@@ -121,17 +252,7 @@ export function createAgentRoutes(basePath = "/api", options) {
121
252
  temperature: request.temperature,
122
253
  maxTokens: request.maxTokens,
123
254
  ...(requestToolFilter ? { toolFilter: requestToolFilter } : {}),
124
- context: {
125
- // When an authenticated user context exists (set by auth middleware),
126
- // always use its IDs to prevent caller-supplied impersonation.
127
- sessionId: ctx.user
128
- ? ctx.session?.id
129
- : (ctx.session?.id ?? request.sessionId),
130
- userId: ctx.user ? ctx.user.id : request.userId,
131
- userEmail: ctx.user?.email,
132
- userRoles: ctx.user?.roles,
133
- requestId: ctx.requestId,
134
- },
255
+ context: callerContext(ctx, request),
135
256
  });
136
257
  // Map tool calls from SDK format to API format
137
258
  const toolCalls = result.toolCalls?.map((tc) => ({
@@ -174,17 +295,7 @@ export function createAgentRoutes(basePath = "/api", options) {
174
295
  temperature: request.temperature,
175
296
  maxTokens: request.maxTokens,
176
297
  ...(streamToolFilter ? { toolFilter: streamToolFilter } : {}),
177
- context: {
178
- // When an authenticated user context exists (set by auth middleware),
179
- // always use its IDs to prevent caller-supplied impersonation.
180
- sessionId: ctx.user
181
- ? ctx.session?.id
182
- : (ctx.session?.id ?? request.sessionId),
183
- userId: ctx.user ? ctx.user.id : request.userId,
184
- userEmail: ctx.user?.email,
185
- userRoles: ctx.user?.roles,
186
- requestId: ctx.requestId,
187
- },
298
+ context: callerContext(ctx, request),
188
299
  });
189
300
  // Create redactor (no-op if redaction is not enabled)
190
301
  const redactor = createStreamRedactor(ctx.redaction);
@@ -369,14 +480,12 @@ export function createAgentRoutes(basePath = "/api", options) {
369
480
  return validation.error;
370
481
  }
371
482
  const { requestedBy, ...skill } = validation.data;
483
+ const requester = skillRequester(ctx, requestedBy);
372
484
  try {
373
485
  const result = await manager.requestMutation({
374
486
  type: "create",
375
487
  skill,
376
- // Authenticated identity wins over caller-supplied attribution.
377
- ...(ctx.user?.id || requestedBy
378
- ? { requestedBy: ctx.user?.id ?? requestedBy }
379
- : {}),
488
+ ...(requester ? { requestedBy: requester } : {}),
380
489
  });
381
490
  return result;
382
491
  }
@@ -400,14 +509,13 @@ export function createAgentRoutes(basePath = "/api", options) {
400
509
  return validation.error;
401
510
  }
402
511
  const { requestedBy, ...patch } = validation.data;
512
+ const requester = skillRequester(ctx, requestedBy);
403
513
  try {
404
514
  const result = await manager.requestMutation({
405
515
  type: "update",
406
516
  skillId: ctx.params.id,
407
517
  patch,
408
- ...(ctx.user?.id || requestedBy
409
- ? { requestedBy: ctx.user?.id ?? requestedBy }
410
- : {}),
518
+ ...(requester ? { requestedBy: requester } : {}),
411
519
  });
412
520
  return result;
413
521
  }
@@ -427,11 +535,12 @@ export function createAgentRoutes(basePath = "/api", options) {
427
535
  if ("error" in manager) {
428
536
  return manager;
429
537
  }
538
+ const requester = skillRequester(ctx, undefined);
430
539
  try {
431
540
  const result = await manager.requestMutation({
432
541
  type: "delete",
433
542
  skillId: ctx.params.id,
434
- ...(ctx.user?.id ? { requestedBy: ctx.user.id } : {}),
543
+ ...(requester ? { requestedBy: requester } : {}),
435
544
  });
436
545
  return result;
437
546
  }
@@ -24,7 +24,7 @@ import { loadAccountQuotas, saveAccountQuota, } from "../../proxy/accountQuota.j
24
24
  import { createCodexUsageTap } from "../../proxy/codexUsage.js";
25
25
  import { CODEX_ACCOUNT_PREFIX, parseCodexRateLimitHeaders, } from "../../proxy/codexAccountUsage.js";
26
26
  import { buildClientAttribution } from "../../proxy/clientAttribution.js";
27
- import { getProxyUpstreamFailure } from "../../proxy/proxyFailureDetails.js";
27
+ import { classifyProxyFailureCode, getProxyUpstreamFailure, } from "../../proxy/proxyFailureDetails.js";
28
28
  import { registerProxyResponseObserver, isProxyRequestFinalized, registerInternalProxyRequest, getProxyBridgeResult, releaseProxyRequestAccounting, } from "../../proxy/proxyActivity.js";
29
29
  import { parseCodexNativeRequest, translateCodexRequestToClaude, classifyCodexOutboundFailure, buildSystemBlocksFromDeveloperMessages, } from "../../proxy/codexOutboundFallback.js";
30
30
  import { createAnthropicFallbackStream } from "../../proxy/codexToAnthropicFallback.js";
@@ -77,6 +77,32 @@ function codexTransportScope(error) {
77
77
  function summarizeCodexUpstreamError(errorText, fallback) {
78
78
  return sanitizeForLog(errorText).slice(0, 200) || fallback;
79
79
  }
80
+ /**
81
+ * Whether a pre-stream 401/403 refuses the REQUEST rather than the credential.
82
+ *
83
+ * Only the body can tell: a content-policy 403 and an expired-token 403 share a
84
+ * status. `classifyProxyFailureCode` is the verdict the outbound fallback stops
85
+ * on, so the pool and the fallback read one code the same way. Anything it does
86
+ * not call non-retryable, including a body with no code at all, stays an
87
+ * authentication failure.
88
+ */
89
+ function readCodexRequestRefusal(status, errorText) {
90
+ if (status !== 401 && status !== 403) {
91
+ return undefined;
92
+ }
93
+ const failure = getProxyUpstreamFailure({ responseBody: errorText });
94
+ if (failure?.code === undefined) {
95
+ return undefined;
96
+ }
97
+ const known = classifyProxyFailureCode(failure.code);
98
+ return known.retryable === false
99
+ ? {
100
+ code: failure.code,
101
+ status: known.status ?? status,
102
+ message: failure.message,
103
+ }
104
+ : undefined;
105
+ }
80
106
  /**
81
107
  * In-flight proactive refreshes, keyed by account.
82
108
  *
@@ -1389,6 +1415,32 @@ async function handleCodexUpstreamFailure(rt, args) {
1389
1415
  const { writeAttempt } = rt;
1390
1416
  const { account, attempt, attemptStartedAt, upstream, authRetried } = args;
1391
1417
  const errText = await upstream.text().catch(() => "");
1418
+ // A refusal of the request itself says nothing about the account: a refresh
1419
+ // would rotate its refresh token for nothing, a cooldown would park a healthy
1420
+ // account, and every other account would be refused the same way.
1421
+ const refusal = readCodexRequestRefusal(upstream.status, errText);
1422
+ if (refusal) {
1423
+ const errorMessage = refusal.message ??
1424
+ summarizeCodexUpstreamError(errText, "Codex refused the request");
1425
+ recordAttemptError(account.label, CODEX_ACCOUNT_TYPE, upstream.status);
1426
+ writeAttempt(account, attempt, attemptStartedAt, upstream.status, {
1427
+ errorType: "invalid_request_error",
1428
+ errorCode: refusal.code,
1429
+ errorMessage,
1430
+ retryable: false,
1431
+ });
1432
+ return {
1433
+ lastFailure: {
1434
+ errorType: "invalid_request_error",
1435
+ errorCode: refusal.code,
1436
+ retryable: false,
1437
+ },
1438
+ lastErrorStatus: refusal.status,
1439
+ lastErrorCode: refusal.code,
1440
+ lastErrorMessage: errorMessage,
1441
+ refusedRequest: true,
1442
+ };
1443
+ }
1392
1444
  // 401/403 → try a forced token refresh once, then rotate.
1393
1445
  if ((upstream.status === 401 || upstream.status === 403) &&
1394
1446
  !authRetried &&
@@ -1486,9 +1538,10 @@ async function handleCodexUpstreamFailure(rt, args) {
1486
1538
  }
1487
1539
  // Other non-ok → record and rotate.
1488
1540
  if (upstream.status === 401 || upstream.status === 403) {
1489
- // Reached only when the account has no refresh token to retry with, so
1490
- // it will fail identically on the next request. Park it briefly instead
1491
- // of letting it stay first in line with unknown quota.
1541
+ // Reached when the account has no refresh token to retry with, or when
1542
+ // the retry after a refresh was rejected again. Either way it will fail
1543
+ // identically on the next request, so park it briefly instead of letting
1544
+ // it stay first in line with unknown quota.
1492
1545
  await saveAccountCooldown(account.key, Date.now() + CODEX_AUTH_COOLDOWN_MS, "auth").catch(() => undefined);
1493
1546
  }
1494
1547
  const errorMessage = summarizeCodexUpstreamError(errText, "Codex error");
@@ -1733,7 +1786,8 @@ loopbackPort, internalDispatch) {
1733
1786
  let lastErrorCode;
1734
1787
  let lastFailure = { errorType: "all_accounts_failed" };
1735
1788
  let lastAttemptedAccount;
1736
- for (const account of eligible) {
1789
+ let requestRefused = false;
1790
+ accountLoop: for (const account of eligible) {
1737
1791
  try {
1738
1792
  tracer?.setAccountSelection({
1739
1793
  strategy: "codex-quota-order",
@@ -1859,6 +1913,11 @@ loopbackPort, internalDispatch) {
1859
1913
  }
1860
1914
  ({ lastFailure, lastErrorStatus, lastErrorCode, lastErrorMessage } =
1861
1915
  failure);
1916
+ if (failure.refusedRequest) {
1917
+ // Every other account would be refused the same way.
1918
+ requestRefused = true;
1919
+ break accountLoop;
1920
+ }
1862
1921
  break;
1863
1922
  }
1864
1923
  }
@@ -1874,7 +1933,7 @@ loopbackPort, internalDispatch) {
1874
1933
  ...lastFailure,
1875
1934
  errorMessage: lastFailure.errorMessage ?? lastErrorMessage,
1876
1935
  });
1877
- return buildCodexErrorResponse(lastErrorStatus, lastErrorMessage);
1936
+ return buildCodexErrorResponse(lastErrorStatus, lastErrorMessage, requestRefused ? lastErrorCode : undefined);
1878
1937
  };
1879
1938
  try {
1880
1939
  return await dispatch();
@@ -29,8 +29,17 @@ export { createTranscribeRoutes } from "./transcribeRoutes.js";
29
29
  export declare function createAllRoutes(basePath?: string, options?: CreateRoutesOptions): RouteGroup[];
30
30
  /**
31
31
  * Register all routes with a server adapter
32
+ *
33
+ * With `options.perUserAuth` this also turns on the adapter's
34
+ * `carryMiddlewareState` (`enableMiddlewareStateCarry()`): the agent routes can
35
+ * only take identity from `ctx.user` if the adapter hands the handler what
36
+ * authentication middleware set. An adapter without that method must carry
37
+ * state itself; otherwise the routes see a user only where the adapter happens
38
+ * to share its context (Hono, when the client sent its own `X-Request-ID`), and
39
+ * every other request runs as anonymous.
32
40
  */
33
41
  export declare function registerAllRoutes(adapter: {
34
42
  registerRouteGroup: (group: RouteGroup) => void;
35
43
  listRoutes?: () => RouteDefinition[];
44
+ enableMiddlewareStateCarry?: () => void;
36
45
  }, basePath?: string, options?: CreateRoutesOptions): void;
@@ -41,7 +41,10 @@ export { createTranscribeRoutes } from "./transcribeRoutes.js";
41
41
  */
42
42
  export function createAllRoutes(basePath = "/api", options) {
43
43
  const routes = [
44
- createAgentRoutes(basePath, options?.agent),
44
+ createAgentRoutes(basePath, {
45
+ ...options?.agent,
46
+ perUserAuth: options?.perUserAuth,
47
+ }),
45
48
  createToolRoutes(basePath),
46
49
  createMCPRoutes(basePath),
47
50
  createMemoryRoutes(basePath),
@@ -76,8 +79,19 @@ export function createAllRoutes(basePath = "/api", options) {
76
79
  }
77
80
  /**
78
81
  * Register all routes with a server adapter
82
+ *
83
+ * With `options.perUserAuth` this also turns on the adapter's
84
+ * `carryMiddlewareState` (`enableMiddlewareStateCarry()`): the agent routes can
85
+ * only take identity from `ctx.user` if the adapter hands the handler what
86
+ * authentication middleware set. An adapter without that method must carry
87
+ * state itself; otherwise the routes see a user only where the adapter happens
88
+ * to share its context (Hono, when the client sent its own `X-Request-ID`), and
89
+ * every other request runs as anonymous.
79
90
  */
80
91
  export function registerAllRoutes(adapter, basePath = "/api", options) {
92
+ if (options?.perUserAuth) {
93
+ adapter.enableMiddlewareStateCarry?.();
94
+ }
81
95
  // If adapter has listRoutes and getRoutes not provided, use adapter's listRoutes
82
96
  const routeOptions = {
83
97
  ...options,
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Which route-handler contexts received the state the adapters now carry from
3
+ * middleware to the handler.
4
+ *
5
+ * Before the adapters carried that state, a handler normally got a fresh
6
+ * context with no `ctx.user` and no `ctx.session`. The one exception was Hono
7
+ * when the client sent its own `X-Request-ID`: Hono kept the middleware's
8
+ * context in a store keyed by that header, so the handler found it again, user
9
+ * included. The agent routes' default (`perUserAuth` unset) must not start
10
+ * trusting `ctx.user` on the requests that never had it, so the built-in
11
+ * adapters mark the contexts that received the carry, and the agent routes
12
+ * treat a marked context as the fresh one it used to be: identity from the body.
13
+ *
14
+ * Everything else keeps the earlier rule, an authenticated `ctx.user` wins: a
15
+ * Hono request with a caller-supplied `X-Request-ID` (never marked), a custom
16
+ * adapter that carries `ctx.user` itself, and a route handler called directly
17
+ * with a context the caller built.
18
+ *
19
+ * It is an internal fact about how the context was built, not part of
20
+ * `ServerContext`, and it is not exported from the package.
21
+ *
22
+ * @module server/utils/carriedContextMark
23
+ */
24
+ import type { ServerContext } from "../../types/index.js";
25
+ /** Record that this context received state carried from middleware. */
26
+ export declare function markContextCarried(ctx: ServerContext): void;
27
+ /** `true` when `markContextCarried` was called for this context. */
28
+ export declare function isContextCarried(ctx: ServerContext): boolean;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Which route-handler contexts received the state the adapters now carry from
3
+ * middleware to the handler.
4
+ *
5
+ * Before the adapters carried that state, a handler normally got a fresh
6
+ * context with no `ctx.user` and no `ctx.session`. The one exception was Hono
7
+ * when the client sent its own `X-Request-ID`: Hono kept the middleware's
8
+ * context in a store keyed by that header, so the handler found it again, user
9
+ * included. The agent routes' default (`perUserAuth` unset) must not start
10
+ * trusting `ctx.user` on the requests that never had it, so the built-in
11
+ * adapters mark the contexts that received the carry, and the agent routes
12
+ * treat a marked context as the fresh one it used to be: identity from the body.
13
+ *
14
+ * Everything else keeps the earlier rule, an authenticated `ctx.user` wins: a
15
+ * Hono request with a caller-supplied `X-Request-ID` (never marked), a custom
16
+ * adapter that carries `ctx.user` itself, and a route handler called directly
17
+ * with a context the caller built.
18
+ *
19
+ * It is an internal fact about how the context was built, not part of
20
+ * `ServerContext`, and it is not exported from the package.
21
+ *
22
+ * @module server/utils/carriedContextMark
23
+ */
24
+ const receivedCarry = new WeakSet();
25
+ /** Record that this context received state carried from middleware. */
26
+ export function markContextCarried(ctx) {
27
+ receivedCarry.add(ctx);
28
+ }
29
+ /** `true` when `markContextCarried` was called for this context. */
30
+ export function isContextCarried(ctx) {
31
+ return receivedCarry.has(ctx);
32
+ }
@@ -1068,6 +1068,10 @@ export type CodexRotationFailure = {
1068
1068
  lastErrorStatus: number;
1069
1069
  lastErrorCode: string | undefined;
1070
1070
  lastErrorMessage: string;
1071
+ /** The upstream refused the request itself (a policy or invalid-request
1072
+ * code), not the account: no other account would answer differently, so the
1073
+ * pool ends the request here instead of rotating. */
1074
+ refusedRequest?: boolean;
1071
1075
  };
1072
1076
  /** Minimal persistence contract needed by Codex rotating-token refreshes. */
1073
1077
  export type CodexRefreshTokenStore = {