agl 40.4.0 → 40.5.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.
Files changed (156) hide show
  1. package/changelog.md +33 -0
  2. package/dist_serve/bundle.js +2228 -2177
  3. package/dist_serve/bundle.js.map +2 -2
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/aglapi/aglapisurface.document.js +2 -2
  6. package/dist_ts/classes.accountsactions.d.ts +11 -1
  7. package/dist_ts/classes.accountsactions.js +59 -2
  8. package/dist_ts/classes.controller.js +5 -5
  9. package/dist_ts/classes.flexaccountaccess.js +3 -2
  10. package/dist_ts/classes.harnessreleases.d.ts +14 -4
  11. package/dist_ts/classes.harnessreleases.js +28 -4
  12. package/dist_ts/classes.inferencegateway.d.ts +28 -3
  13. package/dist_ts/classes.inferencegateway.js +177 -61
  14. package/dist_ts/classes.inferenceplacement.d.ts +5 -3
  15. package/dist_ts/classes.inferenceplacement.js +2 -2
  16. package/dist_ts/classes.inferencerouter.d.ts +349 -91
  17. package/dist_ts/classes.inferencerouter.js +804 -297
  18. package/dist_ts/classes.inferenceservice.d.ts +14 -2
  19. package/dist_ts/classes.inferenceservice.js +53 -14
  20. package/dist_ts/classes.inferencespend.d.ts +339 -0
  21. package/dist_ts/classes.inferencespend.js +815 -0
  22. package/dist_ts/classes.inferenceusagecache.d.ts +8 -0
  23. package/dist_ts/classes.inferenceusagecache.js +9 -5
  24. package/dist_ts/classes.inferenceusagetap.d.ts +50 -9
  25. package/dist_ts/classes.inferenceusagetap.js +185 -31
  26. package/dist_ts/classes.proxycontrol.d.ts +3 -2
  27. package/dist_ts/classes.proxycontrol.js +1 -2
  28. package/dist_ts/classes.proxydaemon.js +3 -2
  29. package/dist_ts/classes.proxystore.d.ts +6 -0
  30. package/dist_ts/classes.proxystore.js +11 -2
  31. package/dist_ts/fleet/classes.claudeworkers.d.ts +5 -4
  32. package/dist_ts/fleet/classes.claudeworkers.js +10 -9
  33. package/dist_ts/functions.claudecoderelease.d.ts +12 -6
  34. package/dist_ts/functions.claudecoderelease.js +13 -7
  35. package/dist_ts/functions.inferencecli.js +12 -8
  36. package/dist_ts/interfaces.auth.d.ts +1 -1
  37. package/dist_ts/interfaces.auth.js +3 -1
  38. package/dist_ts/plugins.d.ts +3 -1
  39. package/dist_ts/plugins.js +7 -2
  40. package/dist_ts_authswitch/authority-apikeys.d.ts +32 -0
  41. package/dist_ts_authswitch/authority-apikeys.js +65 -0
  42. package/dist_ts_authswitch/authority-contract.d.ts +172 -33
  43. package/dist_ts_authswitch/authority-contract.js +54 -1
  44. package/dist_ts_authswitch/authority-overview.js +8 -1
  45. package/dist_ts_authswitch/authority-placement.d.ts +7 -2
  46. package/dist_ts_authswitch/authority-placement.js +10 -3
  47. package/dist_ts_authswitch/authority-runtime-contract.d.ts +2 -2
  48. package/dist_ts_authswitch/authority-spend.d.ts +103 -0
  49. package/dist_ts_authswitch/authority-spend.js +110 -0
  50. package/dist_ts_authswitch/authority-subscription.d.ts +12 -1
  51. package/dist_ts_authswitch/authority-subscription.js +24 -1
  52. package/dist_ts_authswitch/authority-usagelimit.d.ts +7 -4
  53. package/dist_ts_authswitch/authority-usagelimit.js +89 -39
  54. package/dist_ts_authswitch/authority-usagewindows.d.ts +4 -1
  55. package/dist_ts_authswitch/authority-usagewindows.js +41 -16
  56. package/dist_ts_authswitch/classes.authoritybroker.d.ts +30 -19
  57. package/dist_ts_authswitch/classes.authoritybroker.js +152 -61
  58. package/dist_ts_authswitch/classes.authoritycli.d.ts +9 -4
  59. package/dist_ts_authswitch/classes.authoritycli.js +99 -30
  60. package/dist_ts_authswitch/classes.authorityclient.d.ts +24 -7
  61. package/dist_ts_authswitch/classes.authorityclient.js +45 -15
  62. package/dist_ts_authswitch/classes.authoritydaemon.js +39 -12
  63. package/dist_ts_authswitch/classes.authoritydatabase.d.ts +28 -5
  64. package/dist_ts_authswitch/classes.authoritydatabase.js +171 -9
  65. package/dist_ts_authswitch/classes.authorityimport.js +2 -2
  66. package/dist_ts_authswitch/classes.authoritymodels.d.ts +71 -3
  67. package/dist_ts_authswitch/classes.authoritymodels.js +249 -20
  68. package/dist_ts_authswitch/classes.authorityregistry.js +4 -2
  69. package/dist_ts_authswitch/classes.authorityusage.d.ts +69 -7
  70. package/dist_ts_authswitch/classes.authorityusage.js +118 -4
  71. package/dist_ts_authswitch/classes.claudenative.d.ts +23 -8
  72. package/dist_ts_authswitch/classes.claudenative.js +49 -21
  73. package/dist_ts_authswitch/classes.claudesignin.d.ts +14 -2
  74. package/dist_ts_authswitch/classes.claudesignin.js +72 -23
  75. package/dist_ts_authswitch/classes.codexmanaged.d.ts +8 -0
  76. package/dist_ts_authswitch/classes.codexmanaged.js +33 -18
  77. package/dist_ts_authswitch/classes.harnessprocesses.d.ts +8 -0
  78. package/dist_ts_authswitch/classes.harnessprocesses.js +41 -9
  79. package/dist_ts_authswitch/testsupport.d.ts +24 -5
  80. package/dist_ts_authswitch/testsupport.js +56 -12
  81. package/dist_ts_authswitch/ts_migration/0011_account_api_key_fields.d.ts +12 -0
  82. package/dist_ts_authswitch/ts_migration/0011_account_api_key_fields.js +21 -0
  83. package/dist_ts_authswitch/ts_migration/index.js +3 -1
  84. package/dist_ts_interfaces/functions.authorityscope.d.ts +18 -7
  85. package/dist_ts_interfaces/functions.authorityscope.js +35 -8
  86. package/dist_ts_interfaces/interfaces.d.ts +1 -1
  87. package/dist_ts_interfaces/interfaces.js +1 -1
  88. package/dist_ts_interfaces/plugins.d.ts +2 -1
  89. package/dist_ts_interfaces/plugins.js +7 -4
  90. package/dist_ts_interfaces/requests.accounts.d.ts +56 -0
  91. package/dist_ts_interfaces/requests.accounts.js +11 -1
  92. package/dist_ts_interfaces/requests.d.ts +2 -2
  93. package/dist_ts_interfaces/requests.js +1 -1
  94. package/docs/authswitch.md +75 -12
  95. package/package.json +5 -5
  96. package/readme.md +199 -22
  97. package/ts/00_commitinfo_data.ts +1 -1
  98. package/ts/aglapi/aglapisurface.document.ts +2 -2
  99. package/ts/classes.accountsactions.ts +75 -1
  100. package/ts/classes.controller.ts +4 -4
  101. package/ts/classes.flexaccountaccess.ts +2 -1
  102. package/ts/classes.harnessreleases.ts +25 -4
  103. package/ts/classes.inferencegateway.ts +184 -64
  104. package/ts/classes.inferenceplacement.ts +6 -4
  105. package/ts/classes.inferencerouter.ts +1026 -314
  106. package/ts/classes.inferenceservice.ts +56 -13
  107. package/ts/classes.inferencespend.ts +870 -0
  108. package/ts/classes.inferenceusagecache.ts +16 -4
  109. package/ts/classes.inferenceusagetap.ts +159 -34
  110. package/ts/classes.proxycontrol.ts +3 -3
  111. package/ts/classes.proxydaemon.ts +2 -1
  112. package/ts/classes.proxystore.ts +11 -1
  113. package/ts/fleet/classes.claudeworkers.ts +9 -8
  114. package/ts/functions.claudecoderelease.ts +13 -6
  115. package/ts/functions.inferencecli.ts +11 -7
  116. package/ts/interfaces.auth.ts +2 -0
  117. package/ts/plugins.ts +7 -0
  118. package/ts_authswitch/authority-apikeys.ts +85 -0
  119. package/ts_authswitch/authority-contract.ts +201 -34
  120. package/ts_authswitch/authority-overview.ts +7 -0
  121. package/ts_authswitch/authority-placement.ts +12 -4
  122. package/ts_authswitch/authority-runtime-contract.ts +2 -2
  123. package/ts_authswitch/authority-spend.ts +168 -0
  124. package/ts_authswitch/authority-subscription.ts +28 -1
  125. package/ts_authswitch/authority-usagelimit.ts +95 -40
  126. package/ts_authswitch/authority-usagewindows.ts +42 -17
  127. package/ts_authswitch/classes.authoritybroker.ts +161 -68
  128. package/ts_authswitch/classes.authoritycli.ts +112 -32
  129. package/ts_authswitch/classes.authorityclient.ts +66 -19
  130. package/ts_authswitch/classes.authoritydaemon.ts +43 -13
  131. package/ts_authswitch/classes.authoritydatabase.ts +178 -8
  132. package/ts_authswitch/classes.authorityimport.ts +1 -1
  133. package/ts_authswitch/classes.authoritymodels.ts +165 -19
  134. package/ts_authswitch/classes.authorityregistry.ts +3 -0
  135. package/ts_authswitch/classes.authorityusage.ts +171 -10
  136. package/ts_authswitch/classes.claudenative.ts +51 -22
  137. package/ts_authswitch/classes.claudesignin.ts +74 -24
  138. package/ts_authswitch/classes.codexmanaged.ts +30 -16
  139. package/ts_authswitch/classes.harnessprocesses.ts +39 -8
  140. package/ts_authswitch/native-contracts.md +82 -21
  141. package/ts_authswitch/readme.md +1 -1
  142. package/ts_authswitch/testsupport.ts +58 -11
  143. package/ts_authswitch/ts_migration/0011_account_api_key_fields.ts +20 -0
  144. package/ts_authswitch/ts_migration/index.ts +2 -0
  145. package/ts_interfaces/functions.authorityscope.ts +43 -8
  146. package/ts_interfaces/interfaces.ts +1 -1
  147. package/ts_interfaces/plugins.ts +6 -3
  148. package/ts_interfaces/requests.accounts.ts +48 -0
  149. package/ts_interfaces/requests.ts +4 -0
  150. package/ts_web/00_commitinfo_data.ts +1 -1
  151. package/ts_web/accounts.plugins.ts +7 -1
  152. package/ts_web/accounts.templates.ts +20 -11
  153. package/ts_web/classes.accountsreadmodel.ts +326 -71
  154. package/ts_web/elements.accounts.ts +219 -20
  155. package/ts_web/elements.codexconnections.ts +3 -1
  156. package/ts_web/plugins.ts +4 -2
