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,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'
@@ -1148,10 +1234,13 @@ interface IPillParts {
1148
1234
  service: IAccountsServiceStatus;
1149
1235
  status: IAccountStatus;
1150
1236
  worst: TAccountLoginState | null;
1151
- payment: boolean;
1237
+ /** The payment problem's badge while one is held: `Payment problem`, or a key's `Spend cap reached`. */
1238
+ payment: IAccountStatus | null;
1152
1239
  /** A switch of a tool on this host that runs on the account could not be settled. */
1153
1240
  held: boolean;
1154
1241
  limit: plugins.authswitchUsageLimit.IAuthSwitchUsageLimit | null;
1242
+ /** What the pill calls the limit while the account is at it: `At limit`, a key's `At spend limit` or `Unpriced answer`. */
1243
+ limitText: string;
1155
1244
  /** Until when the reserve is lifted, while it is. */
1156
1245
  liftedUntil: string | null;
1157
1246
  }
@@ -1169,14 +1258,14 @@ const accountPill = (partsArg: IPillParts): IAccountPill => {
1169
1258
  : textArg;
1170
1259
  if (partsArg.removed) return pill('removed', 'Removed', 'neutral');
1171
1260
  if (partsArg.service.state !== 'current') return pill('service', partsArg.status.text, partsArg.status.tone);
1172
- if (partsArg.payment) return pill('payment', 'Payment problem', 'critical');
1261
+ if (partsArg.payment) return pill('payment', partsArg.payment.text, 'critical');
1173
1262
  if (partsArg.worst === 'quarantined' || partsArg.worst === 'needsReauth') {
1174
1263
  return pill('signIn', partsArg.status.text, partsArg.status.tone);
1175
1264
  }
1176
1265
  if (partsArg.held) return pill('held', 'Switch held', 'critical');
1177
1266
  if (partsArg.worst === 'pendingHandoff') return pill('handover', partsArg.status.text, partsArg.status.tone);
1178
1267
  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');
1268
+ if (partsArg.limit?.state === 'exhausted') return pill('atLimit', back(partsArg.limitText), 'neutral');
1180
1269
  if (partsArg.limit?.state === 'reserved') return pill('reserved', back('Reserved'), 'neutral');
1181
1270
  if (partsArg.liftedUntil) {
1182
1271
  return pill('lifted', `Reserve lifted · until ${plugins.authswitchPreuseText.localDateTime(partsArg.liftedUntil)}`, 'info');
@@ -1202,10 +1291,41 @@ const primaryMeter = (usageArg: IAccountUsage | null, thresholdArg: number | nul
1202
1291
  };
1203
1292
  };
1204
1293
 
1205
- /** The list row's second line. */
1294
+ /**
1295
+ * The share of a key's spend limit from which its spend wants attention: a display tone only, judged on the last reading
1296
+ * the screen holds, never a limit the accounts service stores.
1297
+ */
1298
+ export const spendAttentionPercent = 80;
1299
+
1300
+ /**
1301
+ * 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
1302
+ * window and wants attention from `spendAttentionPercent`; without a limit its spend alone.
1303
+ */
1304
+ const spendMeter = (spendArg: interfaces.TControllerAccountSpendReading): IAccountPrimaryMeter => {
1305
+ const spent = plugins.authswitchSpend.authSwitchUsdText(spendArg.spentMicroUsd);
1306
+ const percent = plugins.authswitchSpend.authSwitchSpendPercent(spendArg);
1307
+ if (percent === null) return { label: 'This month', percent: null, valueText: spent, tone: 'neutral', threshold: null };
1308
+ return {
1309
+ label: plugins.authswitchUsageWindows.authSwitchSpendWindowLabel,
1310
+ percent: clampPercent(percent),
1311
+ valueText: spent,
1312
+ tone: percent >= 100 ? 'neutral' : percent >= spendAttentionPercent ? 'attention' : 'positive',
1313
+ threshold: null,
1314
+ };
1315
+ };
1316
+
1317
+ /** An API key as it is named beside its name: its provider and how the key ends, when the service knows that. */
1318
+ const keyIdentityText = (accountArg: TAccount): string => {
1319
+ const provider = keyProvider(accountArg.providerId).name;
1320
+ return accountArg.keyHint === null ? `${provider} API key` : `${provider} API key ending ${accountArg.keyHint}`;
1321
+ };
1322
+
1323
+ /** The list row's second line: who the account is -- email and plan, or a key's identity -- and who uses it. */
1206
1324
  const listMeta = (accountArg: TAccount, chipsArg: readonly IAccountHarnessChip[]): string => [
1207
- accountArg.email && accountArg.email !== accountArg.label ? accountArg.email : '',
1208
- accountArg.plan ? `${accountArg.plan} plan` : '',
1325
+ ...isApiKeyAccount(accountArg) ? [keyIdentityText(accountArg)] : [
1326
+ accountArg.email && accountArg.email !== accountArg.label ? accountArg.email : '',
1327
+ accountArg.plan ? `${accountArg.plan} plan` : '',
1328
+ ],
1209
1329
  chipsArg.filter((chipArg) => chipArg.unavailableReason === undefined)
1210
1330
  .map((chipArg) => harnessLabel(chipArg.harnessId)).join(', '),
1211
1331
  ].filter(Boolean).join(' · ');
@@ -1246,21 +1366,96 @@ const usageLimit = (
1246
1366
  return limit.state === 'available' ? null : limit;
1247
1367
  };
1248
1368
 
1249
- const limitStatus = (limit: plugins.authswitchUsageLimit.IAuthSwitchUsageLimit | null): IAccountStatus | null => {
1369
+ /** Whether a key's month holds it for an answer that could not be priced rather than for what it spent. */
1370
+ const heldUnpriced = (spendArg: interfaces.TControllerAccountSpendReading): boolean => (
1371
+ spendArg.limitMicroUsd !== null && spendArg.spentMicroUsd < spendArg.limitMicroUsd
1372
+ );
1373
+
1374
+ /**
1375
+ * The limit as the page's badge; a key's (`spendArg`, its month) is its spend limit, or an answer that could not be
1376
+ * priced.
1377
+ */
1378
+ const limitStatus = (
1379
+ limit: plugins.authswitchUsageLimit.IAuthSwitchUsageLimit | null,
1380
+ spendArg: interfaces.TControllerAccountSpendReading | null,
1381
+ ): IAccountStatus | null => {
1250
1382
  if (!limit) return null;
1251
1383
  const reset = limit.resetAt ? `, resets ${plugins.authswitchPreuseText.localDateTime(limit.resetAt)}` : '';
1384
+ if (spendArg) {
1385
+ return { text: heldUnpriced(spendArg) ? `An answer could not be priced, so the key is held${reset}`
1386
+ : `At its spend limit${reset}`, tone: 'critical' };
1387
+ }
1252
1388
  return { text: `${sentence(plugins.authswitchUsageLimit.authSwitchUsageLimitText(limit))}${reset}`,
1253
1389
  tone: limit.state === 'reserved' ? 'attention' : 'critical' };
1254
1390
  };
1255
1391
 
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 => {
1392
+ /**
1393
+ * The payment problem a usage reading holds the account with, as a badge and what it means; `null` without one, and
1394
+ * once a key's spend cap reset (`authSwitchPaymentProblemHeld`). A key's says its cause.
1395
+ */
1396
+ const paymentView = (accountArg: TAccount, usageArg: TUsage | null, nowArg: number): {
1397
+ status: IAccountStatus; detail: string;
1398
+ } | null => {
1258
1399
  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;
1400
+ if (!problem || !plugins.authswitchSubscription.authSwitchPaymentProblemHeld(problem, nowArg)) return null;
1401
+ const seen = `Seen ${plugins.authswitchPreuseText.localDateTime(problem.at)}.`;
1402
+ if (!isApiKeyAccount(accountArg)) {
1403
+ return { status: { text: 'Payment problem', tone: 'critical' },
1404
+ detail: `${plugins.authswitchSubscription.authSwitchPaymentProblemText} ${seen}` };
1405
+ }
1406
+ const resets = problem.until ? ` The cap resets ${plugins.authswitchPreuseText.localDateTime(problem.until)}.` : '';
1407
+ return { status: { text: problem.cause === 'spend_cap' ? 'Spend cap reached' : 'Payment problem', tone: 'critical' },
1408
+ detail: `${plugins.authswitchSubscription.authSwitchApiKeyPaymentProblemText(problem, accountArg.providerId)} ${seen}`
1409
+ + resets };
1410
+ };
1411
+
1412
+ /** A key's spend limit as its field shows it, in dollars and cents: `50.00`. */
1413
+ export const spendLimitDraft = (spendLimitMicroUsdArg: number): string => (
1414
+ (spendLimitMicroUsdArg / plugins.authswitchSpend.authSwitchMicroUsdPerUsd).toFixed(2)
1415
+ );
1416
+
1417
+ /**
1418
+ * Why a spend limit draft, in dollars, cannot be saved yet, or `''` when it can: an amount to the cent the service's own
1419
+ * rule allows, other than the limit the key has.
1420
+ */
1421
+ export const spendLimitReason = (draftArg: string, currentArg: number | null): string => {
1422
+ if (draftArg.trim().length === 0) return 'Enter an amount in dollars.';
1423
+ const value = plugins.authswitchSpend.authSwitchParseUsd(draftArg);
1424
+ if (value === null) return 'Enter dollars and cents, such as 50 or 12.50.';
1425
+ if (!interfaces.isControllerSpendLimitMicroUsd(value)) {
1426
+ return `Enter an amount from ${plugins.authswitchSpend.authSwitchUsdText(interfaces.controllerSpendLimitRange.min)} to`
1427
+ + ` ${plugins.authswitchSpend.authSwitchUsdText(interfaces.controllerSpendLimitRange.max)}.`;
1428
+ }
1429
+ return value === currentArg ? 'The spend limit is unchanged.' : '';
1430
+ };
1431
+
1432
+ /** A key's spend limit in words, as its page and the screen after an added key state it. */
1433
+ export const spendLimitText = (spendLimitMicroUsdArg: number | null): string => spendLimitMicroUsdArg === null
1434
+ ? 'No monthly limit'
1435
+ : `${plugins.authswitchSpend.authSwitchUsdText(spendLimitMicroUsdArg)} per calendar month (UTC)`;
1436
+
1437
+ /**
1438
+ * Why the Add API key dialog cannot add yet, or `''` when it can: a key typed, an optional name the service's label rule
1439
+ * accepts, and an optional spend limit in dollars its spend limit rule accepts.
1440
+ */
1441
+ export const apiKeyReason = (keyTypedArg: boolean, labelArg: string, limitArg: string): string => {
1442
+ if (!keyTypedArg) return 'Paste the key.';
1443
+ if (labelArg.trim().length > 0) {
1444
+ const name = renameReason(labelArg, '');
1445
+ if (name) return name;
1446
+ }
1447
+ return limitArg.trim().length > 0 ? spendLimitReason(limitArg, null) : '';
1262
1448
  };
1263
1449
 
1450
+ /** What the screen says once a key is added: the account, how its key ends, and the spend limit it actually has. */
1451
+ export const apiKeyAddedNotice = (accountArg: TAccount): IAccountsNotice => ({
1452
+ tone: 'positive',
1453
+ // 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.
1454
+ text: `Added ${accountArg.label}${accountArg.keyHint ? `, the key ending ${accountArg.keyHint}` : ''}. `
1455
+ + (accountArg.spendLimitMicroUsd === null ? 'It has no monthly spend limit.'
1456
+ : `Its monthly spend limit is ${spendLimitText(accountArg.spendLimitMicroUsd)}.`),
1457
+ });
1458
+
1264
1459
  /**
1265
1460
  * Why a usage threshold draft cannot be saved yet, or `''` when it can: a whole percent the service's own rule
1266
1461
  * allows, other than the threshold the account has.
@@ -1315,11 +1510,17 @@ const serviceRow = (
1315
1510
  ));
1316
1511
  const usageLogin = accountArg.removed ? undefined : logins.find((loginArg) => loginArg.health !== 'removed');
1317
1512
  const usageRead = contextArg.usage.get(accountArg.id);
1318
- const usage = usageRead?.state === 'read' ? usageView(usageRead.usage, usageLogin?.owner ?? null) : null;
1513
+ const usage = usageRead?.state === 'read' ? usageView(usageRead.usage, usageLogin?.owner ?? null, contextArg.now) : null;
1319
1514
  const rawLimit = accountArg.removed || usageRead?.state !== 'read' ? null
1320
1515
  : usageLimit(accountArg, usageRead.usage, contextArg.now);
1321
- const limit = limitStatus(rawLimit);
1322
- const payment = accountArg.removed || usageRead?.state !== 'read' ? null : paymentView(usageRead.usage);
1516
+ // A key's month, while the read holds one: its limit, its meter and how the pill names the limit are its spend's.
1517
+ const spend = usageRead?.state === 'read' && usageRead.usage.reading?.kind === 'spend' ? usageRead.usage.reading.spend : null;
1518
+ const limit = limitStatus(rawLimit, spend);
1519
+ const payment = accountArg.removed || usageRead?.state !== 'read' ? null
1520
+ : paymentView(accountArg, usageRead.usage, contextArg.now);
1521
+ const apiKey = isApiKeyAccount(accountArg);
1522
+ const provider = keyProvider(accountArg.providerId);
1523
+ const identity = apiKey ? keyIdentityText(accountArg) : identityText(accountArg.email, accountArg.plan);
1323
1524
  const liftedUntil = !accountArg.removed && accountArg.maxUsagePercent !== null
1324
1525
  && plugins.authswitchUsageLimit.authSwitchReserveLiftActive(accountArg.reserveLiftUntil, contextArg.now)
1325
1526
  ? accountArg.reserveLiftUntil! : null;
@@ -1329,8 +1530,14 @@ const serviceRow = (
1329
1530
  return {
1330
1531
  key: `service:${accountArg.id}`,
1331
1532
  providerId: accountArg.providerId,
1533
+ groupId: apiKey ? `${accountArg.providerId}-api-key` : accountArg.providerId,
1534
+ apiKey,
1535
+ spendLimitMicroUsd: apiKey ? accountArg.spendLimitMicroUsd : null,
1536
+ removeNote: apiKey
1537
+ ? `AGL stops using the key. It stays valid at ${provider.name} until you revoke it in ${provider.console}.`
1538
+ : '',
1332
1539
  label: accountArg.label,
1333
- identityText: identityText(accountArg.email, accountArg.plan),
1540
+ identityText: identity,
1334
1541
  email: accountArg.email ?? '',
1335
1542
  removed: accountArg.removed,
1336
1543
  status,
@@ -1340,13 +1547,14 @@ const serviceRow = (
1340
1547
  service: contextArg.service,
1341
1548
  status,
1342
1549
  worst: worstLoginState(liveLogins),
1343
- payment: payment !== null,
1550
+ payment: payment?.status ?? null,
1344
1551
  held: [...uses.values()].some((useArg) => useArg.host === 'quarantined'),
1345
1552
  limit: rawLimit,
1553
+ limitText: !spend ? 'At limit' : heldUnpriced(spend) ? 'Unpriced answer' : 'At spend limit',
1346
1554
  liftedUntil,
1347
1555
  }),
1348
- listMeta: accountArg.removed ? identityText(accountArg.email, accountArg.plan) : listMeta(accountArg, harnesses),
1349
- primaryMeter: primaryMeter(usage, accountArg.maxUsagePercent),
1556
+ listMeta: accountArg.removed ? identity : listMeta(accountArg, harnesses),
1557
+ primaryMeter: spend ? spendMeter(spend) : primaryMeter(usage, accountArg.maxUsagePercent),
1350
1558
  backAt: rawLimit?.resetAt ?? null,
1351
1559
  harnesses,
1352
1560
  usageLoginId: usageLogin?.id ?? null,
@@ -1373,8 +1581,10 @@ const serviceRow = (
1373
1581
  logins: logins.map((loginArg) => loginView(loginArg, contextArg.service)),
1374
1582
  manage: [
1375
1583
  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
1584
+ // A key's limit is its spend; a usage threshold and its reserve are a sign-in's.
1585
+ ...(apiKey ? [serviceSlot('spendLimit', 'Spend limit', 'lucide:Wallet', contextArg.service, removed)]
1586
+ : [serviceSlot('maxUsage', 'Max usage', 'lucide:SlidersHorizontal', contextArg.service, removed)]),
1587
+ ...(!apiKey && accountArg.maxUsagePercent !== null && accountArg.maxUsagePercent < 100
1378
1588
  ? [serviceSlot('liftReserve', 'Lift reserve', 'lucide:Clock', contextArg.service, removed)] : []),
1379
1589
  serviceSlot('remove', 'Remove', 'lucide:Trash2', contextArg.service, removed),
1380
1590
  ...(usageLogin ? [serviceSlot('usage', 'Show latest usage', 'lucide:Gauge', contextArg.service)] : []),
@@ -1386,11 +1596,12 @@ const serviceRow = (
1386
1596
  : [],
1387
1597
  flexStatusText: flexConnectionId ? contextArg.flex.refreshStatuses.get(flexConnectionId) ?? '' : '',
1388
1598
  facts: [
1389
- { label: 'Provider', value: providerLabel(accountArg.providerId) },
1599
+ { label: 'Provider', value: apiKey ? `${provider.name} (API key)` : providerLabel(accountArg.providerId) },
1390
1600
  ...(usageLogin ? [{ label: 'Usage', value: usageFact(usageRead, usage) }] : []),
1391
1601
  ...(usageRead?.state === 'read'
1392
1602
  ? plugins.authswitchSubscription.authSwitchSubscriptionFacts(usageRead.usage.reading) : []),
1393
- { label: 'Max usage', value: maxUsageFact(accountArg.maxUsagePercent) },
1603
+ apiKey ? { label: 'Monthly spend limit', value: spendLimitText(accountArg.spendLimitMicroUsd) }
1604
+ : { label: 'Max usage', value: maxUsageFact(accountArg.maxUsagePercent) },
1394
1605
  { label: 'Account reference', value: accountArg.id },
1395
1606
  ],
1396
1607
  accountId: accountArg.id,
@@ -1402,23 +1613,22 @@ const serviceRow = (
1402
1613
  };
1403
1614
  };
1404
1615
 
1405
- /** The listed rows, one group per provider, ChatGPT first. */
1616
+ /** The listed rows, one group per kind: ChatGPT, Claude, OpenAI API keys, Anthropic API keys. */
1406
1617
  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);
1618
+ const groups = [...new Set(rowsArg.map((rowArg) => rowArg.groupId))].sort((leftArg, rightArg) => {
1619
+ const left = groupOrder.indexOf(leftArg);
1620
+ const right = groupOrder.indexOf(rightArg);
1621
+ if (left !== right) return (left < 0 ? groupOrder.length : left) - (right < 0 ? groupOrder.length : right);
1411
1622
  return leftArg.localeCompare(rightArg);
1412
1623
  });
1413
- return providers.map((providerIdArg) => ({
1414
- providerId: providerIdArg,
1415
- label: providerLabel(providerIdArg),
1416
- rows: rowsArg.filter((rowArg) => rowArg.providerId === providerIdArg),
1417
- }));
1624
+ return groups.map((groupIdArg) => {
1625
+ const rows = rowsArg.filter((rowArg) => rowArg.groupId === groupIdArg);
1626
+ return { id: groupIdArg, providerId: rows[0]!.providerId, label: groupLabel(groupIdArg), rows };
1627
+ });
1418
1628
  };
1419
1629
 
1420
1630
  /**
1421
- * The account list: the service's accounts, under their provider.
1631
+ * The account list: the service's accounts, under their provider, the API keys apart from the sign-ins.
1422
1632
  *
1423
1633
  * The rows and the sentence under them come from the same read. The controller reads every page of
1424
1634
  * the service's snapshot, so a list that still does not end at its last row says so explicitly
@@ -1459,12 +1669,17 @@ const accountsSummary = (serviceArg: IAccountsServiceStatus, listArg: IAccountsL
1459
1669
  const needsYou = rows.filter((rowArg) => needsYouPills.includes(rowArg.pill.kind));
1460
1670
  const firstBack = limited.map((rowArg) => rowArg.backAt).filter((atArg): atArg is string => atArg !== null)
1461
1671
  .sort((leftArg, rightArg) => Date.parse(leftArg) - Date.parse(rightArg))[0];
1672
+ const keys = rows.filter((rowArg) => rowArg.apiKey);
1673
+ const readyKeys = keys.filter((rowArg) => readyPills.includes(rowArg.pill.kind)).length;
1462
1674
  return {
1463
1675
  ready: ready.length,
1464
1676
  total: rows.length,
1465
- readyText: listArg.groups.map((groupArg) => (
1466
- `${groupArg.rows.filter((rowArg) => readyPills.includes(rowArg.pill.kind)).length} ${groupArg.label}`
1467
- )).join(' · '),
1677
+ readyText: [
1678
+ ...listArg.groups.filter((groupArg) => !groupArg.rows[0]!.apiKey).map((groupArg) => (
1679
+ `${groupArg.rows.filter((rowArg) => readyPills.includes(rowArg.pill.kind)).length} ${groupArg.label}`
1680
+ )),
1681
+ ...keys.length ? [`${readyKeys} API ${readyKeys === 1 ? 'key' : 'keys'}`] : [],
1682
+ ].join(' · '),
1468
1683
  limited: limited.length,
1469
1684
  limitedText: firstBack ? `First back ${plugins.authswitchPreuseText.localDateTime(firstBack)}` : '',
1470
1685
  needsYou: needsYou.length,
@@ -1472,10 +1687,13 @@ const accountsSummary = (serviceArg: IAccountsServiceStatus, listArg: IAccountsL
1472
1687
  };
1473
1688
  };
1474
1689
 
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 }[] => (
1690
+ /**
1691
+ * The accounts whose usage a screen reads: each listed account's usage login, by account id. `apiKey` marks a key, whose
1692
+ * spend changes with every answer AGL relays on it, so the screen reads it again on every change it is told of.
1693
+ */
1694
+ export const usageTargets = (viewArg: IAccountsView): { accountId: string; loginId: string; apiKey: boolean }[] => (
1477
1695
  viewArg.list.groups.flatMap((groupArg) => groupArg.rows).flatMap((rowArg) => (
1478
- rowArg.usageLoginId ? [{ accountId: rowArg.accountId, loginId: rowArg.usageLoginId }] : []
1696
+ rowArg.usageLoginId ? [{ accountId: rowArg.accountId, loginId: rowArg.usageLoginId, apiKey: rowArg.apiKey }] : []
1479
1697
  ))
1480
1698
  );
1481
1699
 
@@ -1512,6 +1730,7 @@ export const accountsView = (
1512
1730
  'addClaude', 'Add Claude account', 'lucide:UserPlus', service,
1513
1731
  claudeSignIns.length ? 'A Claude sign-in is already waiting below.' : '',
1514
1732
  ),
1733
+ addApiKey: serviceSlot('addApiKey', 'Add API key', 'lucide:KeyRound', service),
1515
1734
  adoptCodex: projection && interfaces.findHostCodexAssignment(projection)
1516
1735
  ? null
1517
1736
  : serviceSlot('adoptCodex', 'Add Codex\'s current sign-in', 'lucide:LogIn', service),
@@ -1752,13 +1971,14 @@ const asOfTime = (isoArg: string | null): string => {
1752
1971
  * `ownerArg` is who renews the login it was read for, which names why the usage of Claude Code's own sign-in is
1753
1972
  * missing.
1754
1973
  */
1755
- export const usageView = (usageArg: TUsage, ownerArg: TLogin['owner'] | null = null): IAccountUsage => {
1974
+ export const usageView = (usageArg: TUsage, ownerArg: TLogin['owner'] | null = null,
1975
+ nowArg: number = Date.now()): IAccountUsage => {
1756
1976
  const observed = asOfTime(usageArg.observedAt);
1757
1977
  const reading = usageArg.reading;
1758
1978
  const current = usageArg.state === 'current';
1759
1979
  if (ownerArg === 'claude_native' && usageArg.problem === 'access_unavailable') {
1760
1980
  return { freshness: usageArg.state === 'stale' && observed ? `Last known, as of ${observed}` : 'Unavailable',
1761
- current, problemText: claudeHomeUsageText, meters: usageMeters(reading) };
1981
+ current, problemText: claudeHomeUsageText, meters: usageMeters(reading), spend: null };
1762
1982
  }
1763
1983
  const next = asOfTime(usageArg.nextAttemptAt);
1764
1984
  return {
@@ -1770,6 +1990,38 @@ export const usageView = (usageArg: TUsage, ownerArg: TLogin['owner'] | null = n
1770
1990
  ? `The provider limited usage reads, so the last one was refused${next ? `; the next read is at ${next}` : ''}.`
1771
1991
  : plugins.authswitchUsageWindows.authSwitchUsageProblemText(usageArg.problem),
1772
1992
  meters: usageMeters(reading),
1993
+ spend: reading?.kind === 'spend' ? spendView(reading.spend, nowArg, observed) : null,
1994
+ };
1995
+ };
1996
+
1997
+ /**
1998
+ * An API key's month in words: what AGL's traffic on it cost against its limit, or without one, how many answers could
1999
+ * not be priced, and when the accounts service read the key's ledger for it (`asOfArg`, the reading's `observedAt`): the
2000
+ * screen reads the month again on Refresh, Show latest usage and each change the controller announces, never on a
2001
+ * timer, so the figure says its time. Attention from `spendAttentionPercent` of the limit, critical once the key is
2002
+ * held, as of that reading. A reading whose month has ended says nothing of this one (`authSwitchSpendMonthEnded`).
2003
+ */
2004
+ const spendView = (spendArg: interfaces.TControllerAccountSpendReading, nowArg: number, asOfArg: string): IAccountSpend => {
2005
+ const usd = plugins.authswitchSpend.authSwitchUsdText;
2006
+ const unpriced = spendArg.unpricedAnswers === 0 ? ''
2007
+ : ` · ${spendArg.unpricedAnswers} ${spendArg.unpricedAnswers === 1 ? 'answer' : 'answers'} could not be priced`;
2008
+ const asOf = asOfArg ? ` · as of ${asOfArg}` : '';
2009
+ const unmeteredText = spendArg.limitMicroUsd === null
2010
+ ? 'No monthly spend limit, so there is nothing to meter: AGL counts what the key costs.' : '';
2011
+ if (plugins.authswitchSpend.authSwitchSpendMonthEnded(spendArg, nowArg)) {
2012
+ return { text: `${usd(spendArg.spentMicroUsd)} in a month that has ended · Refresh reads this one${asOf}`,
2013
+ tone: 'neutral', unmeteredText };
2014
+ }
2015
+ if (spendArg.limitMicroUsd === null) {
2016
+ return { text: `Spent ${usd(spendArg.spentMicroUsd)} this month · no limit${unpriced}${asOf}`, tone: 'neutral',
2017
+ unmeteredText };
2018
+ }
2019
+ const percent = plugins.authswitchSpend.authSwitchSpendPercent(spendArg) ?? 0;
2020
+ return {
2021
+ text: `${usd(spendArg.spentMicroUsd)} of ${usd(spendArg.limitMicroUsd)} this month · AGL traffic only${unpriced}${asOf}`,
2022
+ tone: plugins.authswitchSpend.authSwitchSpendExhausted(spendArg, nowArg) ? 'critical'
2023
+ : percent >= spendAttentionPercent ? 'attention' : 'neutral',
2024
+ unmeteredText,
1773
2025
  };
1774
2026
  };
1775
2027