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
|
@@ -5,10 +5,15 @@ import type { IOpenAiLoginHandle, IOpenAiOAuthCredential } from './interfaces.op
|
|
|
5
5
|
import type { IAuthSwitchAccount, IAuthSwitchAccountEvent, IAuthSwitchBinding, IAuthSwitchLogin,
|
|
6
6
|
IAuthSwitchNativeAssignment, IAuthSwitchOperation, IAuthSwitchSnapshot, IReq_AuthSwitchSnapshot,
|
|
7
7
|
TAuthSwitchLoginOwnerTool, TAuthSwitchRevocation } from './authority-contract.js';
|
|
8
|
-
import { AuthSwitchRefusal, isAuthSwitchReserveLiftHours, isAuthSwitchAccountLabel, isAuthSwitchLapsesAt,
|
|
9
|
-
|
|
10
|
-
import
|
|
11
|
-
|
|
8
|
+
import { AuthSwitchRefusal, authSwitchKeyHint, isAuthSwitchReserveLiftHours, isAuthSwitchAccountLabel, isAuthSwitchLapsesAt,
|
|
9
|
+
isAuthSwitchMaxUsagePercent, type TAuthSwitchApiKeyProvider } from './authority-contract.js';
|
|
10
|
+
import { authSwitchApiKeyOtherProvider, authSwitchApiKeyProviderNames, authSwitchApiKeyVerifier,
|
|
11
|
+
type TAuthSwitchApiKeyVerdict, type TAuthSwitchApiKeyVerifier } from './authority-apikeys.js';
|
|
12
|
+
import { isAuthSwitchSpendLimitMicroUsd } from './authority-spend.js';
|
|
13
|
+
import { AuthSwitchAuthorityDatabase, authorityBindPurpose, authSwitchBindPurposes, type TAuthSwitchBindPurpose,
|
|
14
|
+
type TStoredNativeHome } from './classes.authoritydatabase.js';
|
|
15
|
+
import { authorityApiKeyProvider, type IStoredAuthorityAccount, type IStoredAuthorityBinding,
|
|
16
|
+
type IStoredAuthorityGrant, type IStoredAuthorityDeviceOperation } from './classes.authoritymodels.js';
|
|
12
17
|
import type { IAuthSwitchUsageContext } from './classes.authorityusage.js';
|
|
13
18
|
import type { IClaudeNativeLogin } from './classes.claudenative.js';
|
|
14
19
|
import { AuthSwitchTpmSecretCodec, type IAuthSwitchSecretCodec } from './classes.authoritysecrets.js';
|
|
@@ -24,11 +29,10 @@ export interface IAuthSwitchAuthorityBrokerOptions {
|
|
|
24
29
|
now?: () => number;
|
|
25
30
|
proactive?: boolean;
|
|
26
31
|
/**
|
|
27
|
-
* Asks
|
|
28
|
-
*
|
|
29
|
-
* kept the question from being answered.
|
|
32
|
+
* Asks a provider whether it accepts an API key before the authority stores it (`TAuthSwitchApiKeyVerifier`): by
|
|
33
|
+
* default a read of the models the key may use (`authSwitchApiKeyVerifier`).
|
|
30
34
|
*/
|
|
31
|
-
|
|
35
|
+
verifyApiKey?: TAuthSwitchApiKeyVerifier;
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
export interface IAuthSwitchResolvedAccess {
|
|
@@ -87,15 +91,34 @@ const tokenExpiry = (credential: TOpenAiCredential): string | null => {
|
|
|
87
91
|
const revocationBudgetMs = 10_000;
|
|
88
92
|
const openAiGrantId = (accountId: string): string => idHash('authswitch-grant-v1', accountId, 'openai_managed', 'chatgpt');
|
|
89
93
|
/**
|
|
90
|
-
* An
|
|
91
|
-
*
|
|
94
|
+
* An API key's account is named by a digest of the key, the one identity a key has: the same key always lands on the
|
|
95
|
+
* same account, and nothing stored or published reveals the key. An Anthropic key keeps the digest it always had.
|
|
92
96
|
*/
|
|
93
|
-
const
|
|
97
|
+
const apiKeySubject = (providerIdArg: 'anthropic' | 'openai', apiKey: string): string =>
|
|
98
|
+
idHash(`authswitch-${providerIdArg}-api-key`, apiKey);
|
|
94
99
|
/** The workspace of an API-key account: a key names no organization the authority can read. */
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
100
|
+
const apiKeyWorkspace = 'api-key';
|
|
101
|
+
/** Whether an account is an API key's, which its workspace states (`IAuthSwitchAccount.organizationId`). */
|
|
102
|
+
const isApiKeyAccount = (account: IStoredAuthorityAccount): boolean => account.workspaceId === apiKeyWorkspace;
|
|
103
|
+
/** Each provider's key: the purpose and audience of its grant, the account label without one given. */
|
|
104
|
+
const apiKeyGrants: Readonly<Record<TAuthSwitchApiKeyProvider, {
|
|
105
|
+
purpose: 'anthropic_api_key' | 'openai_api_key'; audience: string; label: string;
|
|
106
|
+
}>> = Object.freeze({
|
|
107
|
+
anthropic: { purpose: 'anthropic_api_key', audience: 'anthropic-api', label: 'Anthropic API key' },
|
|
108
|
+
openai: { purpose: 'openai_api_key', audience: 'openai-api', label: 'OpenAI API key' },
|
|
109
|
+
});
|
|
110
|
+
/**
|
|
111
|
+
* What a sealed key is, one kind per provider: `{ kind, apiKey }`. Sealed data is never migrated, so a kind stays as it
|
|
112
|
+
* was first written; the provider is the grant's, and the kind must be that provider's.
|
|
113
|
+
*/
|
|
114
|
+
const apiKeySealedKinds: Readonly<Record<TAuthSwitchApiKeyProvider, string>> = Object.freeze({
|
|
115
|
+
anthropic: 'anthropicApiKey',
|
|
116
|
+
openai: 'openaiApiKey',
|
|
117
|
+
});
|
|
118
|
+
/** The shortest key the authority holds: its hint shows four characters, which must stay a small part of it. */
|
|
119
|
+
const apiKeyMinimumLength = 16;
|
|
120
|
+
/** How long a provider is given to answer whether it accepts a key being added. */
|
|
121
|
+
const apiKeyVerifyTimeoutMs = 15_000;
|
|
99
122
|
|
|
100
123
|
/**
|
|
101
124
|
* What a caller is told when the answer is decided. Each states the repair and names no id, path or host.
|
|
@@ -107,7 +130,7 @@ const anthropicApiKeyVerifyTimeoutMs = 15_000;
|
|
|
107
130
|
const unknownOperation = 'This authority knows no device sign-in by that operation ID. '
|
|
108
131
|
+ 'Start a new sign-in and follow that one.';
|
|
109
132
|
const loginNotBindable = 'This login cannot back a runtime binding. The authority must hold the login '
|
|
110
|
-
+ 'itself, it must be the account\'s selected login, and it must be ready; a Claude login and an
|
|
133
|
+
+ 'itself, it must be the account\'s selected login, and it must be ready; a Claude login and an API key '
|
|
111
134
|
+ 'back only the gateway runtime. Reauthenticate the account or choose another one.';
|
|
112
135
|
const bindingUnauthorized = 'This runtime binding is not authorized. Bind the account again to obtain a '
|
|
113
136
|
+ 'capability for it.';
|
|
@@ -119,12 +142,21 @@ const loginNeedsReauth = 'This account\'s sign-in has ended. Reauthenticate the
|
|
|
119
142
|
+ 'sign-in; a runtime bound to it binds again afterwards.';
|
|
120
143
|
const accessNotFresh = 'The provider could not renew this account\'s access yet. Try the request again '
|
|
121
144
|
+ 'shortly; the authority keeps retrying the renewal.';
|
|
122
|
-
|
|
123
|
-
|
|
145
|
+
/** A key its provider no longer accepts: keys are not renewed, so a new key takes its place. */
|
|
146
|
+
const apiKeyRejected = (providerIdArg: TAuthSwitchApiKeyProvider): string => `${authSwitchApiKeyProviderNames[providerIdArg]
|
|
147
|
+
.name} no longer accepts this account's API key. Add a new key, then remove this account.`;
|
|
148
|
+
/**
|
|
149
|
+
* The spend limit rule (`isAuthSwitchSpendLimitMicroUsd`), spelled out like the label rule: an owner-facing instruction
|
|
150
|
+
* carries no bare number.
|
|
151
|
+
*/
|
|
152
|
+
const spendLimitRule = 'A monthly spend limit is a whole number of cents, from one cent to one million dollars. Choose '
|
|
153
|
+
+ 'another amount, or clear it.';
|
|
154
|
+
const notOnApiKey = 'An API key account has no usage windows to keep a reserve of. Set its monthly spend limit '
|
|
155
|
+
+ 'instead.';
|
|
124
156
|
const publicAccount = (account: IStoredAuthorityAccount): IAuthSwitchAccount => ({
|
|
125
157
|
id: account.id, providerId: account.providerId, label: account.label, email: account.email,
|
|
126
158
|
plan: account.plan, organizationId: account.workspaceId, maxUsagePercent: account.maxUsagePercent, reserveLiftUntil: account.reserveLiftUntil,
|
|
127
|
-
lapsesAt: account.lapsesAt,
|
|
159
|
+
lapsesAt: account.lapsesAt, spendLimitMicroUsd: account.spendLimitMicroUsd, keyHint: account.keyHint,
|
|
128
160
|
removed: account.removed, revision: account.revision, statusObservedAt: account.statusObservedAt,
|
|
129
161
|
});
|
|
130
162
|
|
|
@@ -148,6 +180,7 @@ const loginOwnerTool = (grant: IStoredAuthorityGrant): TAuthSwitchLoginOwnerTool
|
|
|
148
180
|
case 'claude_container_setup': return null;
|
|
149
181
|
// An API key is never renewed, and no tool on this host holds one.
|
|
150
182
|
case 'anthropic_api_key': return null;
|
|
183
|
+
case 'openai_api_key': return null;
|
|
151
184
|
// A purpose added without deciding its tool fails the type check here rather than publishing `null`,
|
|
152
185
|
// which would claim the authority refreshes a login some tool actually holds.
|
|
153
186
|
default: { const unhandledPurpose: never = grant.purpose; return unhandledPurpose; }
|
|
@@ -215,7 +248,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
215
248
|
private readonly secrets: IAuthSwitchSecretCodec;
|
|
216
249
|
private readonly now: () => number;
|
|
217
250
|
private readonly proactive: boolean;
|
|
218
|
-
private readonly
|
|
251
|
+
private readonly verifyApiKey: TAuthSwitchApiKeyVerifier;
|
|
219
252
|
private readonly starts = new Map<string, IStartingOperation>();
|
|
220
253
|
private readonly operations = new Map<string, IManagedOperation>();
|
|
221
254
|
private readonly refreshes = new Map<string, Promise<void>>();
|
|
@@ -235,9 +268,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
235
268
|
this.secrets = options.secrets ?? new AuthSwitchTpmSecretCodec();
|
|
236
269
|
this.now = options.now ?? Date.now;
|
|
237
270
|
this.proactive = options.proactive ?? true;
|
|
238
|
-
this.
|
|
239
|
-
await plugins.flexAnthropic.listAnthropicApiKeyModels({ apiKey }, { signal });
|
|
240
|
-
});
|
|
271
|
+
this.verifyApiKey = options.verifyApiKey ?? authSwitchApiKeyVerifier();
|
|
241
272
|
// Both access paths renew within the same five minutes (`claudeRefreshMarginMs`, `isDue`).
|
|
242
273
|
this.access = new AuthSwitchAccessCache({ now: () => this.now(), marginMs: claudeRefreshMarginMs,
|
|
243
274
|
maxEntries: accessCacheEntries });
|
|
@@ -644,7 +675,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
644
675
|
revision: existing.revision + 1, statusObservedAt: now, updateId: commitId }
|
|
645
676
|
: { id: identity.id, providerId: 'openai', issuer: 'openai', subject: identity.subject,
|
|
646
677
|
workspaceId: identity.workspaceId, label: identity.email ?? `OpenAI ${identity.workspaceId}`,
|
|
647
|
-
email: identity.email, plan: identity.plan, primaryGrantId: grantId, maxUsagePercent: null, reserveLiftUntil: null, lapsesAt: null, removed: false,
|
|
678
|
+
email: identity.email, plan: identity.plan, primaryGrantId: grantId, maxUsagePercent: null, reserveLiftUntil: null, lapsesAt: null, spendLimitMicroUsd: null, keyHint: null, removed: false,
|
|
648
679
|
revision: 1, statusObservedAt: now, updateId: commitId };
|
|
649
680
|
const grant: IStoredAuthorityGrant = oldGrant
|
|
650
681
|
? { ...oldGrant, state: 'ready', owner: 'daemon', ciphertext,
|
|
@@ -784,6 +815,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
784
815
|
const updateId = plugins.crypto.randomUUID();
|
|
785
816
|
const updated = await this.database.changeAccount(updateId, accountId, account => {
|
|
786
817
|
if (!account || account.removed || account.revision !== expectedRevision) throw new AuthSwitchRefusal('account_changed', 'Account changed; refresh before editing.');
|
|
818
|
+
if (isApiKeyAccount(account)) throw new AuthSwitchRefusal('invalid_input', notOnApiKey);
|
|
787
819
|
return { ...account, maxUsagePercent, revision: account.revision + 1, updateId,
|
|
788
820
|
statusObservedAt: new Date(this.now()).toISOString() };
|
|
789
821
|
});
|
|
@@ -802,6 +834,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
802
834
|
if (!account || account.removed || account.revision !== expectedRevision) {
|
|
803
835
|
throw new AuthSwitchRefusal('account_changed', 'Account changed; refresh before editing.');
|
|
804
836
|
}
|
|
837
|
+
if (isApiKeyAccount(account)) throw new AuthSwitchRefusal('invalid_input', notOnApiKey);
|
|
805
838
|
if (account.maxUsagePercent === null || account.maxUsagePercent === 100) {
|
|
806
839
|
throw new AuthSwitchRefusal('invalid_input', 'This account has no reserve to lift.');
|
|
807
840
|
}
|
|
@@ -813,6 +846,30 @@ export class AuthSwitchAuthorityBroker {
|
|
|
813
846
|
return publicAccount(updated.account);
|
|
814
847
|
}
|
|
815
848
|
|
|
849
|
+
/**
|
|
850
|
+
* Sets or clears an API key account's monthly spend limit, as `setAccountMaxUsage` changes a sign-in's threshold.
|
|
851
|
+
* A key's limit applies from the next request on, to the month's spend so far.
|
|
852
|
+
*/
|
|
853
|
+
public async setAccountSpendLimit(accountId: string, expectedRevision: number,
|
|
854
|
+
spendLimitMicroUsd: number | null): Promise<IAuthSwitchAccount> {
|
|
855
|
+
if (!isId(accountId) || !validRevision(expectedRevision)) throw new Error('Invalid account change.');
|
|
856
|
+
if (spendLimitMicroUsd !== null && !isAuthSwitchSpendLimitMicroUsd(spendLimitMicroUsd)) {
|
|
857
|
+
throw new AuthSwitchRefusal('invalid_input', spendLimitRule);
|
|
858
|
+
}
|
|
859
|
+
const updateId = plugins.crypto.randomUUID();
|
|
860
|
+
const updated = await this.database.changeAccount(updateId, accountId, account => {
|
|
861
|
+
if (!account || account.removed || account.revision !== expectedRevision) throw new AuthSwitchRefusal('account_changed', 'Account changed; refresh before editing.');
|
|
862
|
+
if (!isApiKeyAccount(account)) {
|
|
863
|
+
throw new AuthSwitchRefusal('invalid_input', 'Only an API key account has a spend limit. Set a sign-in\'s '
|
|
864
|
+
+ 'maximum usage instead.');
|
|
865
|
+
}
|
|
866
|
+
return { ...account, spendLimitMicroUsd, revision: account.revision + 1, updateId,
|
|
867
|
+
statusObservedAt: new Date(this.now()).toISOString() };
|
|
868
|
+
});
|
|
869
|
+
this.publish();
|
|
870
|
+
return publicAccount(updated.account);
|
|
871
|
+
}
|
|
872
|
+
|
|
816
873
|
/**
|
|
817
874
|
* Sets or clears the date an account's subscription ends, as `setAccountMaxUsage` changes its threshold. The date is
|
|
818
875
|
* stored in its ISO 8601 UTC form; a past one is kept as given and means the account lapsed already.
|
|
@@ -886,7 +943,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
886
943
|
* Whether this daemon renews logins of a bind purpose: Claude logins only with its Claude renewer configured. An
|
|
887
944
|
* API key never needs renewing.
|
|
888
945
|
*/
|
|
889
|
-
private renews(purposeArg:
|
|
946
|
+
private renews(purposeArg: TAuthSwitchBindPurpose): boolean {
|
|
890
947
|
return purposeArg !== 'claude_host_native' || this.claudeGrants !== undefined;
|
|
891
948
|
}
|
|
892
949
|
|
|
@@ -896,14 +953,14 @@ export class AuthSwitchAuthorityBroker {
|
|
|
896
953
|
*/
|
|
897
954
|
private bindableNow(grant: IStoredAuthorityGrant, storedBindable: boolean): boolean {
|
|
898
955
|
return storedBindable && (grant.purpose === 'openai_managed' || grant.purpose === 'claude_host_native'
|
|
899
|
-
|| grant.purpose === 'anthropic_api_key') && this.renews(grant.purpose);
|
|
956
|
+
|| grant.purpose === 'anthropic_api_key' || grant.purpose === 'openai_api_key') && this.renews(grant.purpose);
|
|
900
957
|
}
|
|
901
958
|
|
|
902
959
|
public async bindAccount(input: { accountId: string; loginId: string;
|
|
903
|
-
purpose:
|
|
960
|
+
purpose: TAuthSwitchBindPurpose; runtime: IAuthSwitchBinding['runtime'];
|
|
904
961
|
scopeId: string; incarnationId: string }): Promise<{ binding: IAuthSwitchBinding; capability: string }> {
|
|
905
962
|
if (!isId(input.accountId) || !isId(input.loginId)
|
|
906
|
-
|| !
|
|
963
|
+
|| !authSwitchBindPurposes.includes(input.purpose)
|
|
907
964
|
|| !['flex', 'codex', 'opencode', 'claude', 'gateway'].includes(input.runtime)
|
|
908
965
|
|| !safeScope(input.scopeId, 512) || !safeScope(input.incarnationId, 256)) throw new Error('Invalid account binding.');
|
|
909
966
|
// A Claude login reaches Claude Code itself through the native switch; it backs only the inference gateway,
|
|
@@ -912,8 +969,8 @@ export class AuthSwitchAuthorityBroker {
|
|
|
912
969
|
if (claude && (input.runtime !== 'gateway' || !this.renews(input.purpose))) {
|
|
913
970
|
throw new AuthSwitchRefusal('login_unavailable', loginNotBindable);
|
|
914
971
|
}
|
|
915
|
-
// An
|
|
916
|
-
if (input.purpose === 'anthropic_api_key' && input.runtime !== 'gateway') {
|
|
972
|
+
// An API key backs the gateway alone too: the proxy relays requests on it at API prices.
|
|
973
|
+
if ((input.purpose === 'anthropic_api_key' || input.purpose === 'openai_api_key') && input.runtime !== 'gateway') {
|
|
917
974
|
throw new AuthSwitchRefusal('login_unavailable', loginNotBindable);
|
|
918
975
|
}
|
|
919
976
|
if (claude && !(await this.database.hasVerifiedClaudeGrantReceipt(input.accountId, input.loginId))) {
|
|
@@ -1050,10 +1107,12 @@ export class AuthSwitchAuthorityBroker {
|
|
|
1050
1107
|
}>, minValidityMs: number, rejectedGrantGeneration?: number): Promise<IAuthSwitchResolvedAccess> {
|
|
1051
1108
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
1052
1109
|
const { account, grant } = await readView();
|
|
1110
|
+
// An API key is resolved as a key whatever its provider, so an OpenAI key never reaches the ChatGPT path below.
|
|
1111
|
+
if (attempt === 0 && (grant.purpose === 'anthropic_api_key' || grant.purpose === 'openai_api_key')) {
|
|
1112
|
+
return this.resolveApiKeyAccess(readView, rejectedGrantGeneration);
|
|
1113
|
+
}
|
|
1053
1114
|
if (attempt === 0 && account.providerId === 'anthropic' && grant.providerId === 'anthropic') {
|
|
1054
|
-
return
|
|
1055
|
-
? this.resolveAnthropicApiKeyAccess(readView, rejectedGrantGeneration)
|
|
1056
|
-
: this.resolveClaudeAccess(readView, minValidityMs, rejectedGrantGeneration);
|
|
1115
|
+
return this.resolveClaudeAccess(readView, minValidityMs, rejectedGrantGeneration);
|
|
1057
1116
|
}
|
|
1058
1117
|
if (account.removed || grant.accountId !== account.id || grant.id !== account.primaryGrantId
|
|
1059
1118
|
|| account.providerId !== 'openai' || grant.providerId !== 'openai') {
|
|
@@ -1176,17 +1235,18 @@ export class AuthSwitchAuthorityBroker {
|
|
|
1176
1235
|
}
|
|
1177
1236
|
|
|
1178
1237
|
/**
|
|
1179
|
-
* A bound Anthropic API key, with the refusals `resolveManagedAccess` gives. A key is never renewed: one
|
|
1180
|
-
* rejected for exactly this generation ends (`needs_reauth`, `provider_rejected`), and only adding a key
|
|
1181
|
-
* brings the account back.
|
|
1238
|
+
* A bound Anthropic or OpenAI API key, with the refusals `resolveManagedAccess` gives. A key is never renewed: one its
|
|
1239
|
+
* provider rejected for exactly this generation ends (`needs_reauth`, `provider_rejected`), and only adding a key the
|
|
1240
|
+
* provider accepts brings the account back.
|
|
1182
1241
|
*/
|
|
1183
|
-
private async
|
|
1242
|
+
private async resolveApiKeyAccess(readView: () => Promise<{
|
|
1184
1243
|
account: IStoredAuthorityAccount; grant: IStoredAuthorityGrant;
|
|
1185
1244
|
}>, rejectedGrantGeneration?: number): Promise<IAuthSwitchResolvedAccess> {
|
|
1186
1245
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
1187
1246
|
const { account, grant } = await readView();
|
|
1188
1247
|
if (account.removed || grant.accountId !== account.id || grant.id !== account.primaryGrantId
|
|
1189
|
-
||
|
|
1248
|
+
|| (grant.purpose !== 'anthropic_api_key' && grant.purpose !== 'openai_api_key')
|
|
1249
|
+
|| account.providerId !== authorityApiKeyProvider(grant.purpose) || grant.providerId !== account.providerId
|
|
1190
1250
|
|| grant.owner !== 'daemon') {
|
|
1191
1251
|
throw this.loginEnded(grant.id);
|
|
1192
1252
|
}
|
|
@@ -1209,9 +1269,9 @@ export class AuthSwitchAuthorityBroker {
|
|
|
1209
1269
|
if (grant.state !== 'ready') {
|
|
1210
1270
|
this.access.drop(grant.id);
|
|
1211
1271
|
throw new AuthSwitchRefusal('login_needs_reauth',
|
|
1212
|
-
grant.problem === 'provider_rejected' ? apiKeyRejected : loginNeedsReauth);
|
|
1272
|
+
grant.problem === 'provider_rejected' ? apiKeyRejected(authorityApiKeyProvider(grant.purpose)) : loginNeedsReauth);
|
|
1213
1273
|
}
|
|
1214
|
-
const access = await this.access.open(account, grant, () => this.
|
|
1274
|
+
const access = await this.access.open(account, grant, () => this.openApiKeyAccess(account, grant));
|
|
1215
1275
|
const latest = await readView();
|
|
1216
1276
|
if (latest.account.removed || latest.account.id !== account.id
|
|
1217
1277
|
|| latest.account.primaryGrantId !== latest.grant.id || latest.account.subject !== account.subject
|
|
@@ -1227,49 +1287,80 @@ export class AuthSwitchAuthorityBroker {
|
|
|
1227
1287
|
throw new Error('Account credential rotated repeatedly during resolution; retry the request.');
|
|
1228
1288
|
}
|
|
1229
1289
|
|
|
1230
|
-
/**
|
|
1231
|
-
|
|
1290
|
+
/**
|
|
1291
|
+
* The key a daemon-held API key grant seals, proven to be the key its account is named by: its provider's sealed
|
|
1292
|
+
* kind (`apiKeySealedKinds`) and a key whose digest is the account's subject.
|
|
1293
|
+
*/
|
|
1294
|
+
private async openApiKeyAccess(account: IStoredAuthorityAccount,
|
|
1232
1295
|
grant: IStoredAuthorityGrant): Promise<IAuthSwitchGrantAccess> {
|
|
1233
|
-
if (!grant.ciphertext || grant.owner !== 'daemon'
|
|
1296
|
+
if (!grant.ciphertext || grant.owner !== 'daemon'
|
|
1297
|
+
|| (grant.purpose !== 'anthropic_api_key' && grant.purpose !== 'openai_api_key')
|
|
1234
1298
|
|| grant.accountId !== account.id) throw new Error('Account has no daemon-owned API key.');
|
|
1299
|
+
const providerId = authorityApiKeyProvider(grant.purpose);
|
|
1235
1300
|
const bytes = await this.secrets.unseal(grant.id, grant.ciphertext);
|
|
1236
1301
|
try {
|
|
1237
1302
|
const value: unknown = JSON.parse(Buffer.from(bytes).toString('utf8'));
|
|
1238
1303
|
const record = value && typeof value === 'object' ? value as Record<string, unknown> : {};
|
|
1239
|
-
if (record.kind !==
|
|
1240
|
-
||
|
|
1304
|
+
if (record.kind !== apiKeySealedKinds[providerId] || typeof record.apiKey !== 'string' || !record.apiKey
|
|
1305
|
+
|| apiKeySubject(providerId, record.apiKey) !== account.subject) throw new Error();
|
|
1241
1306
|
return { accessToken: record.apiKey, expiresAt: Infinity };
|
|
1242
1307
|
} catch { throw new Error('Stored API key is invalid or belongs to another account.'); }
|
|
1243
1308
|
finally { bytes.fill(0); }
|
|
1244
1309
|
}
|
|
1245
1310
|
|
|
1246
1311
|
/**
|
|
1247
|
-
* Adds an Anthropic API key as an account of its own once
|
|
1248
|
-
* the
|
|
1249
|
-
*
|
|
1250
|
-
*
|
|
1312
|
+
* Adds an Anthropic or OpenAI API key as an account of its own once its provider accepted it. A key that begins the
|
|
1313
|
+
* way the other provider's keys do is refused before any request. The key is sealed like every login the authority
|
|
1314
|
+
* holds and never answered on the management socket again; the account keeps only how it ends (`keyHint`). A key the
|
|
1315
|
+
* authority holds already is refused while its account is ready; adding it after its account was removed, or after
|
|
1316
|
+
* its provider rejected it, brings that account back with a new authorization, so a runtime bound to it binds again,
|
|
1317
|
+
* and with the spend limit it had unless the request names one.
|
|
1251
1318
|
*/
|
|
1252
|
-
public async
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1319
|
+
public async addApiKey(providerId: TAuthSwitchApiKeyProvider, apiKey: string, label?: string,
|
|
1320
|
+
spendLimitMicroUsd?: number): Promise<{ account: IAuthSwitchAccount; loginId: string }> {
|
|
1321
|
+
if (providerId !== 'anthropic' && providerId !== 'openai') throw new Error('Invalid API key provider.');
|
|
1322
|
+
const provider = authSwitchApiKeyProviderNames[providerId];
|
|
1323
|
+
if (typeof apiKey !== 'string' || apiKey.length < apiKeyMinimumLength || apiKey.length > 512
|
|
1324
|
+
|| !/^[\x21-\x7e]+$/.test(apiKey)) {
|
|
1325
|
+
throw new AuthSwitchRefusal('invalid_input', `An ${provider.name} API key is one word of printable characters, as `
|
|
1326
|
+
+ `${provider.console} shows it. Paste the key again.`);
|
|
1327
|
+
}
|
|
1328
|
+
const other = authSwitchApiKeyOtherProvider(providerId, apiKey);
|
|
1329
|
+
if (other !== null) {
|
|
1330
|
+
throw new AuthSwitchRefusal('invalid_input', `This is an ${authSwitchApiKeyProviderNames[other].name} API key, by `
|
|
1331
|
+
+ `how it begins. Add it as an ${authSwitchApiKeyProviderNames[other].name} key, or paste the ${provider.name} key.`);
|
|
1256
1332
|
}
|
|
1257
1333
|
if (label !== undefined && !isAuthSwitchAccountLabel(label)) {
|
|
1258
1334
|
throw new AuthSwitchRefusal('invalid_input', 'An account label has between one and one hundred and twenty-eight '
|
|
1259
1335
|
+ 'characters, with no space at either end and no control character. Choose another label.');
|
|
1260
1336
|
}
|
|
1337
|
+
if (spendLimitMicroUsd !== undefined && !isAuthSwitchSpendLimitMicroUsd(spendLimitMicroUsd)) {
|
|
1338
|
+
throw new AuthSwitchRefusal('invalid_input', spendLimitRule);
|
|
1339
|
+
}
|
|
1261
1340
|
if (this.closed) throw new Error('Authswitch authority is closing.');
|
|
1341
|
+
let verdict: TAuthSwitchApiKeyVerdict;
|
|
1262
1342
|
try {
|
|
1263
|
-
await this.
|
|
1264
|
-
} catch
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1343
|
+
verdict = await this.verifyApiKey(providerId, apiKey, AbortSignal.timeout(apiKeyVerifyTimeoutMs));
|
|
1344
|
+
} catch {
|
|
1345
|
+
// The provider's own words are left out: an error may quote the request, and with it the key.
|
|
1346
|
+
throw new AuthSwitchRefusal('api_key_unverified', `${provider.name} could not be asked whether it accepts this `
|
|
1347
|
+
+ 'API key. Try again shortly.');
|
|
1348
|
+
}
|
|
1349
|
+
if (verdict === 'rejected') {
|
|
1350
|
+
throw new AuthSwitchRefusal('api_key_unverified', `${provider.name} rejected this API key. Check that it is an `
|
|
1351
|
+
+ `active key from ${provider.console}, then add it again.`);
|
|
1352
|
+
}
|
|
1353
|
+
if (verdict === 'forbidden') {
|
|
1354
|
+
throw new AuthSwitchRefusal('api_key_unverified', `${provider.name} accepts this API key but does not let it `
|
|
1355
|
+
+ 'list models, which is how AGL checks a key. Give the key read access to models (or all permissions) in '
|
|
1356
|
+
+ `${provider.console}, then add it again.`);
|
|
1268
1357
|
}
|
|
1269
|
-
const
|
|
1270
|
-
const
|
|
1271
|
-
const
|
|
1272
|
-
const
|
|
1358
|
+
const kind = apiKeyGrants[providerId];
|
|
1359
|
+
const subject = apiKeySubject(providerId, apiKey);
|
|
1360
|
+
const accountId = idHash('authswitch-account-v1', providerId, subject, apiKeyWorkspace);
|
|
1361
|
+
const grantId = idHash('authswitch-grant-v1', accountId, kind.purpose, kind.audience);
|
|
1362
|
+
const keyHint = authSwitchKeyHint(apiKey);
|
|
1363
|
+
const bytes = Buffer.from(JSON.stringify({ kind: apiKeySealedKinds[providerId], apiKey }), 'utf8');
|
|
1273
1364
|
let ciphertext: string;
|
|
1274
1365
|
try { ciphertext = await this.secrets.seal(grantId, bytes); }
|
|
1275
1366
|
finally { bytes.fill(0); }
|
|
@@ -1282,18 +1373,20 @@ export class AuthSwitchAuthorityBroker {
|
|
|
1282
1373
|
+ 'instead of adding the key again.');
|
|
1283
1374
|
}
|
|
1284
1375
|
const account: IStoredAuthorityAccount = existing
|
|
1285
|
-
? { ...existing, label: label ?? existing.label, primaryGrantId: grantId,
|
|
1376
|
+
? { ...existing, label: label ?? existing.label, primaryGrantId: grantId, keyHint,
|
|
1377
|
+
spendLimitMicroUsd: spendLimitMicroUsd ?? existing.spendLimitMicroUsd, removed: false,
|
|
1286
1378
|
revision: existing.revision + 1, statusObservedAt: now, updateId }
|
|
1287
|
-
: { id: accountId, providerId
|
|
1288
|
-
label: label ??
|
|
1289
|
-
|
|
1379
|
+
: { id: accountId, providerId, issuer: providerId, subject, workspaceId: apiKeyWorkspace,
|
|
1380
|
+
label: label ?? kind.label, email: null, plan: null, primaryGrantId: grantId, maxUsagePercent: null,
|
|
1381
|
+
reserveLiftUntil: null, lapsesAt: null, spendLimitMicroUsd: spendLimitMicroUsd ?? null, keyHint,
|
|
1382
|
+
removed: false, revision: 1, statusObservedAt: now, updateId };
|
|
1290
1383
|
const grant: IStoredAuthorityGrant = oldGrant
|
|
1291
1384
|
? { ...oldGrant, state: 'ready', owner: 'daemon', ciphertext, grantGeneration: oldGrant.grantGeneration + 1,
|
|
1292
1385
|
authorizationGeneration: oldGrant.authorizationGeneration + 1, revision: oldGrant.revision + 1,
|
|
1293
1386
|
accessExpiresAt: null, attemptId: null, retryAt: null, retryCount: 0, problem: 'none', statusObservedAt: now,
|
|
1294
1387
|
updateId }
|
|
1295
|
-
: { id: grantId, accountId, providerId
|
|
1296
|
-
audience:
|
|
1388
|
+
: { id: grantId, accountId, providerId, purpose: kind.purpose,
|
|
1389
|
+
audience: kind.audience, state: 'ready', owner: 'daemon', grantGeneration: 1,
|
|
1297
1390
|
authorizationGeneration: 1, revision: 1, ciphertext, accessExpiresAt: null, attemptId: null, retryAt: null,
|
|
1298
1391
|
retryCount: 0, problem: 'none', statusObservedAt: now, updateId };
|
|
1299
1392
|
return { account, grant };
|