@@ -1,12 +1,17 @@
1
1
  import * as plugins from './plugins.js';
2
- import { inferenceCredentials, inferencePlanPattern, inferenceProviders } from './functions.proxycontract.js';
3
- import { InferenceRouter, InferenceRoutingError, type IInferenceClaudeRoute, type IInferenceRoute,
4
- type IInferenceRoutingPolicy } from './classes.inferencerouter.js';
2
+ import { inferenceCredentials, inferencePlanPattern, inferenceProviders, type TInferenceCredential,
3
+ type TInferenceProvider } from './functions.proxycontract.js';
4
+ import { InferenceRouter, InferenceRoutingError, inferenceSpendLimitText, type IInferenceAccountUnavailable,
5
+ type IInferenceClaudeRoute, type IInferenceRoute, type IInferenceRoutingPolicy,
6
+ type TInferencePaymentCause } from './classes.inferencerouter.js';
5
7
  import { inferenceInputAffinityKeys, inferenceInputStateKeys, inferenceRequestForAccount, InferenceOutputError,
6
8
  inspectInferenceOutput, type IInferenceAffinityStore } from './classes.inferenceaffinity.js';
7
9
  import { inferenceSessionId, inferenceSessionKey } from './classes.inferencesession.js';
8
10
  import type { IInferencePlacementJournal, IInferencePlacementRecord, TInferencePlacementReason } from './classes.inferenceplacement.js';
