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.
- package/changelog.md +33 -0
- package/dist_serve/bundle.js +2228 -2177
- package/dist_serve/bundle.js.map +2 -2
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/aglapi/aglapisurface.document.js +2 -2
- package/dist_ts/classes.accountsactions.d.ts +11 -1
- package/dist_ts/classes.accountsactions.js +59 -2
- package/dist_ts/classes.controller.js +5 -5
- package/dist_ts/classes.flexaccountaccess.js +3 -2
- package/dist_ts/classes.harnessreleases.d.ts +14 -4
- package/dist_ts/classes.harnessreleases.js +28 -4
- package/dist_ts/classes.inferencegateway.d.ts +28 -3
- package/dist_ts/classes.inferencegateway.js +177 -61
- package/dist_ts/classes.inferenceplacement.d.ts +5 -3
- package/dist_ts/classes.inferenceplacement.js +2 -2
- package/dist_ts/classes.inferencerouter.d.ts +349 -91
- package/dist_ts/classes.inferencerouter.js +804 -297
- package/dist_ts/classes.inferenceservice.d.ts +14 -2
- package/dist_ts/classes.inferenceservice.js +53 -14
- package/dist_ts/classes.inferencespend.d.ts +339 -0
- package/dist_ts/classes.inferencespend.js +815 -0
- package/dist_ts/classes.inferenceusagecache.d.ts +8 -0
- package/dist_ts/classes.inferenceusagecache.js +9 -5
- package/dist_ts/classes.inferenceusagetap.d.ts +50 -9
- package/dist_ts/classes.inferenceusagetap.js +185 -31
- package/dist_ts/classes.proxycontrol.d.ts +3 -2
- package/dist_ts/classes.proxycontrol.js +1 -2
- package/dist_ts/classes.proxydaemon.js +3 -2
- package/dist_ts/classes.proxystore.d.ts +6 -0
- package/dist_ts/classes.proxystore.js +11 -2
- package/dist_ts/fleet/classes.claudeworkers.d.ts +5 -4
- package/dist_ts/fleet/classes.claudeworkers.js +10 -9
- package/dist_ts/functions.claudecoderelease.d.ts +12 -6
- package/dist_ts/functions.claudecoderelease.js +13 -7
- package/dist_ts/functions.inferencecli.js +12 -8
- package/dist_ts/interfaces.auth.d.ts +1 -1
- package/dist_ts/interfaces.auth.js +3 -1
- package/dist_ts/plugins.d.ts +3 -1
- package/dist_ts/plugins.js +7 -2
- package/dist_ts_authswitch/authority-apikeys.d.ts +32 -0
- package/dist_ts_authswitch/authority-apikeys.js +65 -0
- package/dist_ts_authswitch/authority-contract.d.ts +172 -33
- package/dist_ts_authswitch/authority-contract.js +54 -1
- package/dist_ts_authswitch/authority-overview.js +8 -1
- package/dist_ts_authswitch/authority-placement.d.ts +7 -2
- package/dist_ts_authswitch/authority-placement.js +10 -3
- package/dist_ts_authswitch/authority-runtime-contract.d.ts +2 -2
- package/dist_ts_authswitch/authority-spend.d.ts +103 -0
- package/dist_ts_authswitch/authority-spend.js +110 -0
- package/dist_ts_authswitch/authority-subscription.d.ts +12 -1
- package/dist_ts_authswitch/authority-subscription.js +24 -1
- package/dist_ts_authswitch/authority-usagelimit.d.ts +7 -4
- package/dist_ts_authswitch/authority-usagelimit.js +89 -39
- package/dist_ts_authswitch/authority-usagewindows.d.ts +4 -1
- package/dist_ts_authswitch/authority-usagewindows.js +41 -16
- package/dist_ts_authswitch/classes.authoritybroker.d.ts +30 -19
- package/dist_ts_authswitch/classes.authoritybroker.js +152 -61
- package/dist_ts_authswitch/classes.authoritycli.d.ts +9 -4
- package/dist_ts_authswitch/classes.authoritycli.js +99 -30
- package/dist_ts_authswitch/classes.authorityclient.d.ts +24 -7
- package/dist_ts_authswitch/classes.authorityclient.js +45 -15
- package/dist_ts_authswitch/classes.authoritydaemon.js +39 -12
- package/dist_ts_authswitch/classes.authoritydatabase.d.ts +28 -5
- package/dist_ts_authswitch/classes.authoritydatabase.js +171 -9
- package/dist_ts_authswitch/classes.authorityimport.js +2 -2
- package/dist_ts_authswitch/classes.authoritymodels.d.ts +71 -3
- package/dist_ts_authswitch/classes.authoritymodels.js +249 -20
- package/dist_ts_authswitch/classes.authorityregistry.js +4 -2
- package/dist_ts_authswitch/classes.authorityusage.d.ts +69 -7
- package/dist_ts_authswitch/classes.authorityusage.js +118 -4
- package/dist_ts_authswitch/classes.claudenative.d.ts +23 -8
- package/dist_ts_authswitch/classes.claudenative.js +49 -21
- package/dist_ts_authswitch/classes.claudesignin.d.ts +14 -2
- package/dist_ts_authswitch/classes.claudesignin.js +72 -23
- package/dist_ts_authswitch/classes.codexmanaged.d.ts +8 -0
- package/dist_ts_authswitch/classes.codexmanaged.js +33 -18
- package/dist_ts_authswitch/classes.harnessprocesses.d.ts +8 -0
- package/dist_ts_authswitch/classes.harnessprocesses.js +41 -9
- package/dist_ts_authswitch/testsupport.d.ts +24 -5
- package/dist_ts_authswitch/testsupport.js +56 -12
- package/dist_ts_authswitch/ts_migration/0011_account_api_key_fields.d.ts +12 -0
- package/dist_ts_authswitch/ts_migration/0011_account_api_key_fields.js +21 -0
- package/dist_ts_authswitch/ts_migration/index.js +3 -1
- package/dist_ts_interfaces/functions.authorityscope.d.ts +18 -7
- package/dist_ts_interfaces/functions.authorityscope.js +35 -8
- package/dist_ts_interfaces/interfaces.d.ts +1 -1
- package/dist_ts_interfaces/interfaces.js +1 -1
- package/dist_ts_interfaces/plugins.d.ts +2 -1
- package/dist_ts_interfaces/plugins.js +7 -4
- package/dist_ts_interfaces/requests.accounts.d.ts +56 -0
- package/dist_ts_interfaces/requests.accounts.js +11 -1
- package/dist_ts_interfaces/requests.d.ts +2 -2
- package/dist_ts_interfaces/requests.js +1 -1
- package/docs/authswitch.md +75 -12
- package/package.json +5 -5
- package/readme.md +199 -22
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/aglapi/aglapisurface.document.ts +2 -2
- package/ts/classes.accountsactions.ts +75 -1
- package/ts/classes.controller.ts +4 -4
- package/ts/classes.flexaccountaccess.ts +2 -1
- package/ts/classes.harnessreleases.ts +25 -4
- package/ts/classes.inferencegateway.ts +184 -64
- package/ts/classes.inferenceplacement.ts +6 -4
- package/ts/classes.inferencerouter.ts +1026 -314
- package/ts/classes.inferenceservice.ts +56 -13
- package/ts/classes.inferencespend.ts +870 -0
- package/ts/classes.inferenceusagecache.ts +16 -4
- package/ts/classes.inferenceusagetap.ts +159 -34
- package/ts/classes.proxycontrol.ts +3 -3
- package/ts/classes.proxydaemon.ts +2 -1
- package/ts/classes.proxystore.ts +11 -1
- package/ts/fleet/classes.claudeworkers.ts +9 -8
- package/ts/functions.claudecoderelease.ts +13 -6
- package/ts/functions.inferencecli.ts +11 -7
- package/ts/interfaces.auth.ts +2 -0
- package/ts/plugins.ts +7 -0
- package/ts_authswitch/authority-apikeys.ts +85 -0
- package/ts_authswitch/authority-contract.ts +201 -34
- package/ts_authswitch/authority-overview.ts +7 -0
- package/ts_authswitch/authority-placement.ts +12 -4
- package/ts_authswitch/authority-runtime-contract.ts +2 -2
- package/ts_authswitch/authority-spend.ts +168 -0
- package/ts_authswitch/authority-subscription.ts +28 -1
- package/ts_authswitch/authority-usagelimit.ts +95 -40
- package/ts_authswitch/authority-usagewindows.ts +42 -17
- package/ts_authswitch/classes.authoritybroker.ts +161 -68
- package/ts_authswitch/classes.authoritycli.ts +112 -32
- package/ts_authswitch/classes.authorityclient.ts +66 -19
- package/ts_authswitch/classes.authoritydaemon.ts +43 -13
- package/ts_authswitch/classes.authoritydatabase.ts +178 -8
- package/ts_authswitch/classes.authorityimport.ts +1 -1
- package/ts_authswitch/classes.authoritymodels.ts +165 -19
- package/ts_authswitch/classes.authorityregistry.ts +3 -0
- package/ts_authswitch/classes.authorityusage.ts +171 -10
- package/ts_authswitch/classes.claudenative.ts +51 -22
- package/ts_authswitch/classes.claudesignin.ts +74 -24
- package/ts_authswitch/classes.codexmanaged.ts +30 -16
- package/ts_authswitch/classes.harnessprocesses.ts +39 -8
- package/ts_authswitch/native-contracts.md +82 -21
- package/ts_authswitch/readme.md +1 -1
- package/ts_authswitch/testsupport.ts +58 -11
- package/ts_authswitch/ts_migration/0011_account_api_key_fields.ts +20 -0
- package/ts_authswitch/ts_migration/index.ts +2 -0
- package/ts_interfaces/functions.authorityscope.ts +43 -8
- package/ts_interfaces/interfaces.ts +1 -1
- package/ts_interfaces/plugins.ts +6 -3
- package/ts_interfaces/requests.accounts.ts +48 -0
- package/ts_interfaces/requests.ts +4 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/accounts.plugins.ts +7 -1
- package/ts_web/accounts.templates.ts +20 -11
- package/ts_web/classes.accountsreadmodel.ts +326 -71
- package/ts_web/elements.accounts.ts +219 -20
- package/ts_web/elements.codexconnections.ts +3 -1
- package/ts_web/plugins.ts +4 -2
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
|
-
import { inferenceCredentials, inferencePlanPattern, inferenceProviders
|
|
3
|
-
|
|
4
|
-
|
|
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:
|
|
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
|
-
*
|
|
320
|
-
*
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
|
402
|
+
let cause: TInferencePaymentCause | undefined;
|
|
352
403
|
try {
|
|
353
|
-
|
|
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 {
|
|
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
|
|
387
|
-
*
|
|
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' &&
|
|
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
|
|
678
|
-
*
|
|
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
|
-
|
|
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,
|
|
688
|
-
...(
|
|
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
|
-
|
|
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
|
|
716
|
-
|
|
717
|
-
|
|
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.
|
|
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
|
-
|
|
734
|
-
|
|
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
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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,
|
|
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
|
|
765
|
-
// A
|
|
766
|
-
// account before a byte reaches the client, never to this one
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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:
|
|
772
|
-
|
|
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:
|
|
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
|
-
|
|
860
|
+
excludedBecause.set(route.accountId, unavailable);
|
|
782
861
|
leftBecause.set(route.accountId, 'unavailable');
|
|
783
862
|
continue;
|
|
784
863
|
}
|
|
785
|
-
response = new Response(
|
|
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
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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)
|
|
10
|
-
*
|
|
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 {
|