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
@@ -1,11 +1,13 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type * as interfaces from '../ts_interfaces/index.js';
3
- import { isControllerReserveLiftHours } from '../ts_interfaces/index.js';
3
+ import { controllerApiKeyMaxLength, isControllerReserveLiftHours } from '../ts_interfaces/index.js';
4
4
  import type { ControllerSocketClient } from './classes.controllersocketclient.js';
5
5
  import {
6
6
  accountsHowTo,
7
7
  accountsView,
8
8
  answerNotice,
9
+ apiKeyAddedNotice,
10
+ apiKeyReason,
9
11
  claudeHostReadPending,
10
12
  claudeHostView,
11
13
  claudeReadPending,
@@ -31,6 +33,8 @@ import {
31
33
  recheckNotice,
32
34
  renameReason,
33
35
  servicePanel,
36
+ spendLimitDraft,
37
+ spendLimitReason,
34
38
  usageTargets,
35
39
  type IAccountActionSlot,
36
40
  type IAccountLoginView,
@@ -102,9 +106,27 @@ type TAccountsDialog =
102
106
  | { kind: 'codexRelease'; accountId: string }
103
107
  | { kind: 'preuse'; accountId: string; loginId: string }
104
108
  | { kind: 'liftReserve'; accountId: string }
109
+ /** Adding an API key of the provider Add account chose. */
110
+ | { kind: 'apiKey'; providerId: interfaces.TControllerApiKeyProvider }
105
111
  | { kind: 'claudeRelease' }
106
112
  | { kind: 'claudeRepair' };
107
113
 
114
+ /** What a spend limit does, under its field. */
115
+ const spendLimitDescription = 'What AGL\'s own traffic on the key may cost in a calendar month (UTC), in dollars. At'
116
+ + ' the limit AGL places nothing more on the key until the month ends; answers already running finish. Keep the'
117
+ + ' provider\'s own limit higher, as the backstop for everything else that uses the key.';
118
+
119
+ /**
120
+ * How long the browser waits for an added key: the accounts service gives the provider 15 seconds to answer whether it
121
+ * accepts the key, and the controller answers once its own projection shows the account.
122
+ */
123
+ const apiKeyAddTimeoutMs = 60_000;
124
+
125
+ /** A key's provider by name, as the Add API key dialog names it. */
126
+ const apiKeyProviderName = (providerIdArg: interfaces.TControllerApiKeyProvider): string => (
127
+ providerIdArg === 'openai' ? 'OpenAI' : 'Anthropic'
128
+ );
129
+
108
130
  /** What a usage threshold does, under its field. */
109
131
  const maxUsageDescription = 'At this share of any usage window AGL places no new work on the account and moves its'
110
132
  + ' sessions on, so the rest stays yours outside AGL.';
@@ -166,8 +188,9 @@ const liftOptions = Array.from({ length: 24 }, (_, indexArg) => ({
166
188
  }));
167
189
 
168
190
  /**
169
- * The Accounts screen: the accounts service's ChatGPT and Claude accounts as a list of one-line rows -- one status, the
170
- * tightest usage window -- that each open the account's own page; the tools on this host; and the service itself.
191
+ * The Accounts screen: the accounts service's ChatGPT and Claude accounts and OpenAI and Anthropic API keys as a list of
192
+ * one-line rows -- one status, the tightest usage window or a key's month -- that each open the account's own page; the
193
+ * tools on this host; and the service itself.
171
194
  *
172
195
  * It holds no account state of its own. It renders the controller's projection and Flex's connections as the app hands
173
196
  * them over, re-reads when the controller announces a change, and runs no timer and no poll -- so a failed read is a
@@ -236,6 +259,24 @@ export class AglAccounts extends plugins.deesElement.DeesElement {
236
259
  @plugins.deesElement.state()
237
260
  private accessor maxUsageDraft: string | null = null;
238
261
 
262
+ /** A key's spend limit, in dollars, as the open page's field holds it while edited; `null` while it shows the saved one. */
263
+ @plugins.deesElement.state()
264
+ private accessor spendLimitDraft: string | null = null;
265
+
266
+ /**
267
+ * Whether the Add API key dialog's key field holds a key. The key itself stays in its field only, which leaves the
268
+ * page with the dialog; it is read from there when the owner adds it.
269
+ */
270
+ @plugins.deesElement.state()
271
+ private accessor apiKeyTyped = false;
272
+
273
+ /** The name and the spend limit, in dollars, the Add API key dialog holds. */
274
+ @plugins.deesElement.state()
275
+ private accessor apiKeyName = '';
276
+
277
+ @plugins.deesElement.state()
278
+ private accessor apiKeyLimit = '';
279
+
239
280
  /** What the screen read of Claude Code's own home on this host. */
240
281
  @plugins.deesElement.state()
241
282
  private accessor claudeHost: TAccountsClaudeHostRead = claudeHostReadPending;
@@ -386,7 +427,7 @@ export class AglAccounts extends plugins.deesElement.DeesElement {
386
427
  }
387
428
 
388
429
  .figure { font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; }
389
- .tile.loud .figure { color: var(--dees-color-accent-warning); }
430
+ .tile.loud .figure { color: var(--dees-color-text-warning); }
390
431
  .of { color: var(--dees-color-text-muted); font-size: var(--dees-font-footnote-size); font-weight: 500; }
391
432
  .tileLabel { font-size: var(--dees-font-footnote-size); font-weight: 500; }
392
433
 
@@ -490,6 +531,9 @@ export class AglAccounts extends plugins.deesElement.DeesElement {
490
531
  }
491
532
 
492
533
  .meterValue { color: var(--dees-color-text-secondary); font-variant-numeric: tabular-nums; }
534
+ .spend { margin: 0; color: var(--dees-color-text-secondary); font-size: var(--dees-font-caption1-size); }
535
+ .spend.attention { color: var(--dees-color-text-warning); }
536
+ .spend.critical { color: var(--dees-color-text-error); }
493
537
  .row dees-badge { justify-self: end; }
494
538
  .chevron { color: var(--dees-color-label-quaternary, var(--dees-color-text-muted)); }
495
539
 
@@ -756,7 +800,10 @@ export class AglAccounts extends plugins.deesElement.DeesElement {
756
800
  this.settleStartedClaudeSignIns(authority);
757
801
  const targets = usageTargets(accountsView(this.read, this.projects, this.flex));
758
802
  for (const target of targets) {
759
- if (rereadUsageArg || !this.usage.has(target.accountId)) void this.readUsage(target.accountId, target.loginId);
803
+ // A key's spend moves with every answer AGL relays on it, and each change of it the controller announces.
804
+ if (rereadUsageArg || target.apiKey || !this.usage.has(target.accountId)) {
805
+ void this.readUsage(target.accountId, target.loginId);
806
+ }
760
807
  }
761
808
  } catch (errorArg) {
762
809
  if (generation !== this.readGeneration) return;
@@ -870,8 +917,9 @@ export class AglAccounts extends plugins.deesElement.DeesElement {
870
917
  }
871
918
 
872
919
  /**
873
- * Re-checks one Claude account: the service reads it at once and clears its payment problem when it reads healthy.
874
- * The usage it answers replaces what the row showed, and the notice says how the read went.
920
+ * Re-checks one Claude account or API key: the service reads a Claude account at once and clears its payment problem
921
+ * when it reads healthy, and lifts a key's hold without asking its provider. The usage it answers replaces what the
922
+ * row showed, and the notice says how the read went.
875
923
  */
876
924
  private async recheckUsage(accountIdArg: string, loginIdArg: string, labelArg: string): Promise<void> {
877
925
  const client = this.client;
@@ -945,6 +993,7 @@ export class AglAccounts extends plugins.deesElement.DeesElement {
945
993
  this.dialogAnswer = null;
946
994
  this.dialogCheck = false;
947
995
  this.liftHours = undefined;
996
+ this.forgetApiKeyDrafts();
948
997
  }
949
998
 
950
999
  /** Closes the dialog on screen before it leaves the template, so focus returns to the control that opened it. */
@@ -959,6 +1008,14 @@ export class AglAccounts extends plugins.deesElement.DeesElement {
959
1008
  this.dialogAnswer = null;
960
1009
  this.dialogCheck = false;
961
1010
  this.liftHours = undefined;
1011
+ this.forgetApiKeyDrafts();
1012
+ }
1013
+
1014
+ /** The Add API key dialog's drafts; its key field leaves the page with the dialog. */
1015
+ private forgetApiKeyDrafts(): void {
1016
+ this.apiKeyTyped = false;
1017
+ this.apiKeyName = '';
1018
+ this.apiKeyLimit = '';
962
1019
  }
963
1020
 
964
1021
  /** Asks the app to run a Flex action; Flex's state and its requests live there. */
@@ -999,6 +1056,47 @@ export class AglAccounts extends plugins.deesElement.DeesElement {
999
1056
  });
1000
1057
  }
1001
1058
 
1059
+ /** Sets an API key's monthly spend limit from the field, in dollars, or clears it with `null`. */
1060
+ private async setSpendLimit(rowArg: IAccountRow, spendLimitMicroUsdArg: number | null): Promise<void> {
1061
+ await this.runAction(`spendlimit:${rowArg.accountId}`, async () => {
1062
+ const { answer } = await this.client!.fire<interfaces.IReq_ControllerAccountsSpendLimit>(
1063
+ 'controller.accounts.spendlimit',
1064
+ { accountId: rowArg.accountId, expectedRevision: rowArg.revision, spendLimitMicroUsd: spendLimitMicroUsdArg },
1065
+ { maxRetries: 0 },
1066
+ );
1067
+ this.notice = answerNotice(answer);
1068
+ if (answer.state === 'done') this.spendLimitDraft = null;
1069
+ await this.refresh();
1070
+ });
1071
+ }
1072
+
1073
+ /**
1074
+ * Adds the API key the dialog holds. The accounts service asks the provider whether it accepts the key before it holds
1075
+ * anything, so the dialog's fields stay locked while it does; added, the dialog closes and the screen says the spend
1076
+ * limit the account actually has -- a key added again keeps its earlier one unless this add named one. A refusal stays
1077
+ * in the dialog, and nothing is sent again on its own.
1078
+ */
1079
+ private async addApiKey(providerIdArg: interfaces.TControllerApiKeyProvider): Promise<void> {
1080
+ const field = this.shadowRoot?.querySelector<plugins.deesCatalog.DeesInputText>(
1081
+ 'dialog[data-dialog="apiKey"] dees-input-text[data-api-key]');
1082
+ const apiKey = String(field?.value ?? '').trim();
1083
+ const label = this.apiKeyName.trim();
1084
+ const limit = this.apiKeyLimit.trim();
1085
+ if (apiKeyReason(apiKey !== '', label, limit) !== '') return;
1086
+ const spendLimitMicroUsd = limit ? plugins.authswitchSpend.authSwitchParseUsd(limit) : null;
1087
+ await this.runInDialog(`apikey:${providerIdArg}`, async () => {
1088
+ const { answer } = await this.client!.fire<interfaces.IReq_ControllerAccountsApiKeyAdd>(
1089
+ 'controller.accounts.apikey.add',
1090
+ { providerId: providerIdArg, apiKey, ...(label ? { label } : {}),
1091
+ ...(spendLimitMicroUsd === null ? {} : { spendLimitMicroUsd }) },
1092
+ { timeoutMs: apiKeyAddTimeoutMs, maxRetries: 0 },
1093
+ );
1094
+ this.notice = answer.state === 'done' ? apiKeyAddedNotice(answer.value.account) : answerNotice(answer);
1095
+ if (answer.state === 'done') await this.refresh();
1096
+ return answer.state === 'done';
1097
+ });
1098
+ }
1099
+
1002
1100
  /** Lifts the account's reserve for the hours the dialog holds. An uncertain send is never retried. */
1003
1101
  private async liftReserve(rowArg: IAccountRow): Promise<void> {
1004
1102
  const hours = this.liftHours;
@@ -1306,6 +1404,7 @@ export class AglAccounts extends plugins.deesElement.DeesElement {
1306
1404
  this.openKey = rowArg.key;
1307
1405
  this.renaming = false;
1308
1406
  this.maxUsageDraft = null;
1407
+ this.spendLimitDraft = null;
1309
1408
  }
1310
1409
 
1311
1410
  private renderNotice(noticeArg: IAccountsNotice): plugins.deesElement.TemplateResult {
@@ -1363,7 +1462,7 @@ export class AglAccounts extends plugins.deesElement.DeesElement {
1363
1462
  ${viewArg.operations.map((operationArg) => this.renderOperation(operationArg))}
1364
1463
  ${viewArg.claudeSignIns.map((signInArg) => this.renderClaudeSignIn(signInArg))}
1365
1464
  ${list.groups.map((groupArg) => plugins.deesElement.html`
1366
- <section class="group" data-provider-group=${groupArg.providerId}>
1465
+ <section class="group" data-provider-group=${groupArg.id}>
1367
1466
  <div class="groupHead"><h3 class="groupTitle">${groupArg.label}</h3><span>${groupArg.rows.length}</span></div>
1368
1467
  <div class="card rows" role="list" aria-label=${`${groupArg.label} accounts`}>
1369
1468
  ${groupArg.rows.map((rowArg) => plugins.deesElement.html`<div role="listitem">${renderListRow(rowArg, open)}</div>`)}
@@ -1414,8 +1513,9 @@ export class AglAccounts extends plugins.deesElement.DeesElement {
1414
1513
  <div class="itemText">${loginArg.statusText}</div>
1415
1514
  <dl class="facts">
1416
1515
  ${loginArg.causeText ? plugins.deesElement.html`<dt>Cause</dt><dd>${loginArg.causeText}</dd>` : ''}
1417
- <dt>Renewed by</dt><dd>${loginArg.renewedBy}</dd>
1418
- <dt>Next automatic renewal</dt><dd>${loginArg.renewalText}</dd>
1516
+ ${loginArg.renewedBy ? plugins.deesElement.html`<dt>Renewed by</dt><dd>${loginArg.renewedBy}</dd>` : ''}
1517
+ ${loginArg.renewalText
1518
+ ? plugins.deesElement.html`<dt>Next automatic renewal</dt><dd>${loginArg.renewalText}</dd>` : ''}
1419
1519
  <dt>Last checked</dt><dd>${loginArg.observedText}</dd>
1420
1520
  </dl>
1421
1521
  ${this.slots([...(loginArg.reauth ? [loginArg.reauth] : []), ...(loginArg.preuse ? [loginArg.preuse] : [])],
@@ -1494,12 +1594,64 @@ export class AglAccounts extends plugins.deesElement.DeesElement {
1494
1594
  `;
1495
1595
  }
1496
1596
 
1597
+ /**
1598
+ * An API key's monthly spend limit in dollars, saved with its own Save, or removed so the key serves without one. A
1599
+ * limit below what the month spent holds the key at its next request.
1600
+ */
1601
+ private renderSpendLimit(rowArg: IAccountRow, slotArg: IAccountActionSlot): plugins.deesElement.TemplateResult {
1602
+ const saved = rowArg.spendLimitMicroUsd;
1603
+ if (saved === null && this.spendLimitDraft === null) {
1604
+ return plugins.deesElement.html`
1605
+ <div class="item" data-spend-limit>
1606
+ <div>
1607
+ <div class="itemLabel">Monthly spend limit</div>
1608
+ <div class="itemText">Not set: AGL places requests on the key whatever they cost this month. Set a limit to
1609
+ stop at an amount of your choosing.</div>
1610
+ </div>
1611
+ ${this.slots([slotArg], () => { this.spendLimitDraft = ''; }, () => 'Set a limit…')}
1612
+ </div>
1613
+ `;
1614
+ }
1615
+ const draft = this.spendLimitDraft ?? spendLimitDraft(saved!);
1616
+ const reason = slotArg.reason || spendLimitReason(draft, saved);
1617
+ return plugins.deesElement.html`
1618
+ <div class="item stacked" data-spend-limit>
1619
+ <div>
1620
+ <div class="itemLabel">Monthly spend limit</div>
1621
+ <div class="itemText">${spendLimitDescription}</div>
1622
+ </div>
1623
+ <div class="inline">
1624
+ <span class="itemText">$</span>
1625
+ <dees-input-text .label=${'Monthly spend limit ($)'} .labelPosition=${'none'} .value=${draft}
1626
+ @input=${(eventArg: Event) => {
1627
+ const target = eventArg.currentTarget as HTMLElement & { value?: string };
1628
+ this.spendLimitDraft = target.value ?? '';
1629
+ }}></dees-input-text>
1630
+ <div>${this.slots(
1631
+ [
1632
+ { id: 'spendLimit', label: 'Save', icon: 'lucide:Check', reason },
1633
+ ...(saved === null ? []
1634
+ : [{ id: 'clearSpendLimit' as const, label: 'No limit', icon: 'lucide:X', reason: slotArg.reason }]),
1635
+ ],
1636
+ (runArg) => void this.setSpendLimit(rowArg, runArg.id === 'clearSpendLimit' ? null
1637
+ : plugins.authswitchSpend.authSwitchParseUsd(draft)),
1638
+ )}</div>
1639
+ ${saved === null
1640
+ ? plugins.deesElement.html`<dees-button size="sm" type="ghost" text="Keep it unset" data-action="keepSpendLimit"
1641
+ @clicked=${() => { this.spendLimitDraft = null; }}></dees-button>`
1642
+ : ''}
1643
+ </div>
1644
+ </div>
1645
+ `;
1646
+ }
1647
+
1497
1648
  /** An account's own page: everything the row stands for, each change with its own control, risky ones confirmed. */
1498
1649
  private renderPage(rowArg: IAccountRow): plugins.deesElement.TemplateResult {
1499
1650
  const slot = (idArg: TAccountActionId) => rowArg.manage.find((slotArg) => slotArg.id === idArg)
1500
1651
  ?? rowArg.actions.find((slotArg) => slotArg.id === idArg) ?? null;
1501
1652
  const renameSlot = slot('rename');
1502
1653
  const maxUsageSlot = slot('maxUsage');
1654
+ const spendLimitSlot = slot('spendLimit');
1503
1655
  const liftSlot = slot('liftReserve');
1504
1656
  const removeSlot = slot('remove');
1505
1657
  const useCodexSlot = slot('useCodex');
@@ -1535,6 +1687,9 @@ export class AglAccounts extends plugins.deesElement.DeesElement {
1535
1687
  }
1536
1688
  };
1537
1689
  const confirmLabel = (slotArg: IAccountActionSlot) => `${slotArg.label}…`;
1690
+ // A key's usage is its month's spend, and its one login is the key itself.
1691
+ const usageHead = rowArg.apiKey ? 'Spend' : 'Usage';
1692
+ const signInHead = rowArg.apiKey ? 'Key' : 'Sign-in';
1538
1693
  return plugins.deesElement.html`
1539
1694
  <section class="page" data-account-page=${rowArg.key} aria-label=${`Account ${rowArg.label}`}>
1540
1695
  <button class="back" type="button" data-action="back" @click=${() => { this.openKey = ''; }}>
@@ -1573,10 +1728,13 @@ export class AglAccounts extends plugins.deesElement.DeesElement {
1573
1728
  </div></div>
1574
1729
  </section>`
1575
1730
  : ''}
1576
- <section class="section" data-usage aria-label=${`Usage · ${rowArg.usage?.freshness ?? ''}`}>
1577
- <div class="sectionHead"><span>Usage</span><span>${rowArg.usage?.freshness ?? ''}</span></div>
1578
- ${renderUsageMeters(rowArg, rowArg.usage?.problemText
1731
+ <section class="section" data-usage aria-label=${`${usageHead} · ${rowArg.usage?.freshness ?? ''}`}>
1732
+ <div class="sectionHead"><span>${usageHead}</span><span>${rowArg.usage?.freshness ?? ''}</span></div>
1733
+ ${renderUsageMeters(rowArg, rowArg.usage?.problemText || rowArg.usage?.spend?.unmeteredText
1579
1734
  || rowArg.facts.find((factArg) => factArg.label === 'Usage')?.value || 'No reading yet.')}
1735
+ ${rowArg.usage?.spend
1736
+ ? plugins.deesElement.html`<p class=${`spend ${rowArg.usage.spend.tone}`} data-spend>${rowArg.usage.spend.text}</p>`
1737
+ : ''}
1580
1738
  ${rowArg.usage?.problemText && rowArg.usage.meters.length
1581
1739
  ? plugins.deesElement.html`<p class="reason">${rowArg.usage.problemText}</p>` : ''}
1582
1740
  ${rowArg.limitStatus
@@ -1585,6 +1743,12 @@ export class AglAccounts extends plugins.deesElement.DeesElement {
1585
1743
  : ''}
1586
1744
  ${usageSlots.length ? this.slots(usageSlots, run) : ''}
1587
1745
  </section>
1746
+ ${spendLimitSlot
1747
+ ? plugins.deesElement.html`<section class="section" data-limits aria-label="Limits">
1748
+ <div class="sectionHead"><span>Limits</span></div>
1749
+ <div class="card">${this.renderSpendLimit(rowArg, this.effectiveSlot(spendLimitSlot))}</div>
1750
+ </section>`
1751
+ : ''}
1588
1752
  ${maxUsageSlot
1589
1753
  ? plugins.deesElement.html`<section class="section" data-limits aria-label="Limits">
1590
1754
  <div class="sectionHead"><span>Limits</span></div>
@@ -1623,8 +1787,8 @@ export class AglAccounts extends plugins.deesElement.DeesElement {
1623
1787
  </section>`
1624
1788
  : ''}
1625
1789
  ${rowArg.logins.length
1626
- ? plugins.deesElement.html`<section class="section" data-signin aria-label="Sign-in">
1627
- <div class="sectionHead"><span>Sign-in</span></div>
1790
+ ? plugins.deesElement.html`<section class="section" data-signin aria-label=${signInHead}>
1791
+ <div class="sectionHead"><span>${signInHead}</span></div>
1628
1792
  <div class="card">
1629
1793
  ${rowArg.logins.map((loginArg) => this.renderLogin(rowArg, loginArg))}
1630
1794
  ${claudeSignIn ? plugins.deesElement.html`<div class="item">
@@ -1645,11 +1809,11 @@ export class AglAccounts extends plugins.deesElement.DeesElement {
1645
1809
  ? plugins.deesElement.html`<section class="section" data-remove aria-label="Remove">
1646
1810
  <div class="card"><div class="item">
1647
1811
  <div>
1648
- <div class="itemLabel">Remove account</div>
1812
+ <div class="itemLabel">${rowArg.apiKey ? 'Remove key' : 'Remove account'}</div>
1649
1813
  <div class="itemText">${rowArg.hostCodex
1650
1814
  ? 'Codex on this host runs on it, so removing hands Codex\'s sign-in to AGL first. Using the account'
1651
1815
  + ' again takes a new sign-in.'
1652
- : 'AGL stops placing work on it. Using it again takes a new sign-in.'}</div>
1816
+ : rowArg.removeNote || 'AGL stops placing work on it. Using it again takes a new sign-in.'}</div>
1653
1817
  </div>
1654
1818
  <div>${this.slots([removeSlot], run, () => rowArg.hostCodex ? 'Hand to AGL and remove…' : 'Remove…')}</div>
1655
1819
  </div></div>
@@ -1857,7 +2021,8 @@ export class AglAccounts extends plugins.deesElement.DeesElement {
1857
2021
  </button>`;
1858
2022
  };
1859
2023
  return this.dialogShell('add', 'Add account', plugins.deesElement.html`
1860
- <p class="itemText">AGL places work on the accounts it holds; each one is added with its own sign-in.</p>
2024
+ <p class="itemText">AGL places work on the accounts it holds: a subscription with its own sign-in, and an API key
2025
+ once no subscription can take a request.</p>
1861
2026
  <div class="sectionHead"><span>Subscription</span></div>
1862
2027
  ${choice('chatgpt', viewArg.add, 'ChatGPT', 'Sign in on any device with a one-time code',
1863
2028
  () => void this.addAccount(), 'openai')}
@@ -1867,8 +2032,40 @@ export class AglAccounts extends plugins.deesElement.DeesElement {
1867
2032
  ? plugins.deesElement.html`<div class="inline"><span class="itemText">Codex on this host is signed in already?</span>
1868
2033
  ${this.slots([viewArg.adoptCodex], () => void this.adoptCodexHost())}</div>`
1869
2034
  : ''}
2035
+ <div class="sectionHead"><span>API key</span></div>
2036
+ ${choice('openai-api-key', viewArg.addApiKey, 'OpenAI API key', 'A key from the OpenAI platform, paid per request',
2037
+ () => this.openDialog({ kind: 'apiKey', providerId: 'openai' }), 'openai')}
2038
+ ${choice('anthropic-api-key', viewArg.addApiKey, 'Anthropic API key', 'A key from the Claude Console, paid per request',
2039
+ () => this.openDialog({ kind: 'apiKey', providerId: 'anthropic' }), 'anthropic')}
1870
2040
  `, this.dialogFoot('Cancel', null));
1871
2041
  }
2042
+ case 'apiKey': {
2043
+ const provider = apiKeyProviderName(dialog.providerId);
2044
+ const running = this.busyAction === `apikey:${dialog.providerId}`;
2045
+ const value = (eventArg: Event): string => String(
2046
+ (eventArg.currentTarget as HTMLElement & { value?: unknown }).value ?? '');
2047
+ return this.dialogShell('apiKey', `Add ${provider} API key`, plugins.deesElement.html`
2048
+ <p class="itemText">An account of its own, which AGL places requests on at API prices once no subscription can take
2049
+ them. ${provider} is asked whether it accepts the key before anything is kept.</p>
2050
+ <dees-input-text data-api-key .label=${'API key'} .isPasswordBool=${true} .autocomplete=${'off'}
2051
+ .maxLength=${controllerApiKeyMaxLength} .disabled=${running}
2052
+ .description=${'Sent to the accounts service, which keeps it sealed and never shows it again.'}
2053
+ @input=${(eventArg: Event) => { this.apiKeyTyped = value(eventArg).trim() !== ''; }}></dees-input-text>
2054
+ <dees-input-text data-api-key-name .label=${'Name (optional)'} .value=${this.apiKeyName} .disabled=${running}
2055
+ @input=${(eventArg: Event) => { this.apiKeyName = value(eventArg); }}></dees-input-text>
2056
+ <dees-input-text data-api-key-limit .label=${'Monthly spend limit in dollars (optional)'} .value=${this.apiKeyLimit}
2057
+ .description=${spendLimitDescription} .disabled=${running}
2058
+ @input=${(eventArg: Event) => { this.apiKeyLimit = value(eventArg); }}></dees-input-text>
2059
+ ${running
2060
+ ? plugins.deesElement.html`<dees-hint data-api-key-progress .type=${'info'}
2061
+ .text=${`Asking ${provider} whether it accepts this key…`}></dees-hint>`
2062
+ : ''}
2063
+ `, this.dialogFoot('Cancel', {
2064
+ label: 'Add key', type: 'accent',
2065
+ reason: apiKeyReason(this.apiKeyTyped, this.apiKeyName, this.apiKeyLimit) || busy(viewArg.addApiKey),
2066
+ run: () => void this.addApiKey(dialog.providerId),
2067
+ }));
2068
+ }
1872
2069
  case 'signIn': {
1873
2070
  const operation = [...viewArg.operations, ...rows.flatMap((rowArg) => rowArg.operations)]
1874
2071
  .find((operationArg) => operationArg.id === dialog.operationId);
@@ -1914,10 +2111,12 @@ export class AglAccounts extends plugins.deesElement.DeesElement {
1914
2111
  const running = this.busyAction === `codex-release:${row.accountId}`;
1915
2112
  const question = asQuestion(handOver
1916
2113
  ? remove ? codexReleaseRemoveConfirmText(row.label) : codexReleaseConfirmText(row.label)
1917
- : row.assigned
2114
+ : `${row.assigned
1918
2115
  ? `Remove ${row.label}? It is in use by ${row.assignedSummary}. AGL cannot stop those from here, so the`
1919
2116
  + ' removal is refused while they use it.'
1920
- : `Remove ${row.label}? Nothing uses it. Once removed, using it again takes a new sign-in.`,
2117
+ : row.apiKey ? `Remove ${row.label}? Nothing uses it.`
2118
+ : `Remove ${row.label}? Nothing uses it. Once removed, using it again takes a new sign-in.`}`
2119
+ + (row.removeNote ? ` ${row.removeNote}` : ''),
1921
2120
  remove ? `Remove ${row.label}?` : `Hand Codex's sign-in of ${row.label} to AGL?`);
1922
2121
  return this.dialogShell(dialog.kind, question.title,
1923
2122
  plugins.deesElement.html`
@@ -33,8 +33,10 @@ const connectionChoices = (
33
33
  };
34
34
  const profiles = profilesArg.filter(profile => profile.state === 'active');
35
35
  const withProfile = new Set(profiles.flatMap(profile => profile.accountId ? [profile.accountId] : []));
36
+ // A ChatGPT account; an OpenAI API key account (tenancy `api-key`) is no sign-in Codex runs on here.
36
37
  const accounts = (authorityArg?.state === 'ready' ? authorityArg.snapshot?.accounts ?? [] : [])
37
- .filter(account => account.providerId === 'openai' && !account.removed && !withProfile.has(account.id));
38
+ .filter(account => account.providerId === 'openai' && account.organizationId !== 'api-key' && !account.removed
39
+ && !withProfile.has(account.id));
38
40
  return [
39
41
  ...profiles.map(profile => ({ value: profile.id, name: profile.name, mode: profile.mode,
40
42
  reason: profile.accountId ? accountReason(profile.accountId) : '' })),
package/ts_web/plugins.ts CHANGED
@@ -9,8 +9,9 @@ import * as mpCatalog from '@modelprofile.com/catalog';
9
9
 
10
10
  import * as simpleWebAuthnBrowser from '@simplewebauthn/browser';
11
11
 
12
- // authswitch's time and preuse sentences, taken from where the Node-loadable read models take them.
13
- import { authswitchPreuseText } from './accounts.plugins.js';
12
+ // authswitch's time and preuse sentences and an API key's dollar amounts, taken from where the Node-loadable read
13
+ // models take them.
14
+ import { authswitchPreuseText, authswitchSpend } from './accounts.plugins.js';
14
15
 
15
16
  // Bundled statically: the CSP allows no CDN scripts, so the catalog's lib
16
17
  // loader is fed these modules instead of fetching them at runtime. The
@@ -59,6 +60,7 @@ import type {
59
60
 
60
61
  export {
61
62
  authswitchPreuseText,
63
+ authswitchSpend,
62
64
  deesCatalog,
63
65
  deesComms,
64
66
  deesElement,