agl 40.4.0 → 40.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/changelog.md +33 -0
  2. package/dist_serve/bundle.js +2228 -2177
  3. package/dist_serve/bundle.js.map +2 -2
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/aglapi/aglapisurface.document.js +2 -2
  6. package/dist_ts/classes.accountsactions.d.ts +11 -1
  7. package/dist_ts/classes.accountsactions.js +59 -2
  8. package/dist_ts/classes.controller.js +5 -5
  9. package/dist_ts/classes.flexaccountaccess.js +3 -2
  10. package/dist_ts/classes.harnessreleases.d.ts +14 -4
  11. package/dist_ts/classes.harnessreleases.js +28 -4
  12. package/dist_ts/classes.inferencegateway.d.ts +28 -3
  13. package/dist_ts/classes.inferencegateway.js +177 -61
  14. package/dist_ts/classes.inferenceplacement.d.ts +5 -3
  15. package/dist_ts/classes.inferenceplacement.js +2 -2
  16. package/dist_ts/classes.inferencerouter.d.ts +349 -91
  17. package/dist_ts/classes.inferencerouter.js +804 -297
  18. package/dist_ts/classes.inferenceservice.d.ts +14 -2
  19. package/dist_ts/classes.inferenceservice.js +53 -14
  20. package/dist_ts/classes.inferencespend.d.ts +339 -0
  21. package/dist_ts/classes.inferencespend.js +815 -0
  22. package/dist_ts/classes.inferenceusagecache.d.ts +8 -0
  23. package/dist_ts/classes.inferenceusagecache.js +9 -5
  24. package/dist_ts/classes.inferenceusagetap.d.ts +50 -9
  25. package/dist_ts/classes.inferenceusagetap.js +185 -31
  26. package/dist_ts/classes.proxycontrol.d.ts +3 -2
  27. package/dist_ts/classes.proxycontrol.js +1 -2
  28. package/dist_ts/classes.proxydaemon.js +3 -2
  29. package/dist_ts/classes.proxystore.d.ts +6 -0
  30. package/dist_ts/classes.proxystore.js +11 -2
  31. package/dist_ts/fleet/classes.claudeworkers.d.ts +5 -4
  32. package/dist_ts/fleet/classes.claudeworkers.js +10 -9
  33. package/dist_ts/functions.claudecoderelease.d.ts +12 -6
  34. package/dist_ts/functions.claudecoderelease.js +13 -7
  35. package/dist_ts/functions.inferencecli.js +12 -8
  36. package/dist_ts/interfaces.auth.d.ts +1 -1
  37. package/dist_ts/interfaces.auth.js +3 -1
  38. package/dist_ts/plugins.d.ts +3 -1
  39. package/dist_ts/plugins.js +7 -2
  40. package/dist_ts_authswitch/authority-apikeys.d.ts +32 -0
  41. package/dist_ts_authswitch/authority-apikeys.js +65 -0
  42. package/dist_ts_authswitch/authority-contract.d.ts +172 -33
  43. package/dist_ts_authswitch/authority-contract.js +54 -1
  44. package/dist_ts_authswitch/authority-overview.js +8 -1
  45. package/dist_ts_authswitch/authority-placement.d.ts +7 -2
  46. package/dist_ts_authswitch/authority-placement.js +10 -3
  47. package/dist_ts_authswitch/authority-runtime-contract.d.ts +2 -2
  48. package/dist_ts_authswitch/authority-spend.d.ts +103 -0
  49. package/dist_ts_authswitch/authority-spend.js +110 -0
  50. package/dist_ts_authswitch/authority-subscription.d.ts +12 -1
  51. package/dist_ts_authswitch/authority-subscription.js +24 -1
  52. package/dist_ts_authswitch/authority-usagelimit.d.ts +7 -4
  53. package/dist_ts_authswitch/authority-usagelimit.js +89 -39
  54. package/dist_ts_authswitch/authority-usagewindows.d.ts +4 -1
  55. package/dist_ts_authswitch/authority-usagewindows.js +41 -16
  56. package/dist_ts_authswitch/classes.authoritybroker.d.ts +30 -19
  57. package/dist_ts_authswitch/classes.authoritybroker.js +152 -61
  58. package/dist_ts_authswitch/classes.authoritycli.d.ts +9 -4
  59. package/dist_ts_authswitch/classes.authoritycli.js +99 -30
  60. package/dist_ts_authswitch/classes.authorityclient.d.ts +24 -7
  61. package/dist_ts_authswitch/classes.authorityclient.js +45 -15
  62. package/dist_ts_authswitch/classes.authoritydaemon.js +39 -12
  63. package/dist_ts_authswitch/classes.authoritydatabase.d.ts +28 -5
  64. package/dist_ts_authswitch/classes.authoritydatabase.js +171 -9
  65. package/dist_ts_authswitch/classes.authorityimport.js +2 -2
  66. package/dist_ts_authswitch/classes.authoritymodels.d.ts +71 -3
  67. package/dist_ts_authswitch/classes.authoritymodels.js +249 -20
  68. package/dist_ts_authswitch/classes.authorityregistry.js +4 -2
  69. package/dist_ts_authswitch/classes.authorityusage.d.ts +69 -7
  70. package/dist_ts_authswitch/classes.authorityusage.js +118 -4
  71. package/dist_ts_authswitch/classes.claudenative.d.ts +23 -8
  72. package/dist_ts_authswitch/classes.claudenative.js +49 -21
  73. package/dist_ts_authswitch/classes.claudesignin.d.ts +14 -2
  74. package/dist_ts_authswitch/classes.claudesignin.js +72 -23
  75. package/dist_ts_authswitch/classes.codexmanaged.d.ts +8 -0
  76. package/dist_ts_authswitch/classes.codexmanaged.js +33 -18
  77. package/dist_ts_authswitch/classes.harnessprocesses.d.ts +8 -0
  78. package/dist_ts_authswitch/classes.harnessprocesses.js +41 -9
  79. package/dist_ts_authswitch/testsupport.d.ts +24 -5
  80. package/dist_ts_authswitch/testsupport.js +56 -12
  81. package/dist_ts_authswitch/ts_migration/0011_account_api_key_fields.d.ts +12 -0
  82. package/dist_ts_authswitch/ts_migration/0011_account_api_key_fields.js +21 -0
  83. package/dist_ts_authswitch/ts_migration/index.js +3 -1
  84. package/dist_ts_interfaces/functions.authorityscope.d.ts +18 -7
  85. package/dist_ts_interfaces/functions.authorityscope.js +35 -8
  86. package/dist_ts_interfaces/interfaces.d.ts +1 -1
  87. package/dist_ts_interfaces/interfaces.js +1 -1
  88. package/dist_ts_interfaces/plugins.d.ts +2 -1
  89. package/dist_ts_interfaces/plugins.js +7 -4
  90. package/dist_ts_interfaces/requests.accounts.d.ts +56 -0
  91. package/dist_ts_interfaces/requests.accounts.js +11 -1
  92. package/dist_ts_interfaces/requests.d.ts +2 -2
  93. package/dist_ts_interfaces/requests.js +1 -1
  94. package/docs/authswitch.md +75 -12
  95. package/package.json +5 -5
  96. package/readme.md +199 -22
  97. package/ts/00_commitinfo_data.ts +1 -1
  98. package/ts/aglapi/aglapisurface.document.ts +2 -2
  99. package/ts/classes.accountsactions.ts +75 -1
  100. package/ts/classes.controller.ts +4 -4
  101. package/ts/classes.flexaccountaccess.ts +2 -1
  102. package/ts/classes.harnessreleases.ts +25 -4
  103. package/ts/classes.inferencegateway.ts +184 -64
  104. package/ts/classes.inferenceplacement.ts +6 -4
  105. package/ts/classes.inferencerouter.ts +1026 -314
  106. package/ts/classes.inferenceservice.ts +56 -13
  107. package/ts/classes.inferencespend.ts +870 -0
  108. package/ts/classes.inferenceusagecache.ts +16 -4
  109. package/ts/classes.inferenceusagetap.ts +159 -34
  110. package/ts/classes.proxycontrol.ts +3 -3
  111. package/ts/classes.proxydaemon.ts +2 -1
  112. package/ts/classes.proxystore.ts +11 -1
  113. package/ts/fleet/classes.claudeworkers.ts +9 -8
  114. package/ts/functions.claudecoderelease.ts +13 -6
  115. package/ts/functions.inferencecli.ts +11 -7
  116. package/ts/interfaces.auth.ts +2 -0
  117. package/ts/plugins.ts +7 -0
  118. package/ts_authswitch/authority-apikeys.ts +85 -0
  119. package/ts_authswitch/authority-contract.ts +201 -34
  120. package/ts_authswitch/authority-overview.ts +7 -0
  121. package/ts_authswitch/authority-placement.ts +12 -4
  122. package/ts_authswitch/authority-runtime-contract.ts +2 -2
  123. package/ts_authswitch/authority-spend.ts +168 -0
  124. package/ts_authswitch/authority-subscription.ts +28 -1
  125. package/ts_authswitch/authority-usagelimit.ts +95 -40
  126. package/ts_authswitch/authority-usagewindows.ts +42 -17
  127. package/ts_authswitch/classes.authoritybroker.ts +161 -68
  128. package/ts_authswitch/classes.authoritycli.ts +112 -32
  129. package/ts_authswitch/classes.authorityclient.ts +66 -19
  130. package/ts_authswitch/classes.authoritydaemon.ts +43 -13
  131. package/ts_authswitch/classes.authoritydatabase.ts +178 -8
  132. package/ts_authswitch/classes.authorityimport.ts +1 -1
  133. package/ts_authswitch/classes.authoritymodels.ts +165 -19
  134. package/ts_authswitch/classes.authorityregistry.ts +3 -0
  135. package/ts_authswitch/classes.authorityusage.ts +171 -10
  136. package/ts_authswitch/classes.claudenative.ts +51 -22
  137. package/ts_authswitch/classes.claudesignin.ts +74 -24
  138. package/ts_authswitch/classes.codexmanaged.ts +30 -16
  139. package/ts_authswitch/classes.harnessprocesses.ts +39 -8
  140. package/ts_authswitch/native-contracts.md +82 -21
  141. package/ts_authswitch/readme.md +1 -1
  142. package/ts_authswitch/testsupport.ts +58 -11
  143. package/ts_authswitch/ts_migration/0011_account_api_key_fields.ts +20 -0
  144. package/ts_authswitch/ts_migration/index.ts +2 -0
  145. package/ts_interfaces/functions.authorityscope.ts +43 -8
  146. package/ts_interfaces/interfaces.ts +1 -1
  147. package/ts_interfaces/plugins.ts +6 -3
  148. package/ts_interfaces/requests.accounts.ts +48 -0
  149. package/ts_interfaces/requests.ts +4 -0
  150. package/ts_web/00_commitinfo_data.ts +1 -1
  151. package/ts_web/accounts.plugins.ts +7 -1
  152. package/ts_web/accounts.templates.ts +20 -11
  153. package/ts_web/classes.accountsreadmodel.ts +326 -71
  154. package/ts_web/elements.accounts.ts +219 -20
  155. package/ts_web/elements.codexconnections.ts +3 -1
  156. package/ts_web/plugins.ts +4 -2
