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.
Files changed (149) hide show
  1. package/changelog.md +27 -0
  2. package/dist_serve/bundle.js +2228 -2177
  3. package/dist_serve/bundle.js.map +2 -2
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/aglapi/aglapisurface.document.js +2 -2
  6. package/dist_ts/classes.accountsactions.d.ts +11 -1
  7. package/dist_ts/classes.accountsactions.js +59 -2
  8. package/dist_ts/classes.controller.js +5 -5
  9. package/dist_ts/classes.flexaccountaccess.js +3 -2
  10. package/dist_ts/classes.harnessreleases.d.ts +14 -4
  11. package/dist_ts/classes.harnessreleases.js +28 -4
  12. package/dist_ts/classes.inferencegateway.d.ts +28 -3
  13. package/dist_ts/classes.inferencegateway.js +177 -61
  14. package/dist_ts/classes.inferenceplacement.d.ts +5 -3
  15. package/dist_ts/classes.inferenceplacement.js +2 -2
  16. package/dist_ts/classes.inferencerouter.d.ts +349 -91
  17. package/dist_ts/classes.inferencerouter.js +804 -297
  18. package/dist_ts/classes.inferenceservice.d.ts +14 -2
  19. package/dist_ts/classes.inferenceservice.js +53 -14
  20. package/dist_ts/classes.inferencespend.d.ts +339 -0
  21. package/dist_ts/classes.inferencespend.js +815 -0
  22. package/dist_ts/classes.inferenceusagecache.d.ts +8 -0
  23. package/dist_ts/classes.inferenceusagecache.js +9 -5
  24. package/dist_ts/classes.inferenceusagetap.d.ts +50 -9
  25. package/dist_ts/classes.inferenceusagetap.js +185 -31
  26. package/dist_ts/classes.proxycontrol.d.ts +3 -2
  27. package/dist_ts/classes.proxycontrol.js +1 -2
  28. package/dist_ts/classes.proxydaemon.js +3 -2
  29. package/dist_ts/classes.proxystore.d.ts +6 -0
  30. package/dist_ts/classes.proxystore.js +11 -2
  31. package/dist_ts/fleet/classes.claudeworkers.d.ts +5 -4
  32. package/dist_ts/fleet/classes.claudeworkers.js +10 -9
  33. package/dist_ts/functions.claudecoderelease.d.ts +12 -6
  34. package/dist_ts/functions.claudecoderelease.js +13 -7
  35. package/dist_ts/functions.inferencecli.js +12 -8
  36. package/dist_ts/interfaces.auth.d.ts +1 -1
  37. package/dist_ts/interfaces.auth.js +3 -1
  38. package/dist_ts/plugins.d.ts +3 -1
  39. package/dist_ts/plugins.js +7 -2
  40. package/dist_ts_authswitch/authority-apikeys.d.ts +32 -0
  41. package/dist_ts_authswitch/authority-apikeys.js +65 -0
  42. package/dist_ts_authswitch/authority-contract.d.ts +142 -28
  43. package/dist_ts_authswitch/authority-contract.js +48 -1
  44. package/dist_ts_authswitch/authority-overview.js +8 -1
  45. package/dist_ts_authswitch/authority-placement.d.ts +7 -2
  46. package/dist_ts_authswitch/authority-placement.js +10 -3
  47. package/dist_ts_authswitch/authority-runtime-contract.d.ts +2 -2
  48. package/dist_ts_authswitch/authority-spend.d.ts +103 -0
  49. package/dist_ts_authswitch/authority-spend.js +110 -0
  50. package/dist_ts_authswitch/authority-subscription.d.ts +12 -1
  51. package/dist_ts_authswitch/authority-subscription.js +24 -1
  52. package/dist_ts_authswitch/authority-usagelimit.d.ts +7 -4
  53. package/dist_ts_authswitch/authority-usagelimit.js +89 -39
  54. package/dist_ts_authswitch/authority-usagewindows.d.ts +4 -1
  55. package/dist_ts_authswitch/authority-usagewindows.js +41 -16
  56. package/dist_ts_authswitch/classes.authoritybroker.d.ts +30 -19
  57. package/dist_ts_authswitch/classes.authoritybroker.js +152 -61
  58. package/dist_ts_authswitch/classes.authoritycli.d.ts +9 -4
  59. package/dist_ts_authswitch/classes.authoritycli.js +94 -29
  60. package/dist_ts_authswitch/classes.authorityclient.d.ts +24 -7
  61. package/dist_ts_authswitch/classes.authorityclient.js +31 -11
  62. package/dist_ts_authswitch/classes.authoritydaemon.js +39 -12
  63. package/dist_ts_authswitch/classes.authoritydatabase.d.ts +28 -5
  64. package/dist_ts_authswitch/classes.authoritydatabase.js +171 -9
  65. package/dist_ts_authswitch/classes.authorityimport.js +2 -2
  66. package/dist_ts_authswitch/classes.authoritymodels.d.ts +71 -3
  67. package/dist_ts_authswitch/classes.authoritymodels.js +249 -20
  68. package/dist_ts_authswitch/classes.authorityregistry.js +4 -2
  69. package/dist_ts_authswitch/classes.authorityusage.d.ts +69 -7
  70. package/dist_ts_authswitch/classes.authorityusage.js +118 -4
  71. package/dist_ts_authswitch/classes.claudenative.d.ts +16 -7
  72. package/dist_ts_authswitch/classes.claudenative.js +29 -16
  73. package/dist_ts_authswitch/classes.claudesignin.js +5 -2
  74. package/dist_ts_authswitch/testsupport.d.ts +24 -5
  75. package/dist_ts_authswitch/testsupport.js +56 -12
  76. package/dist_ts_authswitch/ts_migration/0011_account_api_key_fields.d.ts +12 -0
  77. package/dist_ts_authswitch/ts_migration/0011_account_api_key_fields.js +21 -0
  78. package/dist_ts_authswitch/ts_migration/index.js +3 -1
  79. package/dist_ts_interfaces/functions.authorityscope.d.ts +18 -7
  80. package/dist_ts_interfaces/functions.authorityscope.js +35 -8
  81. package/dist_ts_interfaces/interfaces.d.ts +1 -1
  82. package/dist_ts_interfaces/interfaces.js +1 -1
  83. package/dist_ts_interfaces/plugins.d.ts +2 -1
  84. package/dist_ts_interfaces/plugins.js +7 -4
  85. package/dist_ts_interfaces/requests.accounts.d.ts +56 -0
  86. package/dist_ts_interfaces/requests.accounts.js +11 -1
  87. package/dist_ts_interfaces/requests.d.ts +2 -2
  88. package/dist_ts_interfaces/requests.js +1 -1
  89. package/docs/authswitch.md +75 -12
  90. package/package.json +5 -5
  91. package/readme.md +198 -21
  92. package/ts/00_commitinfo_data.ts +1 -1
  93. package/ts/aglapi/aglapisurface.document.ts +2 -2
  94. package/ts/classes.accountsactions.ts +75 -1
  95. package/ts/classes.controller.ts +4 -4
  96. package/ts/classes.flexaccountaccess.ts +2 -1
  97. package/ts/classes.harnessreleases.ts +25 -4
  98. package/ts/classes.inferencegateway.ts +184 -64
  99. package/ts/classes.inferenceplacement.ts +6 -4
  100. package/ts/classes.inferencerouter.ts +1026 -314
  101. package/ts/classes.inferenceservice.ts +56 -13
  102. package/ts/classes.inferencespend.ts +870 -0
  103. package/ts/classes.inferenceusagecache.ts +16 -4
  104. package/ts/classes.inferenceusagetap.ts +159 -34
  105. package/ts/classes.proxycontrol.ts +3 -3
  106. package/ts/classes.proxydaemon.ts +2 -1
  107. package/ts/classes.proxystore.ts +11 -1
  108. package/ts/fleet/classes.claudeworkers.ts +9 -8
  109. package/ts/functions.claudecoderelease.ts +13 -6
  110. package/ts/functions.inferencecli.ts +11 -7
  111. package/ts/interfaces.auth.ts +2 -0
  112. package/ts/plugins.ts +7 -0
  113. package/ts_authswitch/authority-apikeys.ts +85 -0
  114. package/ts_authswitch/authority-contract.ts +167 -29
  115. package/ts_authswitch/authority-overview.ts +7 -0
  116. package/ts_authswitch/authority-placement.ts +12 -4
  117. package/ts_authswitch/authority-runtime-contract.ts +2 -2
  118. package/ts_authswitch/authority-spend.ts +168 -0
  119. package/ts_authswitch/authority-subscription.ts +28 -1
  120. package/ts_authswitch/authority-usagelimit.ts +95 -40
  121. package/ts_authswitch/authority-usagewindows.ts +42 -17
  122. package/ts_authswitch/classes.authoritybroker.ts +161 -68
  123. package/ts_authswitch/classes.authoritycli.ts +108 -32
  124. package/ts_authswitch/classes.authorityclient.ts +41 -10
  125. package/ts_authswitch/classes.authoritydaemon.ts +43 -13
  126. package/ts_authswitch/classes.authoritydatabase.ts +178 -8
  127. package/ts_authswitch/classes.authorityimport.ts +1 -1
  128. package/ts_authswitch/classes.authoritymodels.ts +165 -19
  129. package/ts_authswitch/classes.authorityregistry.ts +3 -0
  130. package/ts_authswitch/classes.authorityusage.ts +171 -10
  131. package/ts_authswitch/classes.claudenative.ts +28 -15
  132. package/ts_authswitch/classes.claudesignin.ts +4 -1
  133. package/ts_authswitch/native-contracts.md +81 -20
  134. package/ts_authswitch/readme.md +1 -1
  135. package/ts_authswitch/testsupport.ts +58 -11
  136. package/ts_authswitch/ts_migration/0011_account_api_key_fields.ts +20 -0
  137. package/ts_authswitch/ts_migration/index.ts +2 -0
  138. package/ts_interfaces/functions.authorityscope.ts +43 -8
  139. package/ts_interfaces/interfaces.ts +1 -1
  140. package/ts_interfaces/plugins.ts +6 -3
  141. package/ts_interfaces/requests.accounts.ts +48 -0
  142. package/ts_interfaces/requests.ts +4 -0
  143. package/ts_web/00_commitinfo_data.ts +1 -1
  144. package/ts_web/accounts.plugins.ts +3 -1
  145. package/ts_web/accounts.templates.ts +20 -11
  146. package/ts_web/classes.accountsreadmodel.ts +322 -70
  147. package/ts_web/elements.accounts.ts +219 -20
  148. package/ts_web/elements.codexconnections.ts +3 -1
  149. 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, isAuthSwitchMaxUsagePercent } from './authority-contract.js';
