agl 40.4.0 → 40.5.0
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 +27 -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 +142 -28
- package/dist_ts_authswitch/authority-contract.js +48 -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 +94 -29
- package/dist_ts_authswitch/classes.authorityclient.d.ts +24 -7
- package/dist_ts_authswitch/classes.authorityclient.js +31 -11
- 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 +16 -7
- package/dist_ts_authswitch/classes.claudenative.js +29 -16
- package/dist_ts_authswitch/classes.claudesignin.js +5 -2
- 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 +198 -21
- 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 +167 -29
- 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 +108 -32
- package/ts_authswitch/classes.authorityclient.ts +41 -10
- 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 +28 -15
- package/ts_authswitch/classes.claudesignin.ts +4 -1
- package/ts_authswitch/native-contracts.md +81 -20
- 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 +3 -1
- package/ts_web/accounts.templates.ts +20 -11
- package/ts_web/classes.accountsreadmodel.ts +322 -70
- package/ts_web/elements.accounts.ts +219 -20
- package/ts_web/elements.codexconnections.ts +3 -1
- package/ts_web/plugins.ts +4 -2
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import type { TAuthSwitchApiKeyProvider } from './authority-contract.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What the authority asks of a provider before it holds an API key, and how it tells the two providers' keys apart.
|
|
6
|
+
*
|
|
7
|
+
* Nothing here keeps, logs or returns a key, and no answer repeats a provider's error text, which could quote it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What a provider answered when asked for the models a key may use: `accepted`; `rejected`, the provider does not
|
|
12
|
+
* accept the key (HTTP 401); `forbidden`, the key is accepted but may not read the model list (HTTP 403, a restricted
|
|
13
|
+
* key). A verifier throws when the question could not be answered: the provider was not reached, timed out or answered
|
|
14
|
+
* anything else.
|
|
15
|
+
*/
|
|
16
|
+
export type TAuthSwitchApiKeyVerdict = 'accepted' | 'rejected' | 'forbidden';
|
|
17
|
+
|
|
18
|
+
/** Asks `providerId` whether it accepts `apiKey`, within `signal` (`TAuthSwitchApiKeyVerdict`). */
|
|
19
|
+
export type TAuthSwitchApiKeyVerifier = (providerId: TAuthSwitchApiKeyProvider, apiKey: string,
|
|
20
|
+
signal: AbortSignal) => Promise<TAuthSwitchApiKeyVerdict>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The verifier the authority runs: a read of the models the key may use, from Anthropic's `GET /v1/models` or
|
|
24
|
+
* OpenAI's, over `fetchArg` (the global fetch when unset), which nothing else reaches. Both reads come from
|
|
25
|
+
* `@modelprofile.com/flexharness-providers`, which sends the key only to its provider's API, follows no redirect, and
|
|
26
|
+
* throws fixed words that never carry the key or the provider's answer.
|
|
27
|
+
*/
|
|
28
|
+
export const authSwitchApiKeyVerifier = (fetchArg?: typeof fetch): TAuthSwitchApiKeyVerifier =>
|
|
29
|
+
async (providerId, apiKey, signal) => {
|
|
30
|
+
switch (providerId) {
|
|
31
|
+
case 'anthropic':
|
|
32
|
+
try {
|
|
33
|
+
await plugins.flexAnthropic.listAnthropicApiKeyModels({ apiKey }, { signal, ...(fetchArg ? { fetch: fetchArg } : {}) });
|
|
34
|
+
return 'accepted';
|
|
35
|
+
} catch (error) {
|
|
36
|
+
// Anthropic's model list refuses a key it does not accept with 401 and states no other key refusal.
|
|
37
|
+
if (error instanceof plugins.flexAnthropic.AnthropicApiKeyAuthError && error.status === 401) return 'rejected';
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
case 'openai':
|
|
41
|
+
try {
|
|
42
|
+
await plugins.flexOpenAi.listOpenAiApiKeyModels({ apiKey }, { signal, ...(fetchArg ? { fetch: fetchArg } : {}) });
|
|
43
|
+
return 'accepted';
|
|
44
|
+
} catch (error) {
|
|
45
|
+
// OpenAI refuses a key it does not accept with 401, and a key that may not read models with 403.
|
|
46
|
+
if (error instanceof plugins.flexOpenAi.OpenAiApiKeyAuthError) {
|
|
47
|
+
if (error.status === 401) return 'rejected';
|
|
48
|
+
if (error.status === 403) return 'forbidden';
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
default: {
|
|
53
|
+
const unhandledProvider: never = providerId;
|
|
54
|
+
return unhandledProvider;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** How each provider names itself and where its keys are made, as an instruction states it. */
|
|
60
|
+
export const authSwitchApiKeyProviderNames: Readonly<Record<TAuthSwitchApiKeyProvider, { name: string; console: string }>> =
|
|
61
|
+
Object.freeze({
|
|
62
|
+
anthropic: { name: 'Anthropic', console: 'the Claude Console' },
|
|
63
|
+
openai: { name: 'OpenAI', console: 'the OpenAI platform' },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* How the providers' keys begin, as each documents them: Anthropic's API and Admin API keys begin `sk-ant-`
|
|
68
|
+
* (`sk-ant-api03`, `sk-ant-admin`; the Claude API reference, read 2026-10-21); OpenAI's project, service account and
|
|
69
|
+
* admin keys `sk-proj-`, `sk-svcacct-` and `sk-admin-` (OpenAI's own Codex, `OPENAI_API_KEY_PREFIXES`, and its API
|
|
70
|
+
* specification). OpenAI's older keys begin with a bare `sk-`, which Anthropic's share, so it tells nothing apart.
|
|
71
|
+
*/
|
|
72
|
+
const keyPrefixes: Readonly<Record<TAuthSwitchApiKeyProvider, readonly string[]>> = Object.freeze({
|
|
73
|
+
anthropic: ['sk-ant-'],
|
|
74
|
+
openai: ['sk-proj-', 'sk-svcacct-', 'sk-admin-'],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The provider whose keys begin the way `apiKey` does, when it is not `providerIdArg`: a key offered as the other
|
|
79
|
+
* provider's. The authority refuses it before any request, so a key is never sent to the provider it is not for.
|
|
80
|
+
*/
|
|
81
|
+
export const authSwitchApiKeyOtherProvider = (providerIdArg: TAuthSwitchApiKeyProvider,
|
|
82
|
+
apiKey: string): TAuthSwitchApiKeyProvider | null => {
|
|
83
|
+
const other: TAuthSwitchApiKeyProvider = providerIdArg === 'anthropic' ? 'openai' : 'anthropic';
|
|
84
|
+
return keyPrefixes[other].some((prefix) => apiKey.startsWith(prefix)) ? other : null;
|
|
85
|
+
};
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import type { ITypedRequest } from '@api.global/typedrequest-interfaces';
|
|
2
|
-
import type { IAuthSwitchUsageRecheck, IAuthSwitchUsageSnapshot
|
|
2
|
+
import type { IAuthSwitchUsageRecheck, IAuthSwitchUsageSnapshot,
|
|
3
|
+
TAuthSwitchPaymentProblemCause } from './classes.authorityusage.js';
|
|
3
4
|
import type { IAuthSwitchCodexAppServerDetail, TAuthSwitchCodexAppServerRestart } from './authority-import-contract.js';
|
|
4
5
|
export type { IAuthSwitchPaymentProblem, IAuthSwitchUsageRecheck, IAuthSwitchUsageSnapshot,
|
|
5
|
-
TAuthSwitchRecheckOutcome } from './classes.authorityusage.js';
|
|
6
|
+
TAuthSwitchPaymentProblemCause, TAuthSwitchRecheckOutcome } from './classes.authorityusage.js';
|
|
6
7
|
import type { TAuthSwitchUsageObservation } from './authority-usageheaders.js';
|
|
7
8
|
export type { TAuthSwitchUsageObservation } from './authority-usageheaders.js';
|
|
8
9
|
import type { IAuthSwitchTokenUsage } from './authority-usagetokens.js';
|
|
9
10
|
export type { IAuthSwitchTokenUsage } from './authority-usagetokens.js';
|
|
11
|
+
import { isAuthSwitchModelId } from './authority-usagetokens.js';
|
|
12
|
+
export type { IAuthSwitchSpendPeriod, IAuthSwitchSpendReading } from './authority-spend.js';
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
15
|
* Why the authority declined, as a closed set a client branches on without reading the text.
|
|
@@ -85,8 +88,9 @@ export type TAuthSwitchRefusalCode =
|
|
|
85
88
|
*/
|
|
86
89
|
| 'claude_home_unreadable'
|
|
87
90
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
91
|
+
* The provider of the API key offered for an account -- Anthropic or OpenAI -- rejected it (401), accepted it without
|
|
92
|
+
* letting it read the model list (OpenAI's 403, a restricted key), or could not be asked: nothing was stored, and the
|
|
93
|
+
* instruction says which of the three it was and what to do, never repeating the provider's words.
|
|
90
94
|
*/
|
|
91
95
|
| 'api_key_unverified';
|
|
92
96
|
|
|
@@ -169,6 +173,20 @@ export const asAuthSwitchRefusal = (error: unknown): IAuthSwitchRefusal | null =
|
|
|
169
173
|
return { code, instruction };
|
|
170
174
|
};
|
|
171
175
|
|
|
176
|
+
/**
|
|
177
|
+
* The text the authority answers a malformed request with -- a member missing or unknown, a value of the wrong shape,
|
|
178
|
+
* a spend report naming an answer twice -- and no refusal beside it: the caller's own bug, never an instruction for an
|
|
179
|
+
* owner.
|
|
180
|
+
*/
|
|
181
|
+
export const authSwitchMalformedRequestText = 'Invalid authswitch authority request.';
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Whether an error answer is the authority's answer to a malformed request (`authSwitchMalformedRequestText`): its text
|
|
185
|
+
* alone, with no refusal marker. The same request is answered the same way every time it is sent.
|
|
186
|
+
*/
|
|
187
|
+
export const isAuthSwitchMalformedRequest = (error: unknown): boolean => typeof error === 'object' && error !== null
|
|
188
|
+
&& 'errorText' in error && error.errorText === authSwitchMalformedRequestText && asAuthSwitchRefusal(error) === null;
|
|
189
|
+
|
|
172
190
|
/** The longest account label, in UTF-16 code units. */
|
|
173
191
|
export const authSwitchAccountLabelMaxLength = 128;
|
|
174
192
|
|
|
@@ -181,6 +199,13 @@ export const isAuthSwitchAccountLabel = (value: unknown): value is string => typ
|
|
|
181
199
|
&& value.trim() === value && value.length > 0 && value.length <= authSwitchAccountLabelMaxLength
|
|
182
200
|
&& !/[\u0000-\u001f\u007f]/.test(value);
|
|
183
201
|
|
|
202
|
+
/** An API key account's `keyHint`: `…` and the key's last four characters, which tell keys apart and reveal nothing usable. */
|
|
203
|
+
export const authSwitchKeyHint = (apiKey: string): string => `\u2026${apiKey.slice(-4)}`;
|
|
204
|
+
|
|
205
|
+
/** Whether a value is a key hint as `authSwitchKeyHint` makes one: `…` and four printable characters. */
|
|
206
|
+
export const isAuthSwitchKeyHint = (value: unknown): value is string => typeof value === 'string'
|
|
207
|
+
&& /^\u2026[\x21-\x7e]{4}$/.test(value);
|
|
208
|
+
|
|
184
209
|
/** The range of an account's usage threshold, in whole percent of each counted usage window. */
|
|
185
210
|
export const authSwitchMaxUsageRange: Readonly<{ min: number; max: number }> = Object.freeze({ min: 1, max: 100 });
|
|
186
211
|
|
|
@@ -210,7 +235,7 @@ export const isAuthSwitchLapsesAt = (value: unknown): value is string => {
|
|
|
210
235
|
|
|
211
236
|
/** Credential-free account management contract. Safe to import in browser code. */
|
|
212
237
|
export type TAuthSwitchLoginPurpose = 'openai_managed' | 'claude_host_native' | 'claude_container_setup' | 'opencode_native'
|
|
213
|
-
| 'anthropic_api_key';
|
|
238
|
+
| 'anthropic_api_key' | 'openai_api_key';
|
|
214
239
|
export type TAuthSwitchLoginHealth = 'ready' | 'refreshing' | 'retry_wait' | 'needs_reauth' | 'unverified' | 'pending_handoff' | 'handoff_quarantined' | 'removed';
|
|
215
240
|
|
|
216
241
|
export interface IAuthSwitchAccount {
|
|
@@ -225,7 +250,7 @@ export interface IAuthSwitchAccount {
|
|
|
225
250
|
* - `anthropic`: the organization UUID, Claude Code's `oauthAccount.organizationUuid` and the `orgId` of
|
|
226
251
|
* `claude auth status --json`; for an account on an Anthropic API key, which names no organization, `api-key`.
|
|
227
252
|
* - `openai`: the ChatGPT account id (the `chatgpt_account_id` claim, a personal or team ChatGPT workspace),
|
|
228
|
-
* which is not an OpenAI API organization
|
|
253
|
+
* which is not an OpenAI API organization; for an account on an OpenAI API key, `api-key`.
|
|
229
254
|
*
|
|
230
255
|
* Every daemon from 10.3.0 on states it; an older daemon leaves it out.
|
|
231
256
|
*/
|
|
@@ -246,6 +271,17 @@ export interface IAuthSwitchAccount {
|
|
|
246
271
|
* request. Clearing the date makes it active again. See `authority-placement.ts`.
|
|
247
272
|
*/
|
|
248
273
|
lapsesAt: string | null;
|
|
274
|
+
/**
|
|
275
|
+
* An API key account's spend limit per calendar month in UTC, in micro-dollars (`isAuthSwitchSpendLimitMicroUsd`);
|
|
276
|
+
* `null` for none, and always for an account on a sign-in. The limit counts what AGL's inference proxy relayed on the
|
|
277
|
+
* key, nothing else: at it, the proxy places nothing more on the key until the month ends.
|
|
278
|
+
*/
|
|
279
|
+
spendLimitMicroUsd: number | null;
|
|
280
|
+
/**
|
|
281
|
+
* How an API key account's key ends, `…` and its last four characters, so the owner tells keys apart; `null` for an
|
|
282
|
+
* account on a sign-in, and for an Anthropic key the accounts service held before it kept hints.
|
|
283
|
+
*/
|
|
284
|
+
keyHint: string | null;
|
|
249
285
|
removed: boolean;
|
|
250
286
|
revision: number;
|
|
251
287
|
statusObservedAt: string;
|
|
@@ -290,8 +326,8 @@ export interface IAuthSwitchLogin {
|
|
|
290
326
|
* `canBind`: a bind of this login is accepted now, decided by the conditions the bind checks. That is a ready,
|
|
291
327
|
* daemon-held login an account that is not removed has selected, of the account's own provider: its ChatGPT login
|
|
292
328
|
* for every runtime; its Claude login, with a verified import receipt and while the daemon's Claude renewer
|
|
293
|
-
* runs, for the `gateway` runtime alone; or its Anthropic API key (`anthropic_api_key`),
|
|
294
|
-
* alone as well.
|
|
329
|
+
* runs, for the `gateway` runtime alone; or its Anthropic or OpenAI API key (`anthropic_api_key`, `openai_api_key`),
|
|
330
|
+
* for the `gateway` runtime alone as well.
|
|
295
331
|
*/
|
|
296
332
|
capabilities: { canReauthenticate: boolean; canBind: boolean; canPreuse: boolean };
|
|
297
333
|
}
|
|
@@ -303,7 +339,7 @@ export interface IAuthSwitchBinding {
|
|
|
303
339
|
* The runtime a login backs. `flex`, `opencode`, `claude` and `gateway` bindings are held and released by their
|
|
304
340
|
* caller over the runtime socket; `codex` is the daemon's own managed Codex. A ChatGPT login backs every runtime;
|
|
305
341
|
* a Claude login backs `gateway` alone, AGL's inference proxy, which sends Claude Code's own requests to the
|
|
306
|
-
* account, and so does an Anthropic API key, on which the proxy
|
|
342
|
+
* account, and so does an Anthropic or OpenAI API key, on which the proxy relays requests at API prices. `gateway` bindings are preemptible: removing the account, switching this host's Codex to its login, or
|
|
307
343
|
* moving its Claude login into a container home releases them in the same transaction instead of refusing, so a
|
|
308
344
|
* login the authority hands to a native tool never has a second renewer.
|
|
309
345
|
*/
|
|
@@ -716,9 +752,11 @@ export type TAuthSwitchClaudeProofFailure =
|
|
|
716
752
|
|
|
717
753
|
/**
|
|
718
754
|
* What a Claude sign-in shows the person signing in: a command to run in a terminal, and the private Claude home
|
|
719
|
-
* it signs in to. `command` is `claude auth login` with `CLAUDE_CONFIG_DIR`
|
|
720
|
-
* shell; a caller that starts Claude Code itself runs `claude` with
|
|
721
|
-
*
|
|
755
|
+
* it signs in to. `command` is `claude auth login` with `CLAUDE_CONFIG_DIR` and `CLAUDE_SECURESTORAGE_CONFIG_DIR` both
|
|
756
|
+
* set to `directory`, quoted for a POSIX shell; a caller that starts Claude Code itself runs `claude` with
|
|
757
|
+
* `authSwitchClaudeSignInArguments` and those two variables instead. The second keeps the credential store in the home:
|
|
758
|
+
* inherited, it would send the new login to another store. The directory is a local path of the authority's user,
|
|
759
|
+
* like a managed Codex socket path.
|
|
722
760
|
*/
|
|
723
761
|
export interface IAuthSwitchClaudeSignInPrompt {
|
|
724
762
|
flow: 'terminal';
|
|
@@ -1204,16 +1242,94 @@ export interface IReq_AuthSwitchObserveUsage extends ITypedRequest {
|
|
|
1204
1242
|
}
|
|
1205
1243
|
|
|
1206
1244
|
/**
|
|
1207
|
-
* Management-only:
|
|
1208
|
-
* `billing_error`)
|
|
1209
|
-
* (`IAuthSwitchPaymentProblem`)
|
|
1210
|
-
*
|
|
1211
|
-
*
|
|
1212
|
-
*
|
|
1245
|
+
* Management-only: the provider refused an inference request on the login for money at `observedAt`. On a Claude login
|
|
1246
|
+
* that is a billing error (HTTP 402, `billing_error`): the authority holds the login with the payment problem on its usage
|
|
1247
|
+
* record (`IAuthSwitchPaymentProblem`) until a usage read shows its subscription changed or a re-check reads it healthy.
|
|
1248
|
+
* On an API key's login `cause` names the refusal (`TAuthSwitchPaymentProblemCause`; absent means `billing`): the
|
|
1249
|
+
* authority holds the key with it, a `spend_cap` until 00:00 UTC on the first of the next month (`until`), any other
|
|
1250
|
+
* cause until a re-check. Either way it answers the held usage, which then carries the problem, and the proxy routes
|
|
1251
|
+
* nothing to the account while it is held. Refused (`login_unavailable`) for any other login, for a Claude login with
|
|
1252
|
+
* no usage record of its sign-in, and for a Claude login with a cause other than `billing`. The provider is not asked.
|
|
1253
|
+
* An authority of 40.2.0 or older does not know the method, and one of 40.3.0 refuses `cause` and every API key's
|
|
1254
|
+
* login; the proxy then holds the account out of rotation itself, for as long as it runs.
|
|
1213
1255
|
*/
|
|
1214
1256
|
export interface IReq_AuthSwitchReportPaymentProblem extends ITypedRequest {
|
|
1215
1257
|
method: 'authswitch.authority.usage.paymentProblem';
|
|
1216
|
-
request: { accountId: string; loginId: string; observedAt: string };
|
|
1258
|
+
request: { accountId: string; loginId: string; observedAt: string; cause?: TAuthSwitchPaymentProblemCause };
|
|
1259
|
+
response: { usage: IAuthSwitchUsageSnapshot };
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/** Whether a value is a payment problem's cause (`TAuthSwitchPaymentProblemCause`). */
|
|
1263
|
+
export const isAuthSwitchPaymentProblemCause = (value: unknown): value is TAuthSwitchPaymentProblemCause =>
|
|
1264
|
+
value === 'billing' || value === 'spend_cap' || value === 'quota';
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* One answer AGL's inference proxy relayed on an API key, as it reports the answer's cost to the authority
|
|
1268
|
+
* (`authswitch.authority.usage.spend`). The proxy prices the answer once, by the model that answered and the service
|
|
1269
|
+
* tier the provider reported, and sends the same report until the authority acknowledged it.
|
|
1270
|
+
*/
|
|
1271
|
+
export interface IAuthSwitchSpendAnswer {
|
|
1272
|
+
/** The proxy's id of the answer, a UUID: the same on every send of it, and the answer's identity in the ledger. */
|
|
1273
|
+
answerId: string;
|
|
1274
|
+
/** When the answer ended, ISO 8601 UTC; its calendar month in UTC is the period its cost counts in. */
|
|
1275
|
+
endedAt: string;
|
|
1276
|
+
/** The model that answered, as the provider named it (`isAuthSwitchModelId`). */
|
|
1277
|
+
model: string;
|
|
1278
|
+
/** The service tier the provider reported for the answer; null when it reported none. */
|
|
1279
|
+
serviceTier: string | null;
|
|
1280
|
+
/**
|
|
1281
|
+
* What the answer cost, in whole micro-dollars (at most `authSwitchSpendAnswerMaxMicroUsd`); null when the proxy
|
|
1282
|
+
* could not price it. An unpriced answer stops a key with a spend limit until the month ends or the limit is removed.
|
|
1283
|
+
*/
|
|
1284
|
+
microUsd: number | null;
|
|
1285
|
+
/** The price entry the answer was priced by (its checked date); null exactly when `microUsd` is null. */
|
|
1286
|
+
priceSource: string | null;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/** The most answers one spend report carries. */
|
|
1290
|
+
export const authSwitchSpendAnswerBatchLimit = 64;
|
|
1291
|
+
|
|
1292
|
+
/** The most one answer may cost, in micro-dollars: ten thousand dollars, far above any one answer's price. */
|
|
1293
|
+
export const authSwitchSpendAnswerMaxMicroUsd = 10_000_000_000;
|
|
1294
|
+
|
|
1295
|
+
const spendAnswerKeys: readonly (keyof IAuthSwitchSpendAnswer)[] =
|
|
1296
|
+
['answerId', 'endedAt', 'model', 'serviceTier', 'microUsd', 'priceSource'];
|
|
1297
|
+
|
|
1298
|
+
/** Whether a value is one spend report answer as the authority accepts it: the check it applies to every answer. */
|
|
1299
|
+
export const isAuthSwitchSpendAnswer = (value: unknown): value is IAuthSwitchSpendAnswer => {
|
|
1300
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
1301
|
+
const answer = value as Record<string, unknown>;
|
|
1302
|
+
return Object.keys(answer).length === spendAnswerKeys.length && spendAnswerKeys.every((key) => Object.hasOwn(answer, key))
|
|
1303
|
+
&& typeof answer.answerId === 'string'
|
|
1304
|
+
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(answer.answerId)
|
|
1305
|
+
&& typeof answer.endedAt === 'string' && Number.isFinite(Date.parse(answer.endedAt))
|
|
1306
|
+
&& new Date(answer.endedAt).toISOString() === answer.endedAt
|
|
1307
|
+
&& isAuthSwitchModelId(answer.model)
|
|
1308
|
+
&& (answer.serviceTier === null || (typeof answer.serviceTier === 'string'
|
|
1309
|
+
&& /^[a-z][a-z0-9_-]{0,63}$/.test(answer.serviceTier)))
|
|
1310
|
+
&& (answer.microUsd === null ? answer.priceSource === null
|
|
1311
|
+
: Number.isSafeInteger(answer.microUsd) && (answer.microUsd as number) >= 0
|
|
1312
|
+
&& (answer.microUsd as number) <= authSwitchSpendAnswerMaxMicroUsd
|
|
1313
|
+
&& typeof answer.priceSource === 'string' && /^[\x21-\x7e](?:[\x20-\x7e]{0,126}[\x21-\x7e])?$/.test(answer.priceSource));
|
|
1314
|
+
};
|
|
1315
|
+
|
|
1316
|
+
/** Whether a value is a spend report's answers: one to `authSwitchSpendAnswerBatchLimit` valid answers, each id once. */
|
|
1317
|
+
export const isAuthSwitchSpendAnswerBatch = (value: unknown): value is IAuthSwitchSpendAnswer[] => Array.isArray(value)
|
|
1318
|
+
&& value.length >= 1 && value.length <= authSwitchSpendAnswerBatchLimit && value.every(isAuthSwitchSpendAnswer)
|
|
1319
|
+
&& new Set(value.map((answer: IAuthSwitchSpendAnswer) => answer.answerId)).size === value.length;
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* Management-only: what answers AGL's inference proxy relayed on an API key's login cost (`IAuthSwitchSpendAnswer`).
|
|
1323
|
+
* The authority records the whole batch in one transaction in the key's monthly ledger and answers the key's usage,
|
|
1324
|
+
* its spend reading included, once it committed. An answer it recorded already is no change, so the proxy sends a
|
|
1325
|
+
* report again until it is acknowledged; an answer by a recorded id with other content refuses the whole batch
|
|
1326
|
+
* (`invalid_input`, naming the id). Spend is recorded for a removed key too, whose ledger stays. Refused
|
|
1327
|
+
* (`login_unavailable`) for any login but an API key's. An authority of 40.3.0 or older does not know the method; the
|
|
1328
|
+
* proxy then serves no key with a spend limit.
|
|
1329
|
+
*/
|
|
1330
|
+
export interface IReq_AuthSwitchRecordSpend extends ITypedRequest {
|
|
1331
|
+
method: 'authswitch.authority.usage.spend';
|
|
1332
|
+
request: { accountId: string; loginId: string; answers: IAuthSwitchSpendAnswer[] };
|
|
1217
1333
|
response: { usage: IAuthSwitchUsageSnapshot };
|
|
1218
1334
|
}
|
|
1219
1335
|
|
|
@@ -1241,18 +1357,25 @@ export interface IReq_AuthSwitchBeginAdd extends ITypedRequest {
|
|
|
1241
1357
|
response: { operation: IAuthSwitchOperation };
|
|
1242
1358
|
}
|
|
1243
1359
|
|
|
1360
|
+
/** The providers whose API keys the authority holds as accounts. */
|
|
1361
|
+
export type TAuthSwitchApiKeyProvider = 'anthropic' | 'openai';
|
|
1362
|
+
|
|
1244
1363
|
/**
|
|
1245
|
-
* Adds an account on an
|
|
1246
|
-
*
|
|
1247
|
-
* (`
|
|
1248
|
-
*
|
|
1249
|
-
*
|
|
1250
|
-
*
|
|
1251
|
-
*
|
|
1364
|
+
* Adds an account on an API key, served on the MANAGEMENT socket alone: an Anthropic key from the Claude Console or an
|
|
1365
|
+
* OpenAI key from the OpenAI platform. A key that starts the way the other provider's keys do is refused before any
|
|
1366
|
+
* request (`invalid_input`). The authority then asks the provider for the key's models and stores nothing unless the
|
|
1367
|
+
* provider accepts the key (`api_key_unverified`; an OpenAI key that may not read models is refused with what to grant
|
|
1368
|
+
* it); it seals the key like every login it holds and never answers it again, here or anywhere else, and keeps only
|
|
1369
|
+
* how the key ends (`keyHint`). The account's subject is a digest of the key, so the same key is one account: offered
|
|
1370
|
+
* again while held, it is refused (`invalid_input`); offered after its removal or after the provider rejected it, it
|
|
1371
|
+
* brings the account back, with its month's spend. `spendLimitMicroUsd` sets the key's monthly spend limit
|
|
1372
|
+
* (`isAuthSwitchSpendLimitMicroUsd`); left out, a new key has none and a returning key keeps the one it had. The login's
|
|
1373
|
+
* purpose is `anthropic_api_key` or `openai_api_key`: it backs the `gateway` runtime alone, where AGL's inference
|
|
1374
|
+
* proxy relays requests on it, and nothing renews or revokes it -- the key is revoked at the provider.
|
|
1252
1375
|
*/
|
|
1253
1376
|
export interface IReq_AuthSwitchAddApiKey extends ITypedRequest {
|
|
1254
1377
|
method: 'authswitch.authority.apikey.add';
|
|
1255
|
-
request: { providerId:
|
|
1378
|
+
request: { providerId: TAuthSwitchApiKeyProvider; apiKey: string; label?: string; spendLimitMicroUsd?: number };
|
|
1256
1379
|
response: { account: IAuthSwitchAccount; loginId: string };
|
|
1257
1380
|
}
|
|
1258
1381
|
|
|
@@ -1313,7 +1436,8 @@ export interface IReq_AuthSwitchRenameAccount extends ITypedRequest {
|
|
|
1313
1436
|
/**
|
|
1314
1437
|
* Sets an account's usage threshold (`maxUsagePercent`, see `isAuthSwitchMaxUsagePercent`), or clears it with `null`.
|
|
1315
1438
|
* `expectedRevision` is the revision the caller read, so a change of an account that changed meanwhile is refused as
|
|
1316
|
-
* `account_changed`; a value that breaks the rule is
|
|
1439
|
+
* `account_changed`; a value that breaks the rule, and any threshold on an API key account, whose limit is its spend
|
|
1440
|
+
* limit (`authswitch.authority.spendlimit`), is refused as `invalid_input`.
|
|
1317
1441
|
*/
|
|
1318
1442
|
export interface IReq_AuthSwitchSetAccountMaxUsage extends ITypedRequest {
|
|
1319
1443
|
method: 'authswitch.authority.maxusage';
|
|
@@ -1321,7 +1445,21 @@ export interface IReq_AuthSwitchSetAccountMaxUsage extends ITypedRequest {
|
|
|
1321
1445
|
response: { account: IAuthSwitchAccount };
|
|
1322
1446
|
}
|
|
1323
1447
|
|
|
1324
|
-
/**
|
|
1448
|
+
/**
|
|
1449
|
+
* Sets an API key account's monthly spend limit (`spendLimitMicroUsd`, see `isAuthSwitchSpendLimitMicroUsd`), or clears
|
|
1450
|
+
* it with `null`. Refused (`invalid_input`) for an account on a sign-in, and for a value that breaks the rule; a change
|
|
1451
|
+
* of an account that changed since `expectedRevision` is refused as `account_changed`.
|
|
1452
|
+
*/
|
|
1453
|
+
export interface IReq_AuthSwitchSetAccountSpendLimit extends ITypedRequest {
|
|
1454
|
+
method: 'authswitch.authority.spendlimit';
|
|
1455
|
+
request: { accountId: string; expectedRevision: number; spendLimitMicroUsd: number | null };
|
|
1456
|
+
response: { account: IAuthSwitchAccount };
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* Lift only the saved reserve for whole hours 1..24, using the authority clock and expected revision. Refused
|
|
1461
|
+
* (`invalid_input`) for an API key account, which has a spend limit instead of a reserve.
|
|
1462
|
+
*/
|
|
1325
1463
|
export interface IReq_AuthSwitchLiftAccountReserve extends ITypedRequest {
|
|
1326
1464
|
method: 'authswitch.authority.reserve.lift';
|
|
1327
1465
|
request: { accountId: string; expectedRevision: number; hours: number };
|
|
@@ -14,6 +14,7 @@ import { authSwitchPlacement, type IAuthSwitchPlacement, type TAuthSwitchAccount
|
|
|
14
14
|
import { authSwitchSubscriptionFacts, type IAuthSwitchSubscriptionFact } from './authority-subscription.js';
|
|
15
15
|
import type { IAuthSwitchPaymentProblem } from './classes.authorityusage.js';
|
|
16
16
|
import { plainText } from './formatting.js';
|
|
17
|
+
import { authSwitchUsdText } from './authority-spend.js';
|
|
17
18
|
import { asOfTime, compactUntil } from './timetext.js';
|
|
18
19
|
|
|
19
20
|
/** Where a tool runs on an account: a tool's own home the service switches, or a runtime AGL bound to it. */
|
|
@@ -269,6 +270,12 @@ const usageCell = (accountArg: IAuthSwitchAccountOverview, nowArg: number): stri
|
|
|
269
270
|
const lines = accountArg.windows.map((windowArg) => `${plainText(windowName(windowArg))} `
|
|
270
271
|
+ `${windowPercentText(windowArg)} · ${compactUntil(windowArg.resetAt, nowArg)}`);
|
|
271
272
|
const usage = accountArg.usage;
|
|
273
|
+
// A key's month in dollars, which its window, a percent of its limit, does not say -- nor anything without a limit.
|
|
274
|
+
if (usage?.reading?.kind === 'spend') {
|
|
275
|
+
const spend = usage.reading.spend;
|
|
276
|
+
lines.push(spend.limitMicroUsd === null ? `${authSwitchUsdText(spend.spentMicroUsd)} this month · no limit`
|
|
277
|
+
: `${authSwitchUsdText(spend.spentMicroUsd)} of ${authSwitchUsdText(spend.limitMicroUsd)} this month`);
|
|
278
|
+
}
|
|
272
279
|
if (usage?.reading && usage.observedAt !== null) lines.push(`as of ${asOfTime(usage.observedAt, nowArg)}`);
|
|
273
280
|
if (usage?.lastReadError?.rateLimited) {
|
|
274
281
|
lines.push(`last read rate-limited; next ${asOfTime(usage.nextAttemptAt, nowArg, 'not scheduled')}`);
|
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
* command line and `agl proxy status` show it; an existing session keeps its account for the prompt cache and is never
|
|
5
5
|
* placed again by it.
|
|
6
6
|
*
|
|
7
|
-
* Besides types, the module imports only the limit rule of `authority-usagelimit.ts
|
|
8
|
-
* whose own imports carry nothing to a page, so it bundles for a browser with
|
|
7
|
+
* Besides types, the module imports only the limit rule of `authority-usagelimit.ts`, the window reader it uses and the
|
|
8
|
+
* spend month of `authority-spend.ts`, whose own imports carry nothing to a page, so it bundles for a browser with
|
|
9
|
+
* nothing behind it.
|
|
9
10
|
*/
|
|
10
11
|
import type { TAuthSwitchUsageReading } from './classes.authorityusage.js';
|
|
11
12
|
import { authSwitchCountedWindows, authSwitchEffectiveMaxUsage } from './authority-usagelimit.js';
|
|
12
13
|
import { authSwitchUsageWindowPercent, type IAuthSwitchUsageWindow } from './authority-usagewindows.js';
|
|
14
|
+
import { authSwitchSpendMonthEnded, authSwitchSpendPeriod } from './authority-spend.js';
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* `active`: the account has no lapse date. `canceled`: its subscription ends at `lapsesAt`, which is still ahead.
|
|
@@ -66,6 +68,10 @@ const windowResetAt = (windowArg: IAuthSwitchUsageWindow, nowArg: number): numbe
|
|
|
66
68
|
* divided by the hours until the window's deadline: its reset, or the account's lapse when that comes first, and at
|
|
67
69
|
* least an hour. Usage that resets soon, or is lost when the account lapses, is worth more now than usage that stays for
|
|
68
70
|
* days; placing by it uses that first and spares the accounts that reset later.
|
|
71
|
+
*
|
|
72
|
+
* An API key's spend reading whose month ended (`authSwitchSpendMonthEnded`, as `authSwitchUsageLimit` judges it) says
|
|
73
|
+
* nothing about the current month, which starts with nothing spent: its window counts as 0% used, with the end of the
|
|
74
|
+
* current month (`authSwitchSpendPeriod`) as its reset.
|
|
69
75
|
*/
|
|
70
76
|
export const authSwitchPlacement = (readingArg: TAuthSwitchUsageReading | null, maxUsagePercentArg: number | null,
|
|
71
77
|
lapsesAtArg: string | null, nowArg: number, reserveLiftUntilArg: string | null = null): IAuthSwitchPlacement => {
|
|
@@ -75,9 +81,11 @@ export const authSwitchPlacement = (readingArg: TAuthSwitchUsageReading | null,
|
|
|
75
81
|
: lapsesAt - nowArg <= lapseHorizonMs ? 'last_resort' : 'normal';
|
|
76
82
|
const threshold = authSwitchEffectiveMaxUsage(maxUsagePercentArg, reserveLiftUntilArg, nowArg) ?? 100;
|
|
77
83
|
const windows = readingArg === null ? [] : authSwitchCountedWindows(readingArg, maxUsagePercentArg);
|
|
84
|
+
const spendMonthEnded = readingArg?.kind === 'spend' && authSwitchSpendMonthEnded(readingArg.spend, nowArg);
|
|
78
85
|
const scores = windows.map((windowArg) => {
|
|
79
|
-
const headroom = Math.max(0, threshold - authSwitchUsageWindowPercent(windowArg));
|
|
80
|
-
const
|
|
86
|
+
const headroom = spendMonthEnded ? threshold : Math.max(0, threshold - authSwitchUsageWindowPercent(windowArg));
|
|
87
|
+
const resetAt = spendMonthEnded ? Date.parse(authSwitchSpendPeriod(nowArg).end) : windowResetAt(windowArg, nowArg);
|
|
88
|
+
const deadline = Math.min(resetAt, lapsesAt);
|
|
81
89
|
return headroom / (Math.max(deadline - nowArg, minimumDeadlineMs) / 3_600_000);
|
|
82
90
|
});
|
|
83
91
|
return { lifecycle, eligibility, score: scores.length ? Math.min(...scores) : null };
|
|
@@ -19,9 +19,9 @@ export interface IReq_AuthSwitchBindAccount extends ITypedRequest {
|
|
|
19
19
|
loginId: string;
|
|
20
20
|
/**
|
|
21
21
|
* The login's purpose: `openai_managed` for a ChatGPT login, `claude_host_native` for a Claude login,
|
|
22
|
-
* `anthropic_api_key` for an Anthropic API key.
|
|
22
|
+
* `anthropic_api_key` for an Anthropic API key, `openai_api_key` for an OpenAI API key.
|
|
23
23
|
*/
|
|
24
|
-
purpose: 'openai_managed' | 'claude_host_native' | 'anthropic_api_key';
|
|
24
|
+
purpose: 'openai_managed' | 'claude_host_native' | 'anthropic_api_key' | 'openai_api_key';
|
|
25
25
|
runtime: IAuthSwitchBinding['runtime'];
|
|
26
26
|
scopeId: string;
|
|
27
27
|
incarnationId: string;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an API key spent: the calendar month in UTC a key's spend counts in, the reading the accounts service answers
|
|
3
|
+
* for a key's login, the spend limit rule, and how an amount reads in US dollars.
|
|
4
|
+
*
|
|
5
|
+
* Every amount is a whole number of micro-dollars (a millionth of a US dollar), so sums stay exact. The module imports
|
|
6
|
+
* nothing, so it bundles for a browser with nothing behind it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Micro-dollars in one US dollar. */
|
|
10
|
+
export const authSwitchMicroUsdPerUsd = 1_000_000;
|
|
11
|
+
|
|
12
|
+
/** Micro-dollars in one US cent: a spend limit is a whole number of cents. */
|
|
13
|
+
export const authSwitchMicroUsdPerCent = 10_000;
|
|
14
|
+
|
|
15
|
+
/** One calendar month in UTC: the period a key's spend counts in and its limit applies to. */
|
|
16
|
+
export interface IAuthSwitchSpendPeriod {
|
|
17
|
+
/** `YYYY-MM`. */
|
|
18
|
+
key: string;
|
|
19
|
+
/** 00:00 UTC on the month's first day, ISO 8601. */
|
|
20
|
+
start: string;
|
|
21
|
+
/** 00:00 UTC on the next month's first day, ISO 8601: when the month's spend resets. */
|
|
22
|
+
end: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const twoDigits = (valueArg: number): string => String(valueArg).padStart(2, '0');
|
|
26
|
+
|
|
27
|
+
/** The calendar month in UTC that `nowArg` (epoch milliseconds) falls in. */
|
|
28
|
+
export const authSwitchSpendPeriod = (nowArg: number): IAuthSwitchSpendPeriod => {
|
|
29
|
+
const at = new Date(nowArg);
|
|
30
|
+
const year = at.getUTCFullYear();
|
|
31
|
+
if (!Number.isFinite(nowArg) || year < 2000 || year > 9999) throw new RangeError('A spend period needs a time from 2000 to 9999.');
|
|
32
|
+
const month = at.getUTCMonth();
|
|
33
|
+
return {
|
|
34
|
+
key: `${year}-${twoDigits(month + 1)}`,
|
|
35
|
+
start: new Date(Date.UTC(year, month, 1)).toISOString(),
|
|
36
|
+
end: new Date(Date.UTC(year, month + 1, 1)).toISOString(),
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Whether a value is a spend period's key, `YYYY-MM` from 2000-01 to 9999-12. */
|
|
41
|
+
export const isAuthSwitchSpendPeriodKey = (valueArg: unknown): valueArg is string =>
|
|
42
|
+
typeof valueArg === 'string' && /^(?:[2-9]\d{3})-(?:0[1-9]|1[0-2])$/.test(valueArg);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* An API key's spend in one calendar month, as the accounts service holds it from what AGL's inference proxy reported:
|
|
46
|
+
* the answers the proxy relayed on the key in the month and what each cost by the proxy's price table. It counts AGL's
|
|
47
|
+
* traffic alone; whatever else used the key is not in it.
|
|
48
|
+
*/
|
|
49
|
+
export interface IAuthSwitchSpendReading {
|
|
50
|
+
/** The month, `YYYY-MM` (`IAuthSwitchSpendPeriod.key`). */
|
|
51
|
+
period: string;
|
|
52
|
+
/** 00:00 UTC on the month's first day, ISO 8601. */
|
|
53
|
+
periodStart: string;
|
|
54
|
+
/** 00:00 UTC on the next month's first day, ISO 8601: when the month's spend resets. */
|
|
55
|
+
resetAt: string;
|
|
56
|
+
/** What the month's priced answers cost, in micro-dollars. */
|
|
57
|
+
spentMicroUsd: number;
|
|
58
|
+
/** The key's monthly spend limit when the reading was answered, in micro-dollars; null for none. */
|
|
59
|
+
limitMicroUsd: number | null;
|
|
60
|
+
/** The answers counted in the month, priced or not. */
|
|
61
|
+
answers: number;
|
|
62
|
+
/**
|
|
63
|
+
* The answers of the month the proxy could not price: their cost is unknown, so a key with a limit takes no work
|
|
64
|
+
* until the month ends or its limit is removed.
|
|
65
|
+
*/
|
|
66
|
+
unpricedAnswers: number;
|
|
67
|
+
/** When the month's newest answer ended, ISO 8601; null before the month's first answer. */
|
|
68
|
+
lastAnswerAt: string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A spend limit's range in micro-dollars: one cent to one million dollars. */
|
|
72
|
+
export const authSwitchSpendLimitRange: Readonly<{ min: number; max: number }> = Object.freeze({
|
|
73
|
+
min: authSwitchMicroUsdPerCent,
|
|
74
|
+
max: 1_000_000 * authSwitchMicroUsdPerUsd,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The spend limit rule, the one the authority applies to a key's monthly limit: a whole number of cents from
|
|
79
|
+
* `authSwitchSpendLimitRange.min` to `.max`, in micro-dollars. `null` clears a limit. A consumer validates with this
|
|
80
|
+
* rather than restating it; the authority answers any other value with the `invalid_input` refusal.
|
|
81
|
+
*/
|
|
82
|
+
export const isAuthSwitchSpendLimitMicroUsd = (valueArg: unknown): valueArg is number => Number.isSafeInteger(valueArg)
|
|
83
|
+
&& (valueArg as number) >= authSwitchSpendLimitRange.min && (valueArg as number) <= authSwitchSpendLimitRange.max
|
|
84
|
+
&& (valueArg as number) % authSwitchMicroUsdPerCent === 0;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A dollar amount as a person types it -- `50`, `12.5`, `$1000.00`, no thousands separator -- in micro-dollars, or
|
|
88
|
+
* `null` when the text is no such amount. It reads at most two decimals: an amount finer than a cent is no amount.
|
|
89
|
+
*/
|
|
90
|
+
export const authSwitchParseUsd = (textArg: string): number | null => {
|
|
91
|
+
const match = /^\$?(\d{1,13})(?:\.(\d{1,2}))?$/.exec(textArg.trim());
|
|
92
|
+
if (!match) return null;
|
|
93
|
+
const microUsd = Number(match[1]) * authSwitchMicroUsdPerUsd
|
|
94
|
+
+ Number((match[2] ?? '').padEnd(2, '0')) * authSwitchMicroUsdPerCent;
|
|
95
|
+
return Number.isSafeInteger(microUsd) ? microUsd : null;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* An amount in US dollars to the cent, with thousands separators: `$12.40`, `$1,000,000.00`. A part of a cent counts
|
|
100
|
+
* as a whole one, so a spend never reads lower than it is.
|
|
101
|
+
*/
|
|
102
|
+
export const authSwitchUsdText = (microUsdArg: number): string => {
|
|
103
|
+
if (!Number.isSafeInteger(microUsdArg) || microUsdArg < 0) throw new RangeError('An amount is a whole number of micro-dollars.');
|
|
104
|
+
const cents = Math.ceil(microUsdArg / authSwitchMicroUsdPerCent);
|
|
105
|
+
const dollars = String(Math.floor(cents / 100)).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
106
|
+
return `$${dollars}.${twoDigits(cents % 100)}`;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** The share of its limit a reading's month spent, in percent (above 100 once over it); null without a limit. */
|
|
110
|
+
export const authSwitchSpendPercent = (readingArg: IAuthSwitchSpendReading): number | null =>
|
|
111
|
+
readingArg.limitMicroUsd === null ? null : readingArg.spentMicroUsd * 100 / readingArg.limitMicroUsd;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Whether a reading's month ended by `nowArg` (epoch milliseconds): its `resetAt` passed. Such a reading says nothing
|
|
115
|
+
* about the next month, which starts with nothing spent; every rule that judges a reading decides this by it.
|
|
116
|
+
*/
|
|
117
|
+
export const authSwitchSpendMonthEnded = (readingArg: IAuthSwitchSpendReading, nowArg: number): boolean =>
|
|
118
|
+
Date.parse(readingArg.resetAt) <= nowArg;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* An answer AGL's inference proxy relayed on a key whose report the accounts service has not acknowledged yet: when it
|
|
122
|
+
* ended and what it cost.
|
|
123
|
+
*/
|
|
124
|
+
export interface IAuthSwitchSpendPending {
|
|
125
|
+
/** When the answer ended, ISO 8601 UTC: its calendar month in UTC is the one it counts in. */
|
|
126
|
+
endedAt: string;
|
|
127
|
+
/** What the answer cost, in micro-dollars; null when it could not be priced. */
|
|
128
|
+
microUsd: number | null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A key's month as the proxy judges it at `nowArg` (epoch milliseconds): the held reading with the answers added whose
|
|
133
|
+
* report the accounts service has not acknowledged yet. Only the month `nowArg` falls in counts. When the held
|
|
134
|
+
* reading's month has ended, that month starts at nothing spent and no answer unpriced, under the reading's limit; of
|
|
135
|
+
* the pending answers only those that ended in it are added, priced and unpriced alike, so an answer from before
|
|
136
|
+
* midnight on the 1st counts in the month that ended, never in the new one. The reading's limit is kept: it is the limit
|
|
137
|
+
* the accounts service answered last.
|
|
138
|
+
*/
|
|
139
|
+
export const authSwitchSpendWithPending = (readingArg: IAuthSwitchSpendReading,
|
|
140
|
+
pendingArg: readonly IAuthSwitchSpendPending[], nowArg: number): IAuthSwitchSpendReading => {
|
|
141
|
+
const period = authSwitchSpendPeriod(nowArg);
|
|
142
|
+
const start = Date.parse(period.start);
|
|
143
|
+
const end = Date.parse(period.end);
|
|
144
|
+
const held = readingArg.period === period.key ? readingArg : null;
|
|
145
|
+
let spentMicroUsd = held?.spentMicroUsd ?? 0;
|
|
146
|
+
let answers = held?.answers ?? 0;
|
|
147
|
+
let unpricedAnswers = held?.unpricedAnswers ?? 0;
|
|
148
|
+
let lastAnswerAt = held?.lastAnswerAt ?? null;
|
|
149
|
+
for (const answer of pendingArg) {
|
|
150
|
+
const endedAt = Date.parse(answer.endedAt);
|
|
151
|
+
if (!(endedAt >= start && endedAt < end)) continue;
|
|
152
|
+
answers += 1;
|
|
153
|
+
if (answer.microUsd === null) unpricedAnswers += 1;
|
|
154
|
+
else spentMicroUsd += answer.microUsd;
|
|
155
|
+
if (lastAnswerAt === null || endedAt > Date.parse(lastAnswerAt)) lastAnswerAt = new Date(endedAt).toISOString();
|
|
156
|
+
}
|
|
157
|
+
return { period: period.key, periodStart: period.start, resetAt: period.end, spentMicroUsd,
|
|
158
|
+
limitMicroUsd: readingArg.limitMicroUsd, answers, unpricedAnswers, lastAnswerAt };
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Whether a key's reading keeps it from taking work at `nowArg` (epoch milliseconds): it has a limit, its month has
|
|
163
|
+
* not ended, and the month spent the limit or holds an answer that could not be priced. A key without a limit is never
|
|
164
|
+
* stopped by its spend; once the month ended the reading says nothing about the next one.
|
|
165
|
+
*/
|
|
166
|
+
export const authSwitchSpendExhausted = (readingArg: IAuthSwitchSpendReading, nowArg: number): boolean =>
|
|
167
|
+
readingArg.limitMicroUsd !== null && !authSwitchSpendMonthEnded(readingArg, nowArg)
|
|
168
|
+
&& (readingArg.spentMicroUsd >= readingArg.limitMicroUsd || readingArg.unpricedAnswers > 0);
|