@@ -5,9 +5,10 @@ import * as plugins from './accounts.plugins.js';
5
5
  * The Accounts screen as data.
6
6
  *
7
7
  * The accounts service is the one place accounts and their logins live; every harness only uses
8
- * them. So the screen is one list of the service's accounts, each ChatGPT or Claude account one
9
- * row: who it is, one status in plain words, a chip for each harness that uses it, and its usage as
10
- * meters. Identifiers, logins and technical facts appear only in a row's details.
8
+ * them. So the screen is one list of the service's accounts, each ChatGPT or Claude account and each
9
+ * OpenAI or Anthropic API key one row: who it is, one status in plain words, a chip for each harness
10
+ * that uses it, and its usage as meters -- a key's usage is its month's spend in dollars.
11
+ * Identifiers, logins and technical facts appear only on its page.
11
12
  *
12
13
  * Everything the screen says is decided here. The browser holds no account state of its own, keeps
13
14
  * no uncertainty flag and runs no timer, so a failed read can never leave a view locked -- the next
@@ -114,8 +115,12 @@ export interface IAccountsServiceStatus {
114
115
 
115
116
  export type TAccountsListState = 'unavailable' | 'empty' | 'complete' | 'partial';
116
117
 
117
- /** The accounts of one provider -- ChatGPT or Claude -- under that provider's name. */
118
+ /**
119
+ * The accounts of one kind under its name: ChatGPT and Claude sign-ins, then OpenAI and Anthropic API keys. `id` is the
120
+ * provider for a sign-in group and `<provider>-api-key` for a key group.
121
+ */
118
122
  export interface IAccountGroup {
123
+ id: string;
119
124
  providerId: string;
120
125
  label: string;
121
126
  rows: IAccountRow[];
@@ -123,7 +128,7 @@ export interface IAccountGroup {
123
128
 
124
129
  export interface IAccountsList {
125
130
  state: TAccountsListState;
126
- /** The listed accounts, one group per provider, in a fixed provider order. */
131
+ /** The listed accounts, one group per kind, in a fixed order: sign-ins by provider, then API keys by provider. */
127
132
  groups: IAccountGroup[];
128
133
  /** Removed accounts, kept behind a disclosure: a removal is often why a project lost access. */
129
134
  removedRows: IAccountRow[];
@@ -139,6 +144,9 @@ export type TAccountActionId =
139
144
  | 'liftReserve'
140
145
  | 'maxUsage'
141
146
  | 'clearMaxUsage'
147
+ | 'spendLimit'
148
+ | 'clearSpendLimit'
149
+ | 'addApiKey'
142
150
  | 'remove'
143
151
  | 'usage'
144
152
  | 'recheck'
@@ -188,6 +196,18 @@ export interface IAccountMeter {
188
196
  resetText: string;
189
197
  }
190
198
 
199
+ /**
200
+ * An API key's month as its page says it: `$12.40 of $50.00 this month · AGL traffic only · as of 14:02`, or `Spent
201
+ * $12.40 this month · no limit · as of 14:02`, and how loud: attention from `spendAttentionPercent` of the limit,
202
+ * critical once the key is held at it.
203
+ */
204
+ export interface IAccountSpend {
205
+ text: string;
206
+ tone: TAccountsTone;
207
+ /** What stands in place of the meter of a key without a limit, which has nothing to meter; empty with one. */
208
+ unmeteredText: string;
209
+ }
210
+
191
211
  /** An account's usage as its row shows it. */
192
212
  export interface IAccountUsage {
193
213
  /**
@@ -200,6 +220,8 @@ export interface IAccountUsage {
200
220
  /** Why there is no current reading, when the service says. Empty otherwise. */
201
221
  problemText: string;
202
222
  meters: IAccountMeter[];
223
+ /** An API key's spend this month, under its meter; `null` for a sign-in. */
224
+ spend: IAccountSpend | null;
203
225
  }
204
226
 
205
227
  /** A device sign-in that has not finished, as the screen shows it. */
@@ -234,7 +256,9 @@ export interface IAccountStatus {
234
256
 
235
257
  /**
236
258
  * What a row's one status stands for, worst first: the service, a payment problem, a sign-in, a switch held on this
237
- * host, a hand-over, a renewal retrying, the usage limit, the reserve, a lifted reserve, no sign-in, and ready.
259
+ * host, a hand-over, a renewal retrying, the usage limit, the reserve, a lifted reserve, no sign-in, and ready. For an
260
+ * API key, a payment problem includes its provider's spend cap, a sign-in is a key its provider rejected, and the usage
261
+ * limit is its monthly spend limit (or an answer that could not be priced); a key has no reserve.
238
262
  */
239
263
  export type TAccountPillKind =
240
264
  | 'service'
@@ -254,13 +278,19 @@ export interface IAccountPill extends IAccountStatus {
254
278
  kind: TAccountPillKind;
255
279
  }
256
280
 
257
- /** The tightest usage window of an account, as its list row's one meter. */
281
+ /**
282
+ * The tightest usage window of an account, as its list row's one meter. An API key's is its month: what it spent, in
283
+ * percent of its limit, or with no limit its spend alone, with nothing to meter (`percent` null).
284
+ */
258
285
  export interface IAccountPrimaryMeter {
259
286
  label: string;
260
- percent: number;
261
- /** `96%`. */
287
+ percent: number | null;
288
+ /** `96%`; a key's spend, `$12.40`. */
262
289
  valueText: string;
263
- /** Full is neutral, at the account's maximum or past 90% wants attention, anything below is fine. */
290
+ /**
291
+ * Full is neutral; at the account's maximum or past 90% wants attention, a key's spend from `spendAttentionPercent` of
292
+ * its limit; anything below is fine.
293
+ */
264
294
  tone: TAccountsTone;
265
295
  /** The account's maximum usage, drawn as a tick; `null` without one. */
266
296
  threshold: number | null;
@@ -290,11 +320,11 @@ export interface IAccountLoginView {
290
320
  causeText: string;
291
321
  /** A new device sign-in for a login the service renews; absent for any other login. */
292
322
  reauth: IAccountActionSlot | null;
293
- /** Who renews the login: the accounts service, the tool that holds it, or nobody. */
323
+ /** Who renews the login: the accounts service, the tool that holds it, or nobody; empty for an API key, never renewed. */
294
324
  renewedBy: string;
295
325
  /**
296
326
  * When the accounts service renews this login next. It is labelled as a renewal: read as "access
297
- * expires" it sounds like the user's own sign-in is about to end, which it is not.
327
+ * expires" it sounds like the user's own sign-in is about to end, which it is not. Empty for an API key.
298
328
  */
299
329
  renewalText: string;
300
330
  /** When the service last looked at this login. */
@@ -317,8 +347,19 @@ export interface IAccountFact {
317
347
  export interface IAccountRow {
318
348
  key: string;
319
349
  providerId: string;
350
+ /** The group the row is listed in (`IAccountGroup.id`). */
351
+ groupId: string;
352
+ /** An API key's account: a spend limit instead of a maximum usage, and nothing to sign in again. */
353
+ apiKey: boolean;
354
+ /** An API key's monthly spend limit in micro-dollars, or `null` for none and for every sign-in. */
355
+ spendLimitMicroUsd: number | null;
356
+ /** What a removal does that the owner must know, said with the removal; empty for a sign-in. */
357
+ removeNote: string;
320
358
  label: string;
321
- /** Email and plan as one line, each named when the provider did not report it. */
359
+ /**
360
+ * Email and plan as one line, each named when the provider did not report it; for an API key its provider and how the
361
+ * key ends.
362
+ */
322
363
  identityText: string;
323
364
  /** The account's email as the provider reported it; empty when it reported none. */
324
365
  email: string;
@@ -347,8 +388,9 @@ export interface IAccountRow {
347
388
  */
348
389
  limitStatus: IAccountStatus | null;
349
390
  /**
350
- * A payment problem the account is held with -- Anthropic refused a request on it with a billing error (HTTP 402) --
351
- * as a badge, and what it means with when it was seen; `null` and `''` while none is held or its usage was not read.
391
+ * A payment problem the account is held with -- Anthropic refused a request on it with a billing error (HTTP 402), or
392
+ * a key's provider refused it for billing, its spend cap or a used-up credit -- as a badge, and what it means with when
393
+ * it was seen; `null` and `''` while none is held or its usage was not read.
352
394
  */
353
395
  paymentStatus: IAccountStatus | null;
354
396
  paymentDetail: string;
@@ -364,7 +406,10 @@ export interface IAccountRow {
364
406
  operations: IAccountOperationView[];
365
407
  /** Details: the service account's logins. */
366
408
  logins: IAccountLoginView[];
367
- /** Details: renaming, the usage threshold, removing, reading usage again and re-checking a payment problem. */
409
+ /**
410
+ * The page's own actions: renaming, the usage threshold (a key's spend limit), removing, reading usage again and
411
+ * re-checking a payment problem.
412
+ */
368
413
  manage: IAccountActionSlot[];
369
414
  /** Details: reading Flex's models of the account again. */
370
415
  flexActions: IAccountActionSlot[];
@@ -392,7 +437,7 @@ export interface IAccountsSummary {
392
437
  /** Listed accounts that take work now, of all listed. */
393
438
  ready: number;
394
439
  total: number;
395
- /** How the ready ones split by provider: `2 ChatGPT · 3 Claude`. */
440
+ /** How the ready ones split: by provider for sign-ins, the keys together: `2 ChatGPT · 3 Claude · 1 API key`. */
396
441
  readyText: string;
397
442
  /** Accounts their usage holds back: at a limit, or reserved at their maximum. */
398
443
  limited: number;
@@ -412,6 +457,8 @@ export interface IAccountsView {
412
457
  add: IAccountActionSlot;
413
458
  /** Adding a Claude account: Claude Code signs in, in a terminal, to a home the accounts service made for it. */
414
459
  addClaude: IAccountActionSlot;
460
+ /** Adding an OpenAI or Anthropic API key, which the accounts service checks with the provider first. */
461
+ addApiKey: IAccountActionSlot;
415
462
  /**
416
463
  * Adding the account Codex on this host is signed in to, offered until the accounts service manages Codex's
417
464
  * sign-in on this host; `null` from then on.
@@ -624,10 +671,21 @@ const purposeLabel = (loginArg: TLogin): string => {
624
671
  case 'claude_host_native': return 'Claude Code on this host';
625
672
  case 'claude_container_setup': return 'Claude Code in containers';
626
673
  case 'opencode_native': return 'OpenCode sign-in';
627
- case 'anthropic_api_key': return 'Anthropic API key for Flex';
674
+ case 'anthropic_api_key': return 'Anthropic API key';
675
+ case 'openai_api_key': return 'OpenAI API key';
628
676
  }
629
677
  };
630
678
 
679
+ /** Whether a login is an API key's: a key is never renewed and never signed in again, only replaced by another key. */
680
+ const isApiKeyLogin = (loginArg: TLogin): boolean => (
681
+ loginArg.purpose === 'anthropic_api_key' || loginArg.purpose === 'openai_api_key'
682
+ );
683
+
684
+ /** A key's provider by name, and where its keys are made and revoked. */
685
+ const keyProvider = (providerIdArg: string): { name: string; console: string } => providerIdArg === 'openai'
686
+ ? { name: 'OpenAI', console: 'the OpenAI platform' }
687
+ : { name: 'Anthropic', console: 'the Claude Console' };
688
+
631
689
  type TNativeTool = NonNullable<TLogin['ownerTool']>;
632
690
 
633
691
  /**
@@ -661,7 +719,8 @@ const loginCauseText = (loginArg: TLogin): string => {
661
719
  case 'none': return '';
662
720
  case 'provider_unavailable': return 'The provider could not be reached.';
663
721
  case 'exchange_uncertain': return 'A sign-in was interrupted and its outcome is unknown.';
664
- case 'provider_rejected': return 'The provider rejected the saved sign-in.';
722
+ case 'provider_rejected': return isApiKeyLogin(loginArg) ? 'The provider rejected the key.'
723
+ : 'The provider rejected the saved sign-in.';
665
724
  case 'native_owner': return 'Another tool on this host holds this login.';
666
725
  }
667
726
  };
@@ -685,6 +744,13 @@ const needsReauthCopy = (): ILoginStateCopy => ({
685
744
  statusText: 'This login needs a new sign-in.',
686
745
  });
687
746
 
747
+ /** A key its provider no longer accepts: a key is never renewed, so it needs as much as a sign-in, said as a key. */
748
+ const keyRejectedCopy = (): ILoginStateCopy => ({
749
+ state: 'needsReauth',
750
+ status: { text: 'Key rejected', tone: 'attention' },
751
+ statusText: 'The provider no longer accepts this key.',
752
+ });
753
+
688
754
  /** A login a native tool renews: ownership, not health, and the screen says so. */
689
755
  const nativeOwnedCopy = (toolArg: TNativeTool): ILoginStateCopy => ({
690
756
  state: 'nativeOwned',
@@ -735,7 +801,7 @@ const daemonOwnedCopy = (loginArg: TLogin): ILoginStateCopy => {
735
801
  */
736
802
  const loginStateCopy = (loginArg: TLogin): ILoginStateCopy => {
737
803
  if (loginArg.health === 'handoff_quarantined') return quarantinedCopy();
738
- if (loginArg.health === 'needs_reauth') return needsReauthCopy();
804
+ if (loginArg.health === 'needs_reauth') return isApiKeyLogin(loginArg) ? keyRejectedCopy() : needsReauthCopy();
739
805
  if (loginArg.ownerTool !== null) return nativeOwnedCopy(loginArg.ownerTool);
740
806
  return daemonOwnedCopy(loginArg);
741
807
  };
@@ -775,6 +841,10 @@ const preuseReason = (loginArg: TLogin): string => {
775
841
  */
776
842
  const signInWhere = (loginArg: TLogin): string => {
777
843
  if (loginArg.ownerTool !== null) return `Sign in again in ${nativeToolName(loginArg.ownerTool)}.`;
844
+ if (isApiKeyLogin(loginArg)) {
845
+ return `${keyProvider(loginArg.providerId).name} no longer accepts this key. Add a key it accepts with Add account,`
846
+ + ' then remove this one.';
847
+ }
778
848
  if (loginArg.capabilities.canReauthenticate || claudeSignsInAgain(loginArg)) return '';
779
849
  if (loginArg.providerId === 'anthropic') {
780
850
  return 'A hand-over of this sign-in could not be settled; This host shows what holds it.';
@@ -785,11 +855,11 @@ const signInWhere = (loginArg: TLogin): string => {
785
855
 
786
856
  /**
787
857
  * Whether a Claude sign-in of the account replaces this login: the service takes up a login it renews once that login
788
- * has ended, and a Claude sign-in of the same account lands on the same login.
858
+ * has ended, and a Claude sign-in of the same account lands on the same login. An Anthropic API key is no sign-in.
789
859
  */
790
860
  const claudeSignsInAgain = (loginArg: TLogin): boolean => (
791
- loginArg.providerId === 'anthropic' && (loginArg.owner === 'daemon' || loginArg.owner === 'none')
792
- && loginArg.health === 'needs_reauth'
861
+ loginArg.providerId === 'anthropic' && !isApiKeyLogin(loginArg)
862
+ && (loginArg.owner === 'daemon' || loginArg.owner === 'none') && loginArg.health === 'needs_reauth'
793
863
  );
794
864
 
795
865
  /** A new device sign-in for a login the service renews, or `null` where the service offers none. */
@@ -832,8 +902,8 @@ const loginView = (
832
902
  : copy.statusText,
833
903
  causeText: loginCauseText(loginArg),
834
904
  reauth: reauthSlot(loginArg, serviceArg),
835
- renewedBy: renewedByLabel(loginArg),
836
- renewalText: clockTime(loginArg.accessExpiresAt) || 'Not reported',
905
+ renewedBy: isApiKeyLogin(loginArg) ? '' : renewedByLabel(loginArg),
906
+ renewalText: isApiKeyLogin(loginArg) ? '' : clockTime(loginArg.accessExpiresAt) || 'Not reported',
837
907
  observedText: clockTime(loginArg.statusObservedAt) || 'Not reported',
838
908
  preuse: !loginArg.capabilities.canPreuse && loginArg.purpose !== 'openai_managed'
839
909
  ? null
@@ -850,8 +920,23 @@ const providerLabel = (providerIdArg: string): string => {
850
920
  }
851
921
  };
852
922
 
853
- /** The fixed order of the provider groups; any other provider follows under its own id. */
854
- const providerOrder = ['openai', 'anthropic'];
923
+ /** The fixed order of the groups: sign-ins, then API keys; any other provider follows under its own id. */
924
+ const groupOrder = ['openai', 'anthropic', 'openai-api-key', 'anthropic-api-key'];
925
+
926
+ /** A group's heading. */
927
+ const groupLabel = (groupIdArg: string): string => {
928
+ switch (groupIdArg) {
929
+ case 'openai-api-key': return 'OpenAI API keys';
930
+ case 'anthropic-api-key': return 'Anthropic API keys';
931
+ default: return providerLabel(groupIdArg);
932
+ }
933
+ };
934
+
935
+ /**
936
+ * Whether an account is an API key's: the accounts service names `api-key` as the tenancy of every key account, whose
937
+ * key names no organization (`IAuthSwitchAccount.organizationId`).
938
+ */
939
+ const isApiKeyAccount = (accountArg: TAccount): boolean => accountArg.organizationId === 'api-key';
855
940
 
856
941
  const harnessLabel = (harnessArg: TAccountHarnessId): string => {
857
942
  switch (harnessArg) {
@@ -1036,7 +1121,8 @@ const codexSlot = (
1036
1121
  projectionArg: interfaces.IControllerAuthorityProjection,
1037
1122
  serviceArg: IAccountsServiceStatus,
1038
1123
  ): IAccountActionSlot | null => {
1039
- if (accountArg.providerId !== 'openai' || accountArg.removed) return null;
1124
+ // An OpenAI API key is no ChatGPT sign-in: Codex on this host never switches to one.
1125
+ if (accountArg.providerId !== 'openai' || accountArg.removed || isApiKeyAccount(accountArg)) return null;
1040
1126
  const host = interfaces.findHostCodexAssignment(projectionArg);
1041
1127
  if (host?.accountId === accountArg.id) return null;
1042
1128
  const reason = interfaces.findAuthorityDaemonLogin(projectionArg, accountArg.id) === 'no_login'
@@ -1077,7 +1163,10 @@ const codexReleaseSlot = (
1077
1163
  const claudeSignInStatusText = (signInArg: interfaces.TControllerClaudeSignIn): string => (
1078
1164
  signInArg.state === 'committing'
1079
1165
  ? 'Adding the account…'
1080
- : 'Waiting for Claude Code to sign in. Run the command in a terminal and follow Claude Code there.'
1166
+ : signInArg.heldBy
1167
+ ? 'Claude Code signed in. The account is added once these Claude Code processes have ended: '
1168
+ + `${plugins.authswitchAuthority.authSwitchClaudeSignInHoldersText(signInArg.heldBy)}.`
1169
+ : 'Waiting for Claude Code to sign in. Run the command in a terminal and follow Claude Code there.'
1081
1170
  );
1082
1171
 
1083
1172
  /** One Claude sign-in in progress, with the command that completes it and a terminal to run it in. */
@@ -1148,10 +1237,13 @@ interface IPillParts {
1148
1237
  service: IAccountsServiceStatus;
1149
1238
  status: IAccountStatus;
1150
1239
  worst: TAccountLoginState | null;
1151
- payment: boolean;
1240
+ /** The payment problem's badge while one is held: `Payment problem`, or a key's `Spend cap reached`. */
1241
+ payment: IAccountStatus | null;
1152
1242
  /** A switch of a tool on this host that runs on the account could not be settled. */
1153
1243
  held: boolean;
1154
1244
  limit: plugins.authswitchUsageLimit.IAuthSwitchUsageLimit | null;
1245
+ /** What the pill calls the limit while the account is at it: `At limit`, a key's `At spend limit` or `Unpriced answer`. */
1246
+ limitText: string;
1155
1247
  /** Until when the reserve is lifted, while it is. */
1156
1248
  liftedUntil: string | null;
1157
1249
  }
@@ -1169,14 +1261,14 @@ const accountPill = (partsArg: IPillParts): IAccountPill => {
1169
1261
  : textArg;
1170
1262
  if (partsArg.removed) return pill('removed', 'Removed', 'neutral');
1171
1263
  if (partsArg.service.state !== 'current') return pill('service', partsArg.status.text, partsArg.status.tone);
1172
- if (partsArg.payment) return pill('payment', 'Payment problem', 'critical');
1264
+ if (partsArg.payment) return pill('payment', partsArg.payment.text, 'critical');
1173
1265
  if (partsArg.worst === 'quarantined' || partsArg.worst === 'needsReauth') {
1174
1266
  return pill('signIn', partsArg.status.text, partsArg.status.tone);
1175
1267
  }
1176
1268
  if (partsArg.held) return pill('held', 'Switch held', 'critical');
1177
1269
  if (partsArg.worst === 'pendingHandoff') return pill('handover', partsArg.status.text, partsArg.status.tone);
1178
1270
  if (partsArg.worst === 'retryWait') return pill('renewing', partsArg.status.text, partsArg.status.tone);
1179
- if (partsArg.limit?.state === 'exhausted') return pill('atLimit', back('At limit'), 'neutral');
1271
+ if (partsArg.limit?.state === 'exhausted') return pill('atLimit', back(partsArg.limitText), 'neutral');
1180
1272
  if (partsArg.limit?.state === 'reserved') return pill('reserved', back('Reserved'), 'neutral');
1181
1273
  if (partsArg.liftedUntil) {
1182
1274
  return pill('lifted', `Reserve lifted · until ${plugins.authswitchPreuseText.localDateTime(partsArg.liftedUntil)}`, 'info');
@@ -1202,10 +1294,41 @@ const primaryMeter = (usageArg: IAccountUsage | null, thresholdArg: number | nul
1202
1294
  };
1203
1295
  };
1204
1296
 
1205
- /** The list row's second line. */
1297
+ /**
1298
+ * The share of a key's spend limit from which its spend wants attention: a display tone only, judged on the last reading
1299
+ * the screen holds, never a limit the accounts service stores.
1300
+ */
1301
+ export const spendAttentionPercent = 80;
1302
+
1303
+ /**
1304
+ * A key's month as the list row's meter: what it spent, in percent of its limit, which is neutral once full like a used
1305
+ * window and wants attention from `spendAttentionPercent`; without a limit its spend alone.
1306
+ */
1307
+ const spendMeter = (spendArg: interfaces.TControllerAccountSpendReading): IAccountPrimaryMeter => {
1308
+ const spent = plugins.authswitchSpend.authSwitchUsdText(spendArg.spentMicroUsd);
1309
+ const percent = plugins.authswitchSpend.authSwitchSpendPercent(spendArg);
1310
+ if (percent === null) return { label: 'This month', percent: null, valueText: spent, tone: 'neutral', threshold: null };
1311
+ return {
1312
+ label: plugins.authswitchUsageWindows.authSwitchSpendWindowLabel,
1313
+ percent: clampPercent(percent),
1314
+ valueText: spent,
1315
+ tone: percent >= 100 ? 'neutral' : percent >= spendAttentionPercent ? 'attention' : 'positive',
1316
+ threshold: null,
1317
+ };
1318
+ };
1319
+
1320
+ /** An API key as it is named beside its name: its provider and how the key ends, when the service knows that. */
1321
+ const keyIdentityText = (accountArg: TAccount): string => {
1322
+ const provider = keyProvider(accountArg.providerId).name;
1323
+ return accountArg.keyHint === null ? `${provider} API key` : `${provider} API key ending ${accountArg.keyHint}`;
1324
+ };
1325
+
1326
+ /** The list row's second line: who the account is -- email and plan, or a key's identity -- and who uses it. */
1206
1327
  const listMeta = (accountArg: TAccount, chipsArg: readonly IAccountHarnessChip[]): string => [
1207
- accountArg.email && accountArg.email !== accountArg.label ? accountArg.email : '',
1208
- accountArg.plan ? `${accountArg.plan} plan` : '',
1328
+ ...isApiKeyAccount(accountArg) ? [keyIdentityText(accountArg)] : [
1329
+ accountArg.email && accountArg.email !== accountArg.label ? accountArg.email : '',
1330
+ accountArg.plan ? `${accountArg.plan} plan` : '',
1331
+ ],
1209
1332
  chipsArg.filter((chipArg) => chipArg.unavailableReason === undefined)
1210
1333
  .map((chipArg) => harnessLabel(chipArg.harnessId)).join(', '),
1211
1334
  ].filter(Boolean).join(' · ');
@@ -1246,21 +1369,96 @@ const usageLimit = (
1246
1369
  return limit.state === 'available' ? null : limit;
1247
1370
  };
1248
1371
 
1249
- const limitStatus = (limit: plugins.authswitchUsageLimit.IAuthSwitchUsageLimit | null): IAccountStatus | null => {
1372
+ /** Whether a key's month holds it for an answer that could not be priced rather than for what it spent. */
1373
+ const heldUnpriced = (spendArg: interfaces.TControllerAccountSpendReading): boolean => (
1374
+ spendArg.limitMicroUsd !== null && spendArg.spentMicroUsd < spendArg.limitMicroUsd
1375
+ );
1376
+
1377
+ /**
1378
+ * The limit as the page's badge; a key's (`spendArg`, its month) is its spend limit, or an answer that could not be
1379
+ * priced.
1380
+ */
1381
+ const limitStatus = (
1382
+ limit: plugins.authswitchUsageLimit.IAuthSwitchUsageLimit | null,
1383
+ spendArg: interfaces.TControllerAccountSpendReading | null,
1384
+ ): IAccountStatus | null => {
1250
1385
  if (!limit) return null;
1251
1386
  const reset = limit.resetAt ? `, resets ${plugins.authswitchPreuseText.localDateTime(limit.resetAt)}` : '';
1387
+ if (spendArg) {
1388
+ return { text: heldUnpriced(spendArg) ? `An answer could not be priced, so the key is held${reset}`
1389
+ : `At its spend limit${reset}`, tone: 'critical' };
1390
+ }
1252
1391
  return { text: `${sentence(plugins.authswitchUsageLimit.authSwitchUsageLimitText(limit))}${reset}`,
1253
1392
  tone: limit.state === 'reserved' ? 'attention' : 'critical' };
1254
1393
  };
1255
1394
 
1256
- /** The payment problem a usage reading holds the account with, as a badge and what it means; `null` without one. */
1257
- const paymentView = (usageArg: TUsage | null): { status: IAccountStatus; detail: string } | null => {
1395
+ /**
1396
+ * The payment problem a usage reading holds the account with, as a badge and what it means; `null` without one, and
1397
+ * once a key's spend cap reset (`authSwitchPaymentProblemHeld`). A key's says its cause.
1398
+ */
1399
+ const paymentView = (accountArg: TAccount, usageArg: TUsage | null, nowArg: number): {
1400
+ status: IAccountStatus; detail: string;
1401
+ } | null => {
1258
1402
  const problem = usageArg?.paymentProblem;
1259
- return problem ? { status: { text: 'Payment problem', tone: 'critical' },
1260
- detail: `${plugins.authswitchSubscription.authSwitchPaymentProblemText} Seen`
1261
- + ` ${plugins.authswitchPreuseText.localDateTime(problem.at)}.` } : null;
1403
+ if (!problem || !plugins.authswitchSubscription.authSwitchPaymentProblemHeld(problem, nowArg)) return null;
1404
+ const seen = `Seen ${plugins.authswitchPreuseText.localDateTime(problem.at)}.`;
1405
+ if (!isApiKeyAccount(accountArg)) {
1406
+ return { status: { text: 'Payment problem', tone: 'critical' },
1407
+ detail: `${plugins.authswitchSubscription.authSwitchPaymentProblemText} ${seen}` };
1408
+ }
1409
+ const resets = problem.until ? ` The cap resets ${plugins.authswitchPreuseText.localDateTime(problem.until)}.` : '';
1410
+ return { status: { text: problem.cause === 'spend_cap' ? 'Spend cap reached' : 'Payment problem', tone: 'critical' },
1411
+ detail: `${plugins.authswitchSubscription.authSwitchApiKeyPaymentProblemText(problem, accountArg.providerId)} ${seen}`
1412
+ + resets };
1413
+ };
1414
+
1415
+ /** A key's spend limit as its field shows it, in dollars and cents: `50.00`. */
1416
+ export const spendLimitDraft = (spendLimitMicroUsdArg: number): string => (
1417
+ (spendLimitMicroUsdArg / plugins.authswitchSpend.authSwitchMicroUsdPerUsd).toFixed(2)
1418
+ );
1419
+
1420
+ /**
1421
+ * Why a spend limit draft, in dollars, cannot be saved yet, or `''` when it can: an amount to the cent the service's own
1422
+ * rule allows, other than the limit the key has.
1423
+ */
1424
+ export const spendLimitReason = (draftArg: string, currentArg: number | null): string => {
1425
+ if (draftArg.trim().length === 0) return 'Enter an amount in dollars.';
1426
+ const value = plugins.authswitchSpend.authSwitchParseUsd(draftArg);
1427
+ if (value === null) return 'Enter dollars and cents, such as 50 or 12.50.';
1428
+ if (!interfaces.isControllerSpendLimitMicroUsd(value)) {
1429
+ return `Enter an amount from ${plugins.authswitchSpend.authSwitchUsdText(interfaces.controllerSpendLimitRange.min)} to`
1430
+ + ` ${plugins.authswitchSpend.authSwitchUsdText(interfaces.controllerSpendLimitRange.max)}.`;
1431
+ }
1432
+ return value === currentArg ? 'The spend limit is unchanged.' : '';
1433
+ };
1434
+
1435
+ /** A key's spend limit in words, as its page and the screen after an added key state it. */
1436
+ export const spendLimitText = (spendLimitMicroUsdArg: number | null): string => spendLimitMicroUsdArg === null
1437
+ ? 'No monthly limit'
1438
+ : `${plugins.authswitchSpend.authSwitchUsdText(spendLimitMicroUsdArg)} per calendar month (UTC)`;
1439
+
1440
+ /**
1441
+ * Why the Add API key dialog cannot add yet, or `''` when it can: a key typed, an optional name the service's label rule
1442
+ * accepts, and an optional spend limit in dollars its spend limit rule accepts.
1443
+ */
1444
+ export const apiKeyReason = (keyTypedArg: boolean, labelArg: string, limitArg: string): string => {
1445
+ if (!keyTypedArg) return 'Paste the key.';
1446
+ if (labelArg.trim().length > 0) {
1447
+ const name = renameReason(labelArg, '');
1448
+ if (name) return name;
1449
+ }
1450
+ return limitArg.trim().length > 0 ? spendLimitReason(limitArg, null) : '';
1262
1451
  };
1263
1452
 
1453
+ /** What the screen says once a key is added: the account, how its key ends, and the spend limit it actually has. */
1454
+ export const apiKeyAddedNotice = (accountArg: TAccount): IAccountsNotice => ({
1455
+ tone: 'positive',
1456
+ // A key added again keeps the limit it had unless the add named one, so this is the account's limit, not the request's.
1457
+ text: `Added ${accountArg.label}${accountArg.keyHint ? `, the key ending ${accountArg.keyHint}` : ''}. `
1458
+ + (accountArg.spendLimitMicroUsd === null ? 'It has no monthly spend limit.'
1459
+ : `Its monthly spend limit is ${spendLimitText(accountArg.spendLimitMicroUsd)}.`),
1460
+ });
1461
+
1264
1462
  /**
1265
1463
  * Why a usage threshold draft cannot be saved yet, or `''` when it can: a whole percent the service's own rule
1266
1464
  * allows, other than the threshold the account has.
@@ -1315,11 +1513,17 @@ const serviceRow = (
1315
1513
  ));
1316
1514
  const usageLogin = accountArg.removed ? undefined : logins.find((loginArg) => loginArg.health !== 'removed');
1317
1515
  const usageRead = contextArg.usage.get(accountArg.id);
1318
- const usage = usageRead?.state === 'read' ? usageView(usageRead.usage, usageLogin?.owner ?? null) : null;
1516
+ const usage = usageRead?.state === 'read' ? usageView(usageRead.usage, usageLogin?.owner ?? null, contextArg.now) : null;
1319
1517
  const rawLimit = accountArg.removed || usageRead?.state !== 'read' ? null
1320
1518
  : usageLimit(accountArg, usageRead.usage, contextArg.now);
1321
- const limit = limitStatus(rawLimit);
1322
- const payment = accountArg.removed || usageRead?.state !== 'read' ? null : paymentView(usageRead.usage);
1519
+ // A key's month, while the read holds one: its limit, its meter and how the pill names the limit are its spend's.
1520
+ const spend = usageRead?.state === 'read' && usageRead.usage.reading?.kind === 'spend' ? usageRead.usage.reading.spend : null;
1521
+ const limit = limitStatus(rawLimit, spend);
1522
+ const payment = accountArg.removed || usageRead?.state !== 'read' ? null
1523
+ : paymentView(accountArg, usageRead.usage, contextArg.now);
1524
+ const apiKey = isApiKeyAccount(accountArg);
1525
+ const provider = keyProvider(accountArg.providerId);
1526
+ const identity = apiKey ? keyIdentityText(accountArg) : identityText(accountArg.email, accountArg.plan);
1323
1527
  const liftedUntil = !accountArg.removed && accountArg.maxUsagePercent !== null
1324
1528
  && plugins.authswitchUsageLimit.authSwitchReserveLiftActive(accountArg.reserveLiftUntil, contextArg.now)
1325
1529
  ? accountArg.reserveLiftUntil! : null;
@@ -1329,8 +1533,14 @@ const serviceRow = (
1329
1533
  return {
1330
1534
  key: `service:${accountArg.id}`,
1331
1535
  providerId: accountArg.providerId,
1536
+ groupId: apiKey ? `${accountArg.providerId}-api-key` : accountArg.providerId,
1537
+ apiKey,
1538
+ spendLimitMicroUsd: apiKey ? accountArg.spendLimitMicroUsd : null,
1539
+ removeNote: apiKey
1540
+ ? `AGL stops using the key. It stays valid at ${provider.name} until you revoke it in ${provider.console}.`
1541
+ : '',
1332
1542
  label: accountArg.label,
1333
- identityText: identityText(accountArg.email, accountArg.plan),
1543
+ identityText: identity,
1334
1544
  email: accountArg.email ?? '',
1335
1545
  removed: accountArg.removed,
1336
1546
  status,
@@ -1340,13 +1550,14 @@ const serviceRow = (
1340
1550
  service: contextArg.service,
1341
1551
  status,
1342
1552
  worst: worstLoginState(liveLogins),
1343
- payment: payment !== null,
1553
+ payment: payment?.status ?? null,
1344
1554
  held: [...uses.values()].some((useArg) => useArg.host === 'quarantined'),
1345
1555
  limit: rawLimit,
1556
+ limitText: !spend ? 'At limit' : heldUnpriced(spend) ? 'Unpriced answer' : 'At spend limit',
1346
1557
  liftedUntil,
1347
1558
  }),
1348
- listMeta: accountArg.removed ? identityText(accountArg.email, accountArg.plan) : listMeta(accountArg, harnesses),
1349
- primaryMeter: primaryMeter(usage, accountArg.maxUsagePercent),
1559
+ listMeta: accountArg.removed ? identity : listMeta(accountArg, harnesses),
1560
+ primaryMeter: spend ? spendMeter(spend) : primaryMeter(usage, accountArg.maxUsagePercent),
1350
1561
  backAt: rawLimit?.resetAt ?? null,
1351
1562
  harnesses,
1352
1563
  usageLoginId: usageLogin?.id ?? null,
@@ -1373,8 +1584,10 @@ const serviceRow = (
1373
1584
  logins: logins.map((loginArg) => loginView(loginArg, contextArg.service)),
1374
1585
  manage: [
1375
1586
  serviceSlot('rename', 'Rename', 'lucide:PenLine', contextArg.service, removed),
1376
- serviceSlot('maxUsage', 'Max usage', 'lucide:SlidersHorizontal', contextArg.service, removed),
1377
- ...(accountArg.maxUsagePercent !== null && accountArg.maxUsagePercent < 100
1587
+ // A key's limit is its spend; a usage threshold and its reserve are a sign-in's.
1588
+ ...(apiKey ? [serviceSlot('spendLimit', 'Spend limit', 'lucide:Wallet', contextArg.service, removed)]
1589
+ : [serviceSlot('maxUsage', 'Max usage', 'lucide:SlidersHorizontal', contextArg.service, removed)]),
1590
+ ...(!apiKey && accountArg.maxUsagePercent !== null && accountArg.maxUsagePercent < 100
1378
1591
  ? [serviceSlot('liftReserve', 'Lift reserve', 'lucide:Clock', contextArg.service, removed)] : []),
1379
1592
  serviceSlot('remove', 'Remove', 'lucide:Trash2', contextArg.service, removed),
1380
1593
  ...(usageLogin ? [serviceSlot('usage', 'Show latest usage', 'lucide:Gauge', contextArg.service)] : []),
@@ -1386,11 +1599,12 @@ const serviceRow = (
1386
1599
  : [],
1387
1600
  flexStatusText: flexConnectionId ? contextArg.flex.refreshStatuses.get(flexConnectionId) ?? '' : '',
1388
1601
  facts: [
1389
- { label: 'Provider', value: providerLabel(accountArg.providerId) },
1602
+ { label: 'Provider', value: apiKey ? `${provider.name} (API key)` : providerLabel(accountArg.providerId) },
1390
1603
  ...(usageLogin ? [{ label: 'Usage', value: usageFact(usageRead, usage) }] : []),
1391
1604
  ...(usageRead?.state === 'read'
1392
1605
  ? plugins.authswitchSubscription.authSwitchSubscriptionFacts(usageRead.usage.reading) : []),
1393
- { label: 'Max usage', value: maxUsageFact(accountArg.maxUsagePercent) },
1606
+ apiKey ? { label: 'Monthly spend limit', value: spendLimitText(accountArg.spendLimitMicroUsd) }
1607
+ : { label: 'Max usage', value: maxUsageFact(accountArg.maxUsagePercent) },
1394
1608
  { label: 'Account reference', value: accountArg.id },
1395
1609
  ],
1396
1610
  accountId: accountArg.id,
@@ -1402,23 +1616,22 @@ const serviceRow = (
1402
1616
  };
1403
1617
  };
1404
1618
 
1405
- /** The listed rows, one group per provider, ChatGPT first. */
1619
+ /** The listed rows, one group per kind: ChatGPT, Claude, OpenAI API keys, Anthropic API keys. */
1406
1620
  const accountGroups = (rowsArg: readonly IAccountRow[]): IAccountGroup[] => {
1407
- const providers = [...new Set(rowsArg.map((rowArg) => rowArg.providerId))].sort((leftArg, rightArg) => {
1408
- const left = providerOrder.indexOf(leftArg);
1409
- const right = providerOrder.indexOf(rightArg);
1410
- if (left !== right) return (left < 0 ? providerOrder.length : left) - (right < 0 ? providerOrder.length : right);
1621
+ const groups = [...new Set(rowsArg.map((rowArg) => rowArg.groupId))].sort((leftArg, rightArg) => {
1622
+ const left = groupOrder.indexOf(leftArg);
1623
+ const right = groupOrder.indexOf(rightArg);
1624
+ if (left !== right) return (left < 0 ? groupOrder.length : left) - (right < 0 ? groupOrder.length : right);
1411
1625
  return leftArg.localeCompare(rightArg);
1412
1626
  });
1413
- return providers.map((providerIdArg) => ({
1414
- providerId: providerIdArg,
1415
- label: providerLabel(providerIdArg),
1416
- rows: rowsArg.filter((rowArg) => rowArg.providerId === providerIdArg),
1417
- }));
1627
+ return groups.map((groupIdArg) => {
1628
+ const rows = rowsArg.filter((rowArg) => rowArg.groupId === groupIdArg);
1629
+ return { id: groupIdArg, providerId: rows[0]!.providerId, label: groupLabel(groupIdArg), rows };
1630
+ });
1418
1631
  };
1419
1632
 
1420
1633
  /**
1421
- * The account list: the service's accounts, under their provider.
1634
+ * The account list: the service's accounts, under their provider, the API keys apart from the sign-ins.
1422
1635
  *
1423
1636
  * The rows and the sentence under them come from the same read. The controller reads every page of
1424
1637
  * the service's snapshot, so a list that still does not end at its last row says so explicitly
@@ -1459,12 +1672,17 @@ const accountsSummary = (serviceArg: IAccountsServiceStatus, listArg: IAccountsL
1459
1672
  const needsYou = rows.filter((rowArg) => needsYouPills.includes(rowArg.pill.kind));
1460
1673
  const firstBack = limited.map((rowArg) => rowArg.backAt).filter((atArg): atArg is string => atArg !== null)
1461
1674
  .sort((leftArg, rightArg) => Date.parse(leftArg) - Date.parse(rightArg))[0];
1675
+ const keys = rows.filter((rowArg) => rowArg.apiKey);
1676
+ const readyKeys = keys.filter((rowArg) => readyPills.includes(rowArg.pill.kind)).length;
1462
1677
  return {
1463
1678
  ready: ready.length,
1464
1679
  total: rows.length,
1465
- readyText: listArg.groups.map((groupArg) => (
1466
- `${groupArg.rows.filter((rowArg) => readyPills.includes(rowArg.pill.kind)).length} ${groupArg.label}`
1467
- )).join(' · '),
1680
+ readyText: [
1681
+ ...listArg.groups.filter((groupArg) => !groupArg.rows[0]!.apiKey).map((groupArg) => (
1682
+ `${groupArg.rows.filter((rowArg) => readyPills.includes(rowArg.pill.kind)).length} ${groupArg.label}`
1683
+ )),
1684
+ ...keys.length ? [`${readyKeys} API ${readyKeys === 1 ? 'key' : 'keys'}`] : [],
1685
+ ].join(' · '),
1468
1686
  limited: limited.length,
1469
1687
  limitedText: firstBack ? `First back ${plugins.authswitchPreuseText.localDateTime(firstBack)}` : '',
1470
1688
  needsYou: needsYou.length,
@@ -1472,10 +1690,13 @@ const accountsSummary = (serviceArg: IAccountsServiceStatus, listArg: IAccountsL
1472
1690
  };
1473
1691
  };
1474
1692
 
1475
- /** The accounts whose usage a screen reads: each listed account's usage login, by account id. */
1476
- export const usageTargets = (viewArg: IAccountsView): { accountId: string; loginId: string }[] => (
1693
+ /**
1694
+ * The accounts whose usage a screen reads: each listed account's usage login, by account id. `apiKey` marks a key, whose
1695
+ * spend changes with every answer AGL relays on it, so the screen reads it again on every change it is told of.
1696
+ */
1697
+ export const usageTargets = (viewArg: IAccountsView): { accountId: string; loginId: string; apiKey: boolean }[] => (
1477
1698
  viewArg.list.groups.flatMap((groupArg) => groupArg.rows).flatMap((rowArg) => (
1478
- rowArg.usageLoginId ? [{ accountId: rowArg.accountId, loginId: rowArg.usageLoginId }] : []
1699
+ rowArg.usageLoginId ? [{ accountId: rowArg.accountId, loginId: rowArg.usageLoginId, apiKey: rowArg.apiKey }] : []
1479
1700
  ))
1480
1701
  );
1481
1702
 
@@ -1512,6 +1733,7 @@ export const accountsView = (
1512
1733
  'addClaude', 'Add Claude account', 'lucide:UserPlus', service,
1513
1734
  claudeSignIns.length ? 'A Claude sign-in is already waiting below.' : '',
1514
1735
  ),
1736
+ addApiKey: serviceSlot('addApiKey', 'Add API key', 'lucide:KeyRound', service),
1515
1737
  adoptCodex: projection && interfaces.findHostCodexAssignment(projection)
1516
1738
  ? null
1517
1739
  : serviceSlot('adoptCodex', 'Add Codex\'s current sign-in', 'lucide:LogIn', service),
@@ -1752,13 +1974,14 @@ const asOfTime = (isoArg: string | null): string => {
1752
1974
  * `ownerArg` is who renews the login it was read for, which names why the usage of Claude Code's own sign-in is
1753
1975
  * missing.
1754
1976
  */
1755
- export const usageView = (usageArg: TUsage, ownerArg: TLogin['owner'] | null = null): IAccountUsage => {
1977
+ export const usageView = (usageArg: TUsage, ownerArg: TLogin['owner'] | null = null,
1978
+ nowArg: number = Date.now()): IAccountUsage => {
1756
1979
  const observed = asOfTime(usageArg.observedAt);
1757
1980
  const reading = usageArg.reading;
1758
1981
  const current = usageArg.state === 'current';
1759
1982
  if (ownerArg === 'claude_native' && usageArg.problem === 'access_unavailable') {
1760
1983
  return { freshness: usageArg.state === 'stale' && observed ? `Last known, as of ${observed}` : 'Unavailable',
1761
- current, problemText: claudeHomeUsageText, meters: usageMeters(reading) };
1984
+ current, problemText: claudeHomeUsageText, meters: usageMeters(reading), spend: null };
1762
1985
  }
1763
1986
  const next = asOfTime(usageArg.nextAttemptAt);
1764
1987
  return {
@@ -1770,6 +1993,38 @@ export const usageView = (usageArg: TUsage, ownerArg: TLogin['owner'] | null = n
1770
1993
  ? `The provider limited usage reads, so the last one was refused${next ? `; the next read is at ${next}` : ''}.`
1771
1994
  : plugins.authswitchUsageWindows.authSwitchUsageProblemText(usageArg.problem),
1772
1995
  meters: usageMeters(reading),
1996
+ spend: reading?.kind === 'spend' ? spendView(reading.spend, nowArg, observed) : null,
1997
+ };
1998
+ };
1999
+
2000
+ /**
2001
+ * An API key's month in words: what AGL's traffic on it cost against its limit, or without one, how many answers could
2002
+ * not be priced, and when the accounts service read the key's ledger for it (`asOfArg`, the reading's `observedAt`): the
2003
+ * screen reads the month again on Refresh, Show latest usage and each change the controller announces, never on a
2004
+ * timer, so the figure says its time. Attention from `spendAttentionPercent` of the limit, critical once the key is
2005
+ * held, as of that reading. A reading whose month has ended says nothing of this one (`authSwitchSpendMonthEnded`).
2006
+ */
2007
+ const spendView = (spendArg: interfaces.TControllerAccountSpendReading, nowArg: number, asOfArg: string): IAccountSpend => {
2008
+ const usd = plugins.authswitchSpend.authSwitchUsdText;
2009
+ const unpriced = spendArg.unpricedAnswers === 0 ? ''
2010
+ : ` · ${spendArg.unpricedAnswers} ${spendArg.unpricedAnswers === 1 ? 'answer' : 'answers'} could not be priced`;
2011
+ const asOf = asOfArg ? ` · as of ${asOfArg}` : '';
2012
+ const unmeteredText = spendArg.limitMicroUsd === null
2013
+ ? 'No monthly spend limit, so there is nothing to meter: AGL counts what the key costs.' : '';
2014
+ if (plugins.authswitchSpend.authSwitchSpendMonthEnded(spendArg, nowArg)) {
2015
+ return { text: `${usd(spendArg.spentMicroUsd)} in a month that has ended · Refresh reads this one${asOf}`,
2016
+ tone: 'neutral', unmeteredText };
2017
+ }
2018
+ if (spendArg.limitMicroUsd === null) {
2019
+ return { text: `Spent ${usd(spendArg.spentMicroUsd)} this month · no limit${unpriced}${asOf}`, tone: 'neutral',
2020
+ unmeteredText };
2021
+ }
2022
+ const percent = plugins.authswitchSpend.authSwitchSpendPercent(spendArg) ?? 0;
2023
+ return {
2024
+ text: `${usd(spendArg.spentMicroUsd)} of ${usd(spendArg.limitMicroUsd)} this month · AGL traffic only${unpriced}${asOf}`,
2025
+ tone: plugins.authswitchSpend.authSwitchSpendExhausted(spendArg, nowArg) ? 'critical'
2026
+ : percent >= spendAttentionPercent ? 'attention' : 'neutral',
2027
+ unmeteredText,
1773
2028
  };
1774
2029
  };
1775
2030