9
- import { AuthSwitchAuthorityDatabase, authorityBindPurpose, type TStoredNativeHome } from './classes.authoritydatabase.js';
10
- import type { IStoredAuthorityAccount, IStoredAuthorityBinding, IStoredAuthorityGrant,
11
- IStoredAuthorityDeviceOperation } from './classes.authoritymodels.js';
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 Anthropic whether it accepts an API key before the authority stores it: by default a read of the models
28
- * the key may use. It fails with `AnthropicApiKeyAuthError` for a key Anthropic rejected, otherwise with whatever
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
- verifyAnthropicApiKey?: (apiKey: string, signal: AbortSignal) => Promise<void>;
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 Anthropic API key's account is named by a digest of the key, the one identity a key has: the same key always
91
- * lands on the same account, and nothing stored or published reveals the key.
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 anthropicApiKeySubject = (apiKey: string): string => idHash('authswitch-anthropic-api-key', apiKey);
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 anthropicApiKeyWorkspace = 'api-key';
96
- const anthropicApiKeyAudience = 'anthropic-api';
97
- /** How long Anthropic is given to answer whether it accepts a key being added. */
98
- const anthropicApiKeyVerifyTimeoutMs = 15_000;
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 Anthropic API key '
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
- const apiKeyRejected = 'Anthropic no longer accepts this account\'s API key. Add a valid key with '
123
- + '`authswitch account add anthropic-api-key`, or remove the account.';
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 verifyAnthropicApiKey: (apiKey: string, signal: AbortSignal) => Promise<void>;
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.verifyAnthropicApiKey = options.verifyAnthropicApiKey ?? (async (apiKey, signal) => {
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: 'openai_managed' | 'claude_host_native' | 'anthropic_api_key'): boolean {
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: 'openai_managed' | 'claude_host_native' | 'anthropic_api_key'; runtime: IAuthSwitchBinding['runtime'];
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
- || !['openai_managed', 'claude_host_native', 'anthropic_api_key'].includes(input.purpose)
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 Anthropic API key backs the gateway alone too: the proxy runs Flex's Claude models on it.
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 grant.purpose === 'anthropic_api_key'
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 Anthropic
1180
- * rejected for exactly this generation ends (`needs_reauth`, `provider_rejected`), and only adding a key it accepts
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 resolveAnthropicApiKeyAccess(readView: () => Promise<{
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
- || account.providerId !== 'anthropic' || grant.providerId !== 'anthropic' || grant.purpose !== 'anthropic_api_key'
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.openAnthropicApiKeyAccess(account, grant));
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
- /** The key a daemon-held Anthropic API key grant seals, proven to be the key its account is named by. */
1231
- private async openAnthropicApiKeyAccess(account: IStoredAuthorityAccount,
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' || grant.purpose !== 'anthropic_api_key'
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 !== 'anthropicApiKey' || typeof record.apiKey !== 'string' || !record.apiKey
1240
- || anthropicApiKeySubject(record.apiKey) !== account.subject) throw new Error();
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 Anthropic accepted it. The key is sealed like every login
1248
- * the authority holds and never answered on the management socket again. A key the authority holds already is
1249
- * refused while its account is ready; adding it after its account was removed, or after Anthropic rejected it,
1250
- * brings that account back with a new authorization, so a runtime bound to it binds again.
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 addAnthropicApiKey(apiKey: string, label?: string): Promise<{ account: IAuthSwitchAccount; loginId: string }> {
1253
- if (typeof apiKey !== 'string' || apiKey.length === 0 || apiKey.length > 512 || !/^[\x21-\x7e]+$/.test(apiKey)) {
1254
- throw new AuthSwitchRefusal('invalid_input', 'An Anthropic API key is one word of printable characters, as the '
1255
- + 'Claude Console shows it. Paste the key again.');
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.verifyAnthropicApiKey(apiKey, AbortSignal.timeout(anthropicApiKeyVerifyTimeoutMs));
1264
- } catch (error) {
1265
- throw new AuthSwitchRefusal('api_key_unverified', error instanceof plugins.flexAnthropic.AnthropicApiKeyAuthError
1266
- ? 'Anthropic rejected this API key. Check that it is an active key from the Claude Console, then add it again.'
1267
- : 'Anthropic could not be asked whether it accepts this API key. Try again shortly.');
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 subject = anthropicApiKeySubject(apiKey);
1270
- const accountId = idHash('authswitch-account-v1', 'anthropic', subject, anthropicApiKeyWorkspace);
1271
- const grantId = idHash('authswitch-grant-v1', accountId, 'anthropic_api_key', anthropicApiKeyAudience);
1272
- const bytes = Buffer.from(JSON.stringify({ kind: 'anthropicApiKey', apiKey }), 'utf8');
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, removed: false,
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: 'anthropic', issuer: 'anthropic', subject, workspaceId: anthropicApiKeyWorkspace,
1288
- label: label ?? 'Anthropic API key', email: null, plan: null, primaryGrantId: grantId, maxUsagePercent: null, reserveLiftUntil: null,
1289
- lapsesAt: null, removed: false, revision: 1, statusObservedAt: now, updateId };
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: 'anthropic', purpose: 'anthropic_api_key',
1296
- audience: anthropicApiKeyAudience, state: 'ready', owner: 'daemon', grantGeneration: 1,
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 };