9
11
  import { InferenceClaudeUsageTap, InferenceResponsesUsageTap, type IInferenceUsageTap } from './classes.inferenceusagetap.js';
12
+ import { inferenceAnthropicCheckedBytes, inferenceAnthropicServerToolTypes, inferenceAnthropicSpend,
13
+ inferenceAnthropicUnpricedRequest, inferenceOpenAiCheckedBytes, inferenceOpenAiSpend,
14
+ inferenceOpenAiUnpricedRequest } from './classes.inferencespend.js';
10
15
  import { usageSessionPattern, type IInferenceTokenCounts, type IInferenceUsageLedger } from './classes.inferenceusage.js';
11
16
 
12
17
  /**
@@ -281,9 +286,11 @@ const errorResponse = (errorArg: InferenceRoutingError, protocolArg: TProtocol |
281
286
  }
282
287
  if (protocolArg === 'responses' && errorArg.code === 'accounts_exhausted') {
283
288
  const planType = codexPlanType(errorArg.plans);
289
+ // The OpenAI API keys held by their spend limit are named, as every refusal names them.
290
+ const spendLimited = inferenceSpendLimitText(errorArg.spendLimited);
284
291
  return Response.json({ error: {
285
292
  type: 'usage_limit_reached', code: 'usage_limit_reached',
286
- message: 'Every eligible account in the AGL proxy pool has reached its usage limit.',
293
+ message: `Every eligible account in the AGL proxy pool has reached its usage limit.${spendLimited ? ` ${spendLimited}` : ''}`,
287
294
  ...(errorArg.retryAfterSeconds ? { resets_at: Math.ceil(Date.now() / 1000) + errorArg.retryAfterSeconds } : {}),
288
295
  ...(planType ? { plan_type: planType } : {}),
289
296
  } }, { status: 429, headers: errorHeaders(errorArg) });
@@ -315,14 +322,58 @@ const readErrorBody = async (responseArg: Response): Promise<string | undefined>
315
322
  } catch { return undefined; }
316
323
  };
317
324
 
325
+ /** OpenAI's `error.code` values of a 429 that no retry heals: no prepaid credit left, or a spend or usage limit reached. */
326
+ const openAiQuotaCodes = new Set(['credit_balance_exhausted', 'organization_spend_limit_exceeded',
327
+ 'project_spend_limit_exceeded', 'organization_usage_limit_exceeded']);
328
+
329
+ /**
330
+ * The payment problem a provider's refusal states about the account the request went on, from its status and its JSON
331
+ * body (`TInferencePaymentCause`), or `undefined` for any other answer. As the providers document them (read 2026-10-21):
332
+ * Anthropic answers a billing problem with HTTP 402 `error.type: billing_error`
333
+ * (https://platform.claude.com/docs/en/api/errors), and an API key's organization at its usage tier's monthly spend cap
334
+ * with HTTP 429 `error.type: rate_limit_error` and `error.details.error_code: enforced_spend_limit_reached`, no
335
+ * `retry-after`, until 00:00 UTC on the first of the next month
336
+ * (https://platform.claude.com/docs/en/api/rate-limits#reaching-your-spend-cap). OpenAI answers an API key out of quota
337
+ * with HTTP 429 whose `error.code` names the cause (`credit_balance_exhausted`, `organization_spend_limit_exceeded`,
338
+ * `project_spend_limit_exceeded`, `organization_usage_limit_exceeded`) and whose `error.type` can be
339
+ * `insufficient_quota`; retrying does not help (https://developers.openai.com/api/docs/guides/error-codes). A Claude
340
+ * account's subscription states only the billing error; a spend limit set in the Claude Console (HTTP 400
341
+ * `invalid_request_error`) is not read as one.
342
+ */
343
+ export const inferencePaymentCause = (providerArg: TInferenceProvider, credentialArg: TInferenceCredential,
344
+ statusArg: number, bodyArg: unknown): TInferencePaymentCause | undefined => {
345
+ const error = record(bodyArg) && record(bodyArg.error) ? bodyArg.error : undefined;
346
+ if (!error) return undefined;
347
+ if (providerArg === 'anthropic') {
348
+ if (statusArg === 402 && error.type === 'billing_error') return 'billing';
349
+ if (credentialArg === 'api_key' && statusArg === 429 && error.type === 'rate_limit_error' && record(error.details)
350
+ && error.details.error_code === 'enforced_spend_limit_reached') return 'spend_cap';
351
+ return undefined;
352
+ }
353
+ if (credentialArg === 'api_key' && statusArg === 429
354
+ && ((typeof error.code === 'string' && openAiQuotaCodes.has(error.code)) || error.type === 'insufficient_quota')) {
355
+ return 'quota';
356
+ }
357
+ return undefined;
358
+ };
359
+
318
360
  /**
319
- * A Claude account's HTTP 402, read up to `maximumErrorBodyBytes`: `billing` when its body is JSON whose `error.type` is
320
- * `billing_error`, a payment problem of the account. `body` is what the client gets otherwise, byte for byte: the
321
- * buffered body, or for one over the bound the bytes read so far followed by the rest of the stream.
361
+ * Whether a provider answer on the route may state a payment problem of its account (`inferencePaymentCause`), so the
362
+ * gateway reads its body before a byte reaches the client: a Claude account's 402, an API key's 402 or 429.
322
363
  */
323
- const readPaymentRequired = async (responseArg: Response): Promise<{ billing: boolean; body: BodyInit | null }> => {
364
+ const mayStatePaymentProblem = (routeArg: IInferenceRoute | IInferenceClaudeRoute, statusArg: number): boolean =>
365
+ routeArg.credential === 'api_key' ? statusArg === 429 || (statusArg === 402 && routeArg.provider === 'anthropic')
366
+ : statusArg === 402 && routeArg.provider === 'anthropic';
367
+
368
+ /**
369
+ * A provider refusal that may state a payment problem of the route's account, read up to `maximumErrorBodyBytes`: its
370
+ * cause when its JSON body states one (`inferencePaymentCause`). `body` is what the client gets otherwise, byte for byte:
371
+ * the buffered body, or for one over the bound the bytes read so far followed by the rest of the stream.
372
+ */
373
+ const readPaymentRefusal = async (responseArg: Response, routeArg: IInferenceRoute | IInferenceClaudeRoute):
374
+ Promise<{ cause: TInferencePaymentCause | undefined; body: BodyInit | null }> => {
324
375
  const reader = responseArg.body?.getReader();
325
- if (!reader) return { billing: false, body: null };
376
+ if (!reader) return { cause: undefined, body: null };
326
377
  const chunks: Uint8Array[] = [];
327
378
  let size = 0;
328
379
  try {
@@ -333,7 +384,7 @@ const readPaymentRequired = async (responseArg: Response): Promise<{ billing: bo
333
384
  size += value.byteLength;
334
385
  if (size > maximumErrorBodyBytes) {
335
386
  const read = chunks.splice(0);
336
- return { billing: false, body: new ReadableStream<Uint8Array>({
387
+ return { cause: undefined, body: new ReadableStream<Uint8Array>({
337
388
  start: (controllerArg) => { for (const chunk of read) controllerArg.enqueue(chunk); },
338
389
  pull: async (controllerArg) => {
339
390
  const next = await reader.read();
@@ -348,12 +399,11 @@ const readPaymentRequired = async (responseArg: Response): Promise<{ billing: bo
348
399
  throw errorArg;
349
400
  }
350
401
  const bytes = Buffer.concat(chunks);
351
- let billing = false;
402
+ let cause: TInferencePaymentCause | undefined;
352
403
  try {
353
- const parsed: unknown = JSON.parse(bytes.toString('utf8'));
354
- billing = record(parsed) && record(parsed.error) && parsed.error.type === 'billing_error';
404
+ cause = inferencePaymentCause(routeArg.provider, routeArg.credential, responseArg.status, JSON.parse(bytes.toString('utf8')));
355
405
  } catch { /* Not JSON: passed on as it came. */ }
356
- return { billing, body: bytes };
406
+ return { cause, body: bytes };
357
407
  };
358
408
 
359
409
  const readBody = async (requestArg: Request, maximumArg: number): Promise<{ bytes: Uint8Array<ArrayBuffer>; body: Record<string, unknown> }> => {
@@ -383,13 +433,14 @@ const readBody = async (requestArg: Request, maximumArg: number): Promise<{ byte
383
433
  };
384
434
 
385
435
  /**
386
- * A routing policy as the gateway admits it: a known provider and credential, an API-key pool's provider Anthropic and
387
- * no plan restriction on it -- a key has no plan -- and bounded account and plan lists.
436
+ * A routing policy as the gateway admits it: a known provider and credential, no plan restriction on an API-key pool --
437
+ * a key has no plan -- and bounded account and plan lists. An API-key pool is either provider's: Anthropic's keys serve
438
+ * Claude Code and Flex, OpenAI's serve Responses requests.
388
439
  */
389
440
  export const assertInferenceRoutingPolicy = (policyArg: IInferenceRoutingPolicy): void => {
390
441
  if (policyArg.provider !== undefined && !inferenceProviders.includes(policyArg.provider)
391
442
  || policyArg.credential !== undefined && !inferenceCredentials.includes(policyArg.credential)
392
- || policyArg.credential === 'api_key' && (policyArg.provider !== 'anthropic' || policyArg.plans !== undefined)
443
+ || policyArg.credential === 'api_key' && policyArg.plans !== undefined
393
444
  || policyArg.accountIds && (policyArg.accountIds.length > 64 || policyArg.accountIds.some((id) => !/^[a-f0-9]{64}$/.test(id)))
394
445
  || policyArg.plans && (policyArg.plans.length > 32 || policyArg.plans.some((plan) => !inferencePlanPattern.test(plan)))) {
395
446
  throw new Error('The inference routing policy is invalid.');
@@ -667,31 +718,47 @@ export class InferenceGateway {
667
718
  return await this.claudeCountTokens(admitted, sessionKey, model, bytes, claudeSearch, request.headers, signal,
668
719
  (routeArg) => { releaseRoute = routeArg.release; });
669
720
  }
721
+ // What of the request has no price: an API key with a monthly spend limit takes no such request. Only a Responses
722
+ // request goes to an OpenAI API key.
723
+ const unpriced = claude ? inferenceAnthropicUnpricedRequest(body)
724
+ : protocol === 'responses' ? inferenceOpenAiUnpricedRequest(body) : undefined;
670
725
  // The catalog tag is read alongside the provider request; the pool's catalogs are cached for a minute.
671
726
  const catalogTagTask = protocol === 'responses' ? this.currentCatalogTag(client, signal) : Promise.resolve(undefined);
672
727
  catalogTagTask.catch(() => undefined);
673
728
  const excluded = new Set<string>();
674
- /** The accounts of `excluded` this request left for a payment problem, which never count as at their limit. */
675
- const paymentExcluded = new Set<string>();
676
729
  /**
677
- * The last billing error (HTTP 402) a Claude account answered this request with, its bytes as they came: the answer
678
- * when no other account can take the request for a reason other than a limit.
730
+ * The accounts of `excluded` this request left for a reason of their own -- a payment problem, unreadable usage or
731
+ * spend, no model list, sign-in or access -- and why; they never count as at their limit.
679
732
  */
680
- let billingAnswer: (() => Response) | undefined;
733
+ const excludedBecause = new Map<string, IInferenceAccountUnavailable>();
734
+ /**
735
+ * The last payment refusal an account answered this request with -- a billing error (HTTP 402), an API key's spend
736
+ * cap or quota (HTTP 429) -- its bytes as they came: the answer when no other account can take the request for a
737
+ * reason other than a limit.
738
+ */
739
+ let paymentAnswer: (() => Response) | undefined;
681
740
  /** Why each account this request left was left, for the placement journal. */
682
741
  const leftBecause = new Map<string, TInferencePlacementReason>();
683
742
  const exhaustedPlans = new Set<string>();
684
743
  let response: Response;
685
744
  let quotaError: InferenceRoutingError | undefined;
686
745
  const acquire = (accountIdArg: string | undefined): Promise<IInferenceRoute | IInferenceClaudeRoute> => claude
687
- ? this.options.router.acquireClaude(model, admitted.policy, signal, { exclude: excluded, paymentExcluded,
688
- ...(accountIdArg === undefined ? {} : { accountId: accountIdArg }) })
746
+ ? this.options.router.acquireClaude(model, admitted.policy, signal, { exclude: excluded, excludedBecause,
747
+ ...(unpriced === undefined ? {} : { unpriced }),
748
+ // A Claude session carries no state bound to its account: one placed on an API key returns to a Claude account
749
+ // as soon as one can take its request.
750
+ ...(accountIdArg === undefined ? {} : { accountId: accountIdArg, returnToBetterTier: true }) })
689
751
  : this.options.router.acquire(model, admitted.policy, signal, {
690
- exclude: excluded,
752
+ exclude: excluded, excludedBecause,
691
753
  ...(record(body.reasoning) && typeof body.reasoning.effort === 'string' ? { reasoningEffort: body.reasoning.effort } : {}),
692
754
  ...(typeof body.service_tier === 'string' ? { serviceTier: body.service_tier } : {}),
693
755
  ...(admitted.codexClientVersion ? { clientVersion: admitted.codexClientVersion } : {}),
694
- ...(accountIdArg === undefined ? {} : { accountId: accountIdArg }),
756
+ // An OpenAI API key takes Responses requests alone, never a translated Messages request.
757
+ ...(protocol === 'responses' ? { apiKeys: true } : {}),
758
+ ...(unpriced === undefined ? {} : { unpriced }),
759
+ // A Responses session placed on an API key returns to a ChatGPT account when one can take its request, but never
760
+ // while the request carries state bound to its account.
761
+ ...(accountIdArg === undefined ? {} : { accountId: accountIdArg, returnToBetterTier: !inferenceRequestHasAccountState(body) }),
695
762
  });
696
763
  // At most 64 accounts can be tried; bound contention retries separately as well.
697
764
  for (let decision = 0; decision < 128; decision++) {
@@ -712,9 +779,10 @@ export class InferenceGateway {
712
779
  const exhausted = errorArg instanceof InferenceRoutingError && errorArg.code === 'accounts_exhausted';
713
780
  // A session moves when its account is at its limit, no longer in the pool at all, outside the client's
714
781
  // account restriction, or lapsed; a Claude session also when its account takes no request for a reason of
715
- // its own. ChatGPT conversation state stays with the account that holds it.
716
- const left = sessionAccountOutsidePool(errorArg)
717
- || (claude && preferred !== undefined && sessionAccountUnavailable(errorArg, preferred));
782
+ // its own, and a Responses session when its account -- an OpenAI API key -- is held with a payment problem.
783
+ // Any other refusal of its own keeps Responses conversation state with the account that holds it.
784
+ const left = sessionAccountOutsidePool(errorArg) || (preferred !== undefined && sessionAccountUnavailable(errorArg, preferred)
785
+ && (claude || (errorArg as InferenceRoutingError).reason === 'payment_problem'));
718
786
  if (!preferred || !(errorArg instanceof InferenceRoutingError) || (!exhausted && !left && required)) throw errorArg;
719
787
  if (exhausted) {
720
788
  quotaError = errorArg;
@@ -723,20 +791,28 @@ export class InferenceGateway {
723
791
  if (exhausted || left) {
724
792
  excluded.add(preferred);
725
793
  if (!leftBecause.has(preferred)) leftBecause.set(preferred, placementReason(errorArg));
726
- if (errorArg.reason === 'payment_problem') paymentExcluded.add(preferred);
794
+ if (errorArg.unavailable) excludedBecause.set(preferred, errorArg.unavailable);
727
795
  }
728
796
  try {
729
797
  route = await acquire(undefined);
730
798
  } catch (acquireErrorArg) {
731
799
  const limited = acquireErrorArg instanceof InferenceRoutingError && acquireErrorArg.code === 'accounts_exhausted';
732
800
  if (limited && quotaError) {
733
- throw new InferenceRoutingError(429, 'accounts_exhausted', 'The eligible accounts reached their provider limits.',
734
- acquireErrorArg.retryAfterSeconds ?? quotaError.retryAfterSeconds, [...exhaustedPlans, ...acquireErrorArg.plans]);
801
+ // The API keys either refusal found held by their spend limit are named: the session's own, which the
802
+ // second judgement left out, among them.
803
+ const spendLimited = [...quotaError.spendLimited, ...acquireErrorArg.spendLimited];
804
+ throw new InferenceRoutingError(429, 'accounts_exhausted', spendLimited.length
805
+ ? `The eligible accounts reached their limits. ${inferenceSpendLimitText(spendLimited)}`
806
+ : 'The eligible accounts reached their provider limits.',
807
+ acquireErrorArg.retryAfterSeconds ?? quotaError.retryAfterSeconds, [...exhaustedPlans, ...acquireErrorArg.plans],
808
+ undefined, undefined, undefined, spendLimited);
735
809
  }
736
- // No other account takes the request for a reason other than a limit: the client gets Anthropic's own 402.
737
- if (!limited && billingAnswer) {
738
- this.reportRequestFailure({ status: 402, code: 'provider_error', protocol });
739
- return billingAnswer();
810
+ // No other account takes the request for a reason other than a limit: the client gets the provider's own
811
+ // payment refusal.
812
+ if (!limited && paymentAnswer) {
813
+ const answer = paymentAnswer();
814
+ this.reportRequestFailure({ status: answer.status, code: 'provider_error', protocol });
815
+ return answer;
740
816
  }
741
817
  throw acquireErrorArg;
742
818
  }
@@ -752,37 +828,40 @@ export class InferenceGateway {
752
828
  if (binding?.accountId !== route.accountId) {
753
829
  this.recordPlacement({ sessionKey, provider: route.provider, fromAccountId: binding?.accountId ?? null,
754
830
  toAccountId: route.accountId, score: route.score,
755
- reason: binding ? leftBecause.get(binding.accountId) ?? 'left_pool' : 'new' });
831
+ reason: route.returned ? 'returned' : binding ? leftBecause.get(binding.accountId) ?? 'left_pool' : 'new' });
756
832
  }
757
- // Claude Code's own bytes go to Anthropic unchanged.
833
+ // Claude Code's own bytes go to Anthropic unchanged, except that an API key with a monthly spend limit gets the body
834
+ // the price check read, asking for the standard service tier only, the one the price table prices.
758
835
  response = route.provider === 'anthropic'
759
- ? await this.claudeMessages(route, pathname, claudeSearch, request.headers, bytes, signal)
836
+ ? await this.claudeMessages(route, pathname, claudeSearch, request.headers,
837
+ route.spendLimit ? inferenceAnthropicCheckedBytes(body) : bytes, signal)
760
838
  : protocol === 'messages'
761
839
  ? await this.messages(new Request(new URL(pathname, request.url), { method: 'POST', headers: request.headers, signal }),
762
840
  prepared.body ?? body, route)
763
841
  : await this.responses(prepared.body ?? body, route, request.headers, signal);
764
- if (route.provider === 'anthropic' && route.credential === 'subscription' && response.status === 402) {
765
- // A billing error is a payment problem of the account: it leaves rotation, and the request goes to another
766
- // account before a byte reaches the client, never to this one again. Any other 402 passes as it came.
767
- const paymentRequired = await readPaymentRequired(response);
768
- if (paymentRequired.billing) {
769
- this.options.router.reportPaymentProblem(route.accountId);
842
+ if (mayStatePaymentProblem(route, response.status)) {
843
+ // A payment problem of the account -- a billing error, or an API key's spend cap or quota -- takes it out of
844
+ // rotation, and the request goes to another account before a byte reaches the client, never to this one
845
+ // again. Any other answer passes as it came.
846
+ const refusal = await readPaymentRefusal(response, route);
847
+ if (refusal.cause) {
848
+ const unavailable = this.options.router.reportPaymentProblem(route.accountId, refusal.cause);
770
849
  const answered = route;
771
- const answer = { body: paymentRequired.body, headers: new Headers(response.headers) };
772
- billingAnswer = () => {
850
+ const answer = { status: response.status, body: refusal.body, headers: new Headers(response.headers) };
851
+ paymentAnswer = () => {
773
852
  answer.headers.set('cache-control', 'no-store');
774
853
  answer.headers.set('x-agl-account-id', answered.accountId);
775
854
  answer.headers.set('x-agl-plan', answered.plan);
776
- return new Response(answer.body, { status: 402, headers: answer.headers });
855
+ return new Response(answer.body, { status: answer.status, headers: answer.headers });
777
856
  };
778
857
  route.release();
779
858
  releaseRoute = undefined;
780
859
  excluded.add(route.accountId);
781
- paymentExcluded.add(route.accountId);
860
+ excludedBecause.set(route.accountId, unavailable);
782
861
  leftBecause.set(route.accountId, 'unavailable');
783
862
  continue;
784
863
  }
785
- response = new Response(paymentRequired.body, { status: 402, headers: response.headers });
864
+ response = new Response(refusal.body, { status: response.status, headers: response.headers });
786
865
  }
787
866
  if (response.status === 429) {
788
867
  const seconds = this.options.router.retryAfterSeconds(response.headers);
@@ -830,12 +909,23 @@ export class InferenceGateway {
830
909
  const recordUsage = this.usageRecorder(client, route, sessionKey);
831
910
  const answerFormat = (response.headers.get('content-type') ?? '').toLowerCase().startsWith('text/event-stream') ? 'sse' : 'json';
832
911
  const tap: IInferenceUsageTap | undefined = !response.ok || pathname === '/v1/messages/count_tokens' ? undefined
833
- : protocol === 'responses' ? new InferenceResponsesUsageTap(body.stream === true ? 'sse' : 'json', recordUsage)
912
+ : protocol === 'responses' ? new InferenceResponsesUsageTap(body.stream === true ? 'sse' : 'json', recordUsage,
913
+ // An OpenAI API key's answer is priced as it ends; one cut before OpenAI stated its usage counts nowhere, and the
914
+ // journal says so.
915
+ route.credential === 'api_key' ? (answeredArg) => {
916
+ const spent = inferenceOpenAiSpend(answeredArg, route.model.modelId, Date.now());
917
+ if (spent) this.options.router.recordSpend(accountId, route.loginId, spent);
918
+ else this.options.router.uncountedSpend(accountId, answeredArg.model ?? route.model.modelId);
919
+ } : undefined)
834
920
  : new InferenceClaudeUsageTap(answerFormat, (usageArg) => {
835
921
  if (route.provider === 'anthropic') this.options.router.countTokens(accountId, usageArg);
836
922
  recordUsage({ inputTokens: usageArg.inputTokens, outputTokens: usageArg.outputTokens,
837
923
  cacheReadTokens: usageArg.cacheReadTokens, cacheWriteTokens: usageArg.cacheWrite5mTokens + usageArg.cacheWrite1hTokens });
838
- });
924
+ // An API key's answer is priced as it ends, and its spend counts against the key's limit from then on.
925
+ }, route.provider === 'anthropic' && route.credential === 'api_key' ? (answeredArg) => {
926
+ const spent = inferenceAnthropicSpend(answeredArg, route.model.modelId, Date.now(), inferenceAnthropicServerToolTypes(body));
927
+ if (spent) this.options.router.recordSpend(accountId, route.loginId, spent);
928
+ } : undefined);
839
929
  if (!response.ok) this.reportRequestFailure({ status: response.status, code: 'provider_error', protocol });
840
930
  return this.streamResponse(inspected, headers, signal, finish,
841
931
  { protocol, accountId, responsesSse: response.ok && protocol === 'responses' && body.stream === true, ...(tap ? { tap } : {}) });
@@ -920,13 +1010,27 @@ export class InferenceGateway {
920
1010
  this.turnStates.set(id, { accountId: accountIdArg, until: Date.now() + turnStateLifetimeMs });
921
1011
  }
922
1012
 
1013
+ /**
1014
+ * Sends a Responses request on the route: to a ChatGPT account through the ChatGPT backend, normalized for it, or to an
1015
+ * OpenAI API key through OpenAI's API (`OPENAI_API_BASE_URL/responses`) on the key's own connection, as Codex sent it,
1016
+ * with only its `model` rewritten -- and for a key with a monthly spend limit, the service tier it was checked for
1017
+ * (`inferenceOpenAiCheckedBytes`). Codex's thread headers travel with it; a turn token only to the account that issued
1018
+ * it. An authorization failure is about the account the gateway chose, never the client's credential: a key OpenAI
1019
+ * rejects (401) is the proxy's to explain, in its own words.
1020
+ */
923
1021
  private async responses(bodyArg: Record<string, unknown>, routeArg: IInferenceRoute, requestHeadersArg: Headers,
924
1022
  signalArg: AbortSignal): Promise<Response> {
925
- let body: Record<string, unknown>;
926
- try {
927
- body = plugins.openAiProvider.normalizeOpenAiChatGptResponsesRequest(bodyArg);
928
- } catch {
929
- throw new InferenceRoutingError(400, 'invalid_request', 'The Responses instruction prefix must contain text only.');
1023
+ const apiKey = routeArg.credential === 'api_key';
1024
+ let body: Uint8Array<ArrayBuffer> | string;
1025
+ if (apiKey) {
1026
+ body = routeArg.spendLimit ? inferenceOpenAiCheckedBytes(bodyArg, routeArg.model.wireModel)
1027
+ : JSON.stringify({ ...bodyArg, model: routeArg.model.wireModel });
1028
+ } else {
1029
+ try {
1030
+ body = JSON.stringify({ ...plugins.openAiProvider.normalizeOpenAiChatGptResponsesRequest(bodyArg), model: routeArg.model.wireModel });
1031
+ } catch {
1032
+ throw new InferenceRoutingError(400, 'invalid_request', 'The Responses instruction prefix must contain text only.');
1033
+ }
930
1034
  }
931
1035
  const transport = routeArg.connection.settings.fetch;
932
1036
  if (!transport) throw new Error('The provider has no inference transport.');
@@ -938,10 +1042,24 @@ export class InferenceGateway {
938
1042
  const turnState = requestHeadersArg.get(turnStateHeader);
939
1043
  if (turnState !== null && this.turnStateOwner(turnState) === routeArg.accountId) upstreamHeaders.set(turnStateHeader, turnState);
940
1044
  // No caller-controlled URL or upstream credential crosses this boundary.
941
- const response = await transport(`${plugins.openAiProvider.OPENAI_CHATGPT_CODEX_BASE_URL}/responses`, {
942
- method: 'POST', headers: upstreamHeaders,
943
- body: JSON.stringify({ ...body, model: routeArg.model.wireModel }), signal: signalArg,
944
- });
1045
+ let response: Response;
1046
+ try {
1047
+ response = await transport(`${apiKey ? plugins.openAiProvider.OPENAI_API_BASE_URL
1048
+ : plugins.openAiProvider.OPENAI_CHATGPT_CODEX_BASE_URL}/responses`, {
1049
+ method: 'POST', headers: upstreamHeaders, body, signal: signalArg,
1050
+ });
1051
+ } catch (errorArg) {
1052
+ if (errorArg instanceof plugins.openAiProvider.OpenAiApiKeyAuthError && errorArg.status === 401) {
1053
+ throw new InferenceRoutingError(503, 'account_unauthorized', 'OpenAI rejected the API key serving this session. Add '
1054
+ + 'a valid key in AGL Accounts or with `authswitch account add openai-api-key`, and remove the rejected one.');
1055
+ }
1056
+ if (errorArg instanceof plugins.openAiProvider.OpenAiApiKeyAuthError && errorArg.status === 403) {
1057
+ return Response.json({ error: { code: 'provider_forbidden',
1058
+ message: 'OpenAI refused the API key serving this session: it lacks the permission this request needs.' } },
1059
+ { status: 403, headers: { 'content-type': 'application/json' } });
1060
+ }
1061
+ throw errorArg;
1062
+ }
945
1063
  const headers = new Headers();
946
1064
  response.headers.forEach((value, name) => {
947
1065
  if (forwardedResponseHeaders.has(name) || isCodexResponseHeader(name)) headers.set(name, value);
@@ -1092,11 +1210,13 @@ export class InferenceGateway {
1092
1210
  streamArg: { protocol: TProtocol; accountId: string; responsesSse: boolean; tap?: IInferenceUsageTap }): Promise<Response> {
1093
1211
  const tap = streamArg.tap;
1094
1212
  if (!responseArg.body && !streamArg.responsesSse) {
1095
- tap?.end(); finishArg(); return new Response(null, { status: responseArg.status, headers: headersArg });
1213
+ tap?.end(true); finishArg(); return new Response(null, { status: responseArg.status, headers: headersArg });
1096
1214
  }
1097
1215
  const reader = (responseArg.body ?? new ReadableStream<Uint8Array>({ start(controllerArg) { controllerArg.close(); } })).getReader();
1098
1216
  let finished = false;
1099
1217
  let cancelled = false;
1218
+ /** The provider's body was read to its end. */
1219
+ let whole = false;
1100
1220
  let relayed = 0;
1101
1221
  const report = this.options.reportStreamCut ?? writeStreamCut;
1102
1222
  const failure = (errorArg: unknown): InferenceRoutingError => {
@@ -1118,7 +1238,7 @@ export class InferenceGateway {
1118
1238
  finished = true;
1119
1239
  signalArg.removeEventListener('abort', abort);
1120
1240
  reader.releaseLock();
1121
- tap?.end();
1241
+ tap?.end(whole);
1122
1242
  finishArg();
1123
1243
  };
1124
1244
  const abort = () => { void reader.cancel(signalArg.reason).finally(finish).catch(() => undefined); };
@@ -1149,7 +1269,7 @@ export class InferenceGateway {
1149
1269
  // provider's stream. The signal need not say so, as Node's fetch stops a request's signal from following
1150
1270
  // the listener's abort once that request is collected.
1151
1271
  if (cancelled) return;
1152
- if (result.done) { controllerArg.close(); finish(); }
1272
+ if (result.done) { whole = true; controllerArg.close(); finish(); }
1153
1273
  else { relayed += result.value.byteLength; controllerArg.enqueue(result.value); tap?.chunk(result.value); }
1154
1274
  } catch (errorArg) {
1155
1275
  const error = failure(errorArg);
@@ -6,14 +6,16 @@ import { inferenceProviders, type TInferenceProvider } from './functions.proxyco
6
6
  * the account it had: `exhausted` (a window used up, or the provider refusing until a time it named), `reserved` (its
7
7
  * usage threshold reached), `cooling` (resting after a provider 429 another request met), `provider_429` (the provider
8
8
  * refused this request with a 429), `left_pool` (removed, signed out, or taken by a native tool), `outside_restriction`
9
- * (the client's account restriction leaves it out), `lapsed` (its subscription ended) or `unavailable` (a Claude
10
- * account that takes no request for a reason of its own: no usage reading, no model list, no access, or no such model).
9
+ * (the client's account restriction leaves it out), `lapsed` (its subscription ended), `unavailable` (an account that
10
+ * takes no request for a reason of its own: no usage reading, no model list, no access, no such model, or a payment
11
+ * problem) or `returned` (the session was on an API key, and a subscription of the pool could take its request again).
12
+ * An AGL older than a reason it reads among the latest placements fails `agl proxy status` until that entry ages out.
11
13
  */
12
14
  export type TInferencePlacementReason = 'new' | 'exhausted' | 'reserved' | 'cooling' | 'provider_429' | 'left_pool'
13
- | 'outside_restriction' | 'lapsed' | 'unavailable';
15
+ | 'outside_restriction' | 'lapsed' | 'unavailable' | 'returned';
14
16
 
15
17
  export const inferencePlacementReasons: readonly TInferencePlacementReason[] = Object.freeze(['new', 'exhausted', 'reserved',
16
- 'cooling', 'provider_429', 'left_pool', 'outside_restriction', 'lapsed', 'unavailable']);
18
+ 'cooling', 'provider_429', 'left_pool', 'outside_restriction', 'lapsed', 'unavailable', 'returned']);
17
19
 
18
20
  /** A placement as the gateway reports it: the session by its key (`inferenceSessionKey`), never stored as it is. */
19
21
  export interface IInferencePlacementRecord {