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
@@ -1,4 +1,5 @@
1
1
  import * as plugins from './plugins.js';
2
+ import { authSwitchApiKeyVerifier } from './authority-apikeys.js';
2
3
  import { AuthSwitchAuthorityDaemon, type IAuthSwitchAuthorityDaemonOptions } from './classes.authoritydaemon.js';
3
4
  import { AuthSwitchOpenAiProvider } from './classes.openaiprovider.js';
4
5
  import { ClaudeAccountStatus } from './classes.claudestatus.js';
@@ -107,9 +108,9 @@ export interface IAuthSwitchFixtureUsage {
107
108
  }
108
109
 
109
110
  /**
110
- * Anthropic's profile, usage and token endpoints for exactly the fixture tokens it knows, its model list for the
111
- * fixture API keys it issued, and ChatGPT's `wham/usage` for the fixture ChatGPT logins; every other request fails with
112
- * an error, so nothing a test starts reaches a real provider.
111
+ * Anthropic's profile, usage and token endpoints for exactly the fixture tokens it knows, Anthropic's and OpenAI's
112
+ * model lists for the fixture API keys it issued, and ChatGPT's `wham/usage` for the fixture ChatGPT logins; every
113
+ * other request fails with an error, so nothing a test starts reaches a real provider.
113
114
  */
114
115
  export class AuthSwitchFakeClaudeProvider {
115
116
  /** Access token -> the account name it belongs to. */
@@ -138,6 +139,27 @@ export class AuthSwitchFakeClaudeProvider {
138
139
  return apiKey;
139
140
  }
140
141
 
142
+ /**
143
+ * The OpenAI API keys OpenAI accepts -> the account name each was issued for, and whether it may read the model list
144
+ * (a restricted key without that permission may not); delete one to revoke it.
145
+ */
146
+ public readonly openAiApiKeys = new Map<string, { name: string; modelRead: boolean }>();
147
+ /** While true, OpenAI's model list answers 503. */
148
+ public openAiModelsDown = false;
149
+ public openAiModelListCalls = 0;
150
+
151
+ /**
152
+ * Issues `name`'s OpenAI API key, which OpenAI accepts from then on, and answers it: `sk-proj-fixture-<name>`, a
153
+ * project key's prefix, so the authority tells it from an Anthropic key as it would a real one. `modelRead: false`
154
+ * issues a restricted key that may not read the model list.
155
+ */
156
+ public issueOpenAiApiKey(name: string, options: { modelRead?: boolean } = {}): string {
157
+ assertFixtureName(name);
158
+ const apiKey = `sk-proj-fixture-${name}`;
159
+ this.openAiApiKeys.set(apiKey, { name, modelRead: options.modelRead ?? true });
160
+ return apiKey;
161
+ }
162
+
141
163
  /** Accepts the access token of `name`'s login at `generation`, and its refresh token for the next generation. */
142
164
  public know(name: string, generation: number): void {
143
165
  assertFixtureName(name);
@@ -168,6 +190,33 @@ export class AuthSwitchFakeClaudeProvider {
168
190
  }
169
191
  return Response.json({ data: [], has_more: false });
170
192
  }
193
+ if (url === 'https://api.openai.com/v1/models') {
194
+ // The model list of an OpenAI API key, which the authority reads to learn whether OpenAI accepts a key.
195
+ this.openAiModelListCalls++;
196
+ if (this.openAiModelsDown) {
197
+ return Response.json({ error: { message: 'overloaded', type: 'server_error' } }, { status: 503 });
198
+ }
199
+ const headers = new Headers(init?.headers);
200
+ // Only the request the authority's verifier sends is a fixture request: anything else is answered as malformed.
201
+ if ((init?.method ?? 'GET') !== 'GET' || init?.redirect !== 'error' || headers.has('x-api-key')
202
+ || headers.has('openai-organization') || headers.has('openai-project')) {
203
+ return Response.json({ error: { message: 'not a fixture request', type: 'invalid_request_error' } },
204
+ { status: 400 });
205
+ }
206
+ const presented = (headers.get('authorization') ?? '').replace(/^Bearer /, '');
207
+ const issued = this.openAiApiKeys.get(presented);
208
+ if (!issued) {
209
+ // OpenAI quotes the key it refused (masked); the fake quotes it whole, so a test proves nothing repeats it.
210
+ return Response.json({ error: { message: `Incorrect API key provided: ${presented}`,
211
+ type: 'invalid_request_error', param: null, code: 'invalid_api_key' } }, { status: 401 });
212
+ }
213
+ if (!issued.modelRead) {
214
+ return Response.json({ error: { message: 'You have insufficient permissions for this operation. Missing scopes:'
215
+ + ' api.model.read.', type: 'invalid_request_error', param: null, code: null } }, { status: 403 });
216
+ }
217
+ return Response.json({ object: 'list', data: [{ id: 'gpt-fixture', object: 'model', created: 1_700_000_000,
218
+ owned_by: 'openai' }] });
219
+ }
171
220
  if (url === 'https://api.anthropic.com/api/oauth/usage') {
172
221
  const token = (new Headers(init?.headers).get('Authorization') ?? '').replace(/^Bearer /, '');
173
222
  const name = this.accessTokens.get(token);
@@ -395,7 +444,7 @@ export const authSwitchSeedClaudeAccount = async (daemon: AuthSwitchAuthorityDae
395
444
  } else {
396
445
  const account: IStoredAuthorityAccount = { id: accountId, providerId: 'anthropic', issuer: 'anthropic',
397
446
  subject: identity.accountUuid, workspaceId: identity.organizationUuid, label: name,
398
- email: identity.emailAddress, plan: 'max', primaryGrantId: loginId, maxUsagePercent: null, reserveLiftUntil: null, lapsesAt: null, removed: false, revision: 1,
447
+ email: identity.emailAddress, plan: 'max', primaryGrantId: loginId, maxUsagePercent: null, reserveLiftUntil: null, lapsesAt: null, spendLimitMicroUsd: null, keyHint: null, removed: false, revision: 1,
399
448
  statusObservedAt: now, updateId };
400
449
  await database.changeAccountAndGrant(updateId, accountId, loginId, () => ({ account, grant }));
401
450
  }
@@ -418,8 +467,8 @@ export const authSwitchSeedClaudeAccount = async (daemon: AuthSwitchAuthorityDae
418
467
  * It serves the management and runtime sockets exactly as the installed authority does, with the Claude native
419
468
  * authority on a fixture host home, container homes confined to `homesDirectory` and Claude sign-ins made in
420
469
  * `claudeSignInDirectory`. Its OpenAI provider, its
421
- * Claude usage reads and its checks of an Anthropic API key go to the fake provider, which refuses everything that is
422
- * not a fixture request, and no Claude Code process is ever seen running on a home.
470
+ * Claude usage reads and its checks of an Anthropic or OpenAI API key go to the fake provider, which refuses everything
471
+ * that is not a fixture request, and no Claude Code process is ever seen running on a home.
423
472
  */
424
473
  export class AuthSwitchTestAuthority {
425
474
  private stopping: Promise<void> | null = null;
@@ -468,9 +517,7 @@ export class AuthSwitchTestAuthority {
468
517
  dataDirectory: plugins.path.join(directory, 'db'), socketPath: databaseSocketPath,
469
518
  authoritySocketPath, runtimeSocketPath,
470
519
  broker: { provider: new AuthSwitchOpenAiProvider({ fetch: provider.fetch }), secrets: plainCodec, proactive: false,
471
- verifyAnthropicApiKey: async (apiKey, signal) => {
472
- await plugins.flexAnthropic.listAnthropicApiKeyModels({ apiKey }, { signal, fetch: provider.fetch });
473
- } },
520
+ verifyApiKey: authSwitchApiKeyVerifier(provider.fetch) },
474
521
  ...claude.options,
475
522
  codexNative: { home: new CodexNativeHome({ codexHome: codexHost.codexHome, executable: codexHost.binary,
476
523
  scope: codexHost.scope }), secrets: plainCodec },
@@ -663,7 +710,7 @@ export class AuthSwitchTestAuthority {
663
710
  await database.changeAccountAndGrant(updateId, account.accountId, account.loginId, () => ({
664
711
  account: { id: account.accountId, providerId: 'anthropic', issuer: 'anthropic', subject: identity.accountUuid,
665
712
  workspaceId: identity.organizationUuid, label: name, email: identity.emailAddress, plan: 'max',
666
- primaryGrantId: account.loginId, maxUsagePercent: null, reserveLiftUntil: null, lapsesAt: null, removed: false, revision: 1, statusObservedAt: now, updateId },
713
+ primaryGrantId: account.loginId, maxUsagePercent: null, reserveLiftUntil: null, lapsesAt: null, spendLimitMicroUsd: null, keyHint: null, removed: false, revision: 1, statusObservedAt: now, updateId },
667
714
  grant: { id: account.loginId, accountId: account.accountId, providerId: 'anthropic', purpose: 'claude_host_native',
668
715
  audience: 'claude-code-host', state: 'native', owner: 'claude_native', grantGeneration: 1, authorizationGeneration: 1,
669
716
  revision: 1, ciphertext: null, accessExpiresAt: null, attemptId: null, retryAt: null, retryCount: 0,
@@ -701,7 +748,7 @@ export class AuthSwitchTestAuthority {
701
748
  const identity = authSwitchFixtureOpenAiIdentity(name);
702
749
  return { id: accountId, providerId: 'openai', issuer: 'openai', subject: identity.userId,
703
750
  workspaceId: identity.chatgptAccountId, label: name, email: identity.email, plan: 'pro', primaryGrantId: loginId,
704
- maxUsagePercent: null, reserveLiftUntil: null, lapsesAt: null, removed: false, revision: 1, statusObservedAt: now, updateId };
751
+ maxUsagePercent: null, reserveLiftUntil: null, lapsesAt: null, spendLimitMicroUsd: null, keyHint: null, removed: false, revision: 1, statusObservedAt: now, updateId };
705
752
  }
706
753
 
707
754
  /**
@@ -0,0 +1,20 @@
1
+ import * as plugins from '../plugins.js';
2
+
3
+ /**
4
+ * Gives every account recorded before API keys carried a spend limit the two members the account record now states.
5
+ *
6
+ * An API key account may carry a monthly spend limit (`spendLimitMicroUsd`) and names how its key ends (`keyHint`);
7
+ * the record states each, or `null` for none. Every account recorded earlier has neither: it gains
8
+ * `spendLimitMicroUsd: null` and `keyHint: null` -- an Anthropic key held from before keeps no hint, since its key is
9
+ * sealed -- and nothing else about it changes, neither its revision nor its update id, since no one changed the
10
+ * account. A record that already carries a member keeps it exactly as it is, which is what makes an interrupted run
11
+ * safe to repeat.
12
+ */
13
+ export const addAccountApiKeyFields = async (db: plugins.nosqldb.SmartdataDb): Promise<void> => {
14
+ const accounts = db.mongoDb.collection('authswitch_authority_accounts');
15
+ for (const member of ['spendLimitMicroUsd', 'keyHint'] as const) {
16
+ const missing = { [member]: { $exists: false } };
17
+ if (!await accounts.findOne(missing)) continue;
18
+ await accounts.updateMany(missing, { $set: { [member]: null } });
19
+ }
20
+ };
@@ -9,6 +9,7 @@ import { addAccountMaxUsage } from './0007_account_max_usage.js';
9
9
  import { addAccountLapse } from './0008_account_lapse.js';
10
10
  import { addAccountReserveLift } from './0009_account_reserve_lift.js';
11
11
  import { prepareCodexHostRelease } from './0010_codex_host_release.js';
12
+ import { addAccountApiKeyFields } from './0011_account_api_key_fields.js';
12
13
 
13
14
  /**
14
15
  * Runs on every start of an existing store, in this fixed order. Each module recognises the published shapes it
@@ -25,4 +26,5 @@ export const runAuthorityMigrations = async (db: plugins.nosqldb.SmartdataDb): P
25
26
  await addAccountLapse(db);
26
27
  await addAccountReserveLift(db);
27
28
  await prepareCodexHostRelease(db);
29
+ await addAccountApiKeyFields(db);
28
30
  };
@@ -147,10 +147,10 @@ export const findAuthorityDaemonLogin = (
147
147
  };
148
148
 
149
149
  /**
150
- * The login AGL's inference proxy binds for an account, and its purpose, like `findAuthorityDaemonLogin`: a
151
- * ChatGPT account's daemon-held `openai_managed` login, or an Anthropic account's daemon-held login -- a Claude
152
- * sign-in (`claude_host_native`), the one that can back a binding when the account holds several, or an API key
153
- * (`anthropic_api_key`).
150
+ * The login AGL's inference proxy binds for an account, and its purpose, like `findAuthorityDaemonLogin`: an OpenAI
151
+ * account's daemon-held login -- a ChatGPT sign-in (`openai_managed`) or an API key (`openai_api_key`) -- or an
152
+ * Anthropic account's daemon-held login -- a Claude sign-in (`claude_host_native`), the one that can back a binding
153
+ * when the account holds several, or an API key (`anthropic_api_key`).
154
154
  */
155
155
  export const findAuthorityGatewayLogin = (
156
156
  projectionArg: IControllerAuthorityProjection,
@@ -159,17 +159,37 @@ export const findAuthorityGatewayLogin = (
159
159
  const snapshot = projectionArg.snapshot;
160
160
  const account = snapshot?.accounts.find((accountArg) => accountArg.id === accountIdArg);
161
161
  if (!snapshot || !account || account.removed) return 'no_account';
162
- if (account.providerId !== 'anthropic') return findAuthorityDaemonLogin(projectionArg, accountIdArg);
162
+ if (account.providerId !== 'anthropic') {
163
+ const managed = findAuthorityDaemonLogin(projectionArg, accountIdArg);
164
+ return managed !== 'no_login' ? managed : snapshot.logins.find((loginArg) => loginArg.accountId === accountIdArg
165
+ && loginArg.purpose === 'openai_api_key' && loginArg.owner === 'daemon') ?? 'no_login';
166
+ }
163
167
  const logins = snapshot.logins.filter((loginArg) => loginArg.accountId === accountIdArg
164
168
  && (loginArg.purpose === 'claude_host_native' || loginArg.purpose === 'anthropic_api_key')
165
169
  && loginArg.owner === 'daemon');
166
170
  return logins.find((loginArg) => loginArg.capabilities.canBind) ?? logins[0] ?? 'no_login';
167
171
  };
168
172
 
173
+ /**
174
+ * The login an API key's answers are reported on (`authswitch.authority.usage.spend`): the key's live gateway login, or
175
+ * once the key was removed the login it had, as the projection publishes removed accounts and their logins too; the
176
+ * accounts service records a removed key's spend. `undefined` when the projection names no API-key login of the account.
177
+ */
178
+ export const findAuthorityApiKeyLogin = (
179
+ projectionArg: IControllerAuthorityProjection,
180
+ accountIdArg: string,
181
+ ): TControllerAccountLogin | undefined => {
182
+ const isKeyLogin = (loginArg: TControllerAccountLogin) => loginArg.accountId === accountIdArg && loginArg.owner === 'daemon'
183
+ && (loginArg.purpose === 'anthropic_api_key' || loginArg.purpose === 'openai_api_key');
184
+ const live = findAuthorityGatewayLogin(projectionArg, accountIdArg);
185
+ if (typeof live !== 'string') return isKeyLogin(live) ? live : undefined;
186
+ return projectionArg.snapshot?.logins.filter(isKeyLogin).at(-1);
187
+ };
188
+
169
189
  /**
170
190
  * A pool of AGL's inference proxy: its provider, and what its accounts authenticate with -- a subscription sign-in
171
- * (a ChatGPT or a Claude account), or an Anthropic API key. Claude Code runs on the Claude subscription pool, Flex's
172
- * Claude models only on the API-key pool.
191
+ * (a ChatGPT or a Claude account), or an API key of the provider. Claude Code runs on the Claude subscription pool,
192
+ * Flex's Claude models only on the Anthropic API-key pool.
173
193
  */
174
194
  export interface IInferencePool {
175
195
  provider: 'openai' | 'anthropic';
@@ -179,7 +199,7 @@ export interface IInferencePool {
179
199
  /**
180
200
  * The pool of AGL's inference proxy an account serves in, or `undefined` for none: an account whose gateway login
181
201
  * (`findAuthorityGatewayLogin`) the authority holds ready, can bind and keeps in no tool's home -- a ChatGPT account
182
- * this host's Codex does not run on, a Claude account, or an Anthropic API key.
202
+ * this host's Codex does not run on, a Claude account, or an Anthropic or OpenAI API key.
183
203
  */
184
204
  export const findInferencePool = (
185
205
  projectionArg: IControllerAuthorityProjection,
@@ -197,6 +217,9 @@ export const findInferencePool = (
197
217
  if (account.providerId === 'anthropic' && login.purpose === 'anthropic_api_key') {
198
218
  return { provider: 'anthropic', credential: 'api_key' };
199
219
  }
220
+ if (account.providerId === 'openai' && login.purpose === 'openai_api_key') {
221
+ return { provider: 'openai', credential: 'api_key' };
222
+ }
200
223
  return undefined;
201
224
  };
202
225
 
@@ -230,6 +253,18 @@ export const isFlexAuthorityConnectionId = (valueArg: unknown): valueArg is stri
230
253
  */
231
254
  export const flexClaudePoolConnectionId = 'flex:proxy:anthropic';
232
255
 
256
+ /**
257
+ * Whether an account serves in the Claude pool Flex's Claude models run on: an Anthropic API key in the proxy's
258
+ * Anthropic API-key pool (`findInferencePool`). An OpenAI key serves the OpenAI API-key pool, which Flex does not use.
259
+ */
260
+ export const isFlexClaudePoolMember = (
261
+ projectionArg: IControllerAuthorityProjection,
262
+ accountIdArg: string,
263
+ ): boolean => {
264
+ const pool = findInferencePool(projectionArg, accountIdArg);
265
+ return pool?.provider === 'anthropic' && pool.credential === 'api_key';
266
+ };
267
+
233
268
  /** Whether a value is a Flex connection id: an authority account's, or the Claude pool's. */
234
269
  export const isFlexConnectionId = (valueArg: unknown): valueArg is string =>
235
270
  valueArg === flexClaudePoolConnectionId || isFlexAuthorityConnectionId(valueArg);
@@ -5,7 +5,7 @@ import type {
5
5
  TControllerBrowserPaintAvailability,
6
6
  TControllerBrowserPaintTermination,
7
7
  } from './browserpaint.js';
8
- export const controllerProtocolVersion = 43 as const;
8
+ export const controllerProtocolVersion = 44 as const;
9
9
  export const controllerUpgradeManagementVersion = 2 as const;
10
10
  export const controllerPackageName = 'agl' as const;
11
11
  /** The systemd user unit `agl service install` writes; a controller it runs reports it in its status. */
@@ -2,9 +2,12 @@ import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';
2
2
  import type * as browserRuntime from '@push.rocks/smartbrowser/runtime';
3
3
  // The account-management contract on its own. Its own doc comment declares it credential-free and
4
4
  // safe for browser code, while the package root is Node-only, so the browser-facing interfaces take
5
- // the subpath rather than the root export. It is a leaf that imports nothing, so the rules it
6
- // publishes -- the account label rule -- are taken from it as values rather than restated.
5
+ // the subpath rather than the root export. Besides types it imports only the model id rule, itself a
6
+ // leaf, so the rules it publishes -- the account label rule -- are taken from it as values rather than
7
+ // restated.
7
8
  import * as authswitchAuthority from '../ts_authswitch/authority-contract.js';
9
+ // An API key's monthly spend: the spend limit rule and dollar amounts, a leaf that imports nothing.
10
+ import * as authswitchSpend from '../ts_authswitch/authority-spend.js';
8
11
  import type {
9
12
  AuthenticationResponseJSON,
10
13
  PublicKeyCredentialCreationOptionsJSON,
@@ -14,7 +17,7 @@ import type {
14
17
 
15
18
  export { typedrequestInterfaces };
16
19
  export type { browserRuntime };
17
- export { authswitchAuthority };
20
+ export { authswitchAuthority, authswitchSpend };
18
21
 
19
22
  export type {
20
23
  AuthenticationResponseJSON,
@@ -97,6 +97,8 @@ export type TControllerAccountNativeAssignment = plugins.authswitchAuthority.IAu
97
97
  export type TControllerAccountOperation = plugins.authswitchAuthority.IAuthSwitchOperation;
98
98
  export type TControllerAccountPreuse = plugins.authswitchAuthority.IAuthSwitchPreuseOperation;
99
99
  export type TControllerAccountUsage = plugins.authswitchAuthority.IAuthSwitchUsageSnapshot;
100
+ /** An API key's month, the reading of an API key account's usage (`kind: 'spend'`). */
101
+ export type TControllerAccountSpendReading = plugins.authswitchAuthority.IAuthSwitchSpendReading;
100
102
  /** A re-check of a Claude login: the usage held once its read finished, and how that read went. */
101
103
  export type TControllerAccountUsageRecheck = plugins.authswitchAuthority.IAuthSwitchUsageRecheck;
102
104
  export type TControllerAccountsDoctorPage = plugins.authswitchAuthority.IAuthSwitchDoctorPage;
@@ -187,6 +189,52 @@ export interface IReq_ControllerAccountsMaxUsage extends plugins.typedrequestInt
187
189
  response: { answer: TControllerAccountsAnswer<TControllerAccount> };
188
190
  }
189
191
 
192
+ /** The providers whose API keys the Accounts screen adds as accounts. */
193
+ export type TControllerApiKeyProvider = plugins.authswitchAuthority.TAuthSwitchApiKeyProvider;
194
+
195
+ /**
196
+ * The spend limit rule, as the accounts service publishes and applies it: a whole number of cents from
197
+ * `controllerSpendLimitRange.min` to `.max`, in micro-dollars. A view checks a value with it before it offers the
198
+ * change, and the service refuses any other value with `invalid_input`.
199
+ */
200
+ export const isControllerSpendLimitMicroUsd = plugins.authswitchSpend.isAuthSwitchSpendLimitMicroUsd;
201
+ /** The range of a spend limit, in micro-dollars. */
202
+ export const controllerSpendLimitRange = plugins.authswitchSpend.authSwitchSpendLimitRange;
203
+
204
+ /** The longest API key the accounts service holds, in characters; a longer value is refused before it is sent. */
205
+ export const controllerApiKeyMaxLength = 512;
206
+
207
+ /**
208
+ * Adds an Anthropic or OpenAI API key as an account, with an optional label and monthly spend limit
209
+ * (`isControllerSpendLimitMicroUsd`). The accounts service asks the provider whether it accepts the key before it holds
210
+ * it, and answers why when it does not (`api_key_unverified`, `invalid_input`). The key is never answered, logged or
211
+ * audited; the account names it only by how it ends (`keyHint`). The answer arrives only once the controller's own
212
+ * projection shows the account; it can take up to 35 seconds, the provider's answer included.
213
+ */
214
+ export interface IReq_ControllerAccountsApiKeyAdd extends plugins.typedrequestInterfaces.implementsTR<
215
+ plugins.typedrequestInterfaces.ITypedRequest,
216
+ IReq_ControllerAccountsApiKeyAdd
217
+ > {
218
+ method: 'controller.accounts.apikey.add';
219
+ request: { providerId: TControllerApiKeyProvider; apiKey: string; label?: string; spendLimitMicroUsd?: number };
220
+ response: { answer: TControllerAccountsAnswer<{ account: TControllerAccount; loginId: string }> };
221
+ }
222
+
223
+ /**
224
+ * Sets an API key account's monthly spend limit, or clears it with `null`: at the limit AGL's inference proxy places
225
+ * nothing more on the key until the month ends (UTC). `expectedRevision` is the revision the view showed, so a change of
226
+ * an account that changed meanwhile is refused as `account_changed`, and a value that breaks the rule, or an account on
227
+ * a sign-in, as `invalid_input`, both by the service. The answer arrives only once the projection shows the result.
228
+ */
229
+ export interface IReq_ControllerAccountsSpendLimit extends plugins.typedrequestInterfaces.implementsTR<
230
+ plugins.typedrequestInterfaces.ITypedRequest,
231
+ IReq_ControllerAccountsSpendLimit
232
+ > {
233
+ method: 'controller.accounts.spendlimit';
234
+ request: { accountId: string; expectedRevision: number; spendLimitMicroUsd: number | null };
235
+ response: { answer: TControllerAccountsAnswer<TControllerAccount> };
236
+ }
237
+
190
238
  /** Duration validation shared with the authority; no implicit duration is supplied. */
191
239
  export const isControllerReserveLiftHours = plugins.authswitchAuthority.isAuthSwitchReserveLiftHours;
192
240
 
@@ -29,6 +29,8 @@ import type {
29
29
  IReq_ControllerAccountsRename,
30
30
  IReq_ControllerAccountsMaxUsage,
31
31
  IReq_ControllerAccountsReserveLift,
32
+ IReq_ControllerAccountsApiKeyAdd,
33
+ IReq_ControllerAccountsSpendLimit,
32
34
  IReq_ControllerAccountsSnapshot,
33
35
  IReq_ControllerAccountsUsageGet,
34
36
  IReq_ControllerAccountsUsageRecheck,
@@ -399,6 +401,8 @@ export type TControllerRequest =
399
401
  | IReq_ControllerAccountsRename
400
402
  | IReq_ControllerAccountsMaxUsage
401
403
  | IReq_ControllerAccountsReserveLift
404
+ | IReq_ControllerAccountsApiKeyAdd
405
+ | IReq_ControllerAccountsSpendLimit
402
406
  | IReq_ControllerAccountsRemove
403
407
  | IReq_ControllerAccountsAdd
404
408
  | IReq_ControllerAccountsReauth
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: 'agl',
6
- version: '40.4.0',
6
+ version: '40.5.0',
7
7
  description: 'Agent Gateway Layer for OpenCode, FlexHarness, and Codex sessions'
8
8
  }
@@ -11,5 +11,7 @@ import * as authswitchUsageWindows from '../ts_authswitch/authority-usagewindows
11
11
  import * as authswitchUsageLimit from '../ts_authswitch/authority-usagelimit.js';
12
12
  // A Claude account's subscription facts and payment problem, in the words the authswitch command line uses; types only behind it.
13
13
  import * as authswitchSubscription from '../ts_authswitch/authority-subscription.js';
14
+ // An API key's month: the spend limit rule and dollar amounts, as the command line states them; it imports nothing.
15
+ import * as authswitchSpend from '../ts_authswitch/authority-spend.js';
14
16
 
15
- export { authswitchPreuseText, authswitchSubscription, authswitchUsageLimit, authswitchUsageWindows };
17
+ export { authswitchPreuseText, authswitchSpend, authswitchSubscription, authswitchUsageLimit, authswitchUsageWindows };
@@ -1,10 +1,11 @@
1
1
  import * as plugins from './plugins.js';
2
- import type {
3
- IAccountActionSlot,
4
- IAccountFact,
5
- IAccountRow,
6
- IAccountsSummary,
7
- TAccountsTone,
2
+ import {
3
+ spendAttentionPercent,
4
+ type IAccountActionSlot,
5
+ type IAccountFact,
6
+ type IAccountRow,
7
+ type IAccountsSummary,
8
+ type TAccountsTone,
8
9
  } from './classes.accountsreadmodel.js';
9
10
 
10
11
  /**
@@ -95,7 +96,10 @@ export const renderSlots = (
95
96
  `;
96
97
  };
97
98
 
98
- /** An account as one line of the list: mark, name and who uses it, its tightest window, its one status. */
99
+ /**
100
+ * An account as one line of the list: mark, name and who uses it, its tightest window -- a key's month, which without a
101
+ * limit has its spend and no bar -- and its one status.
102
+ */
99
103
  export const renderListRow = (
100
104
  rowArg: IAccountRow,
101
105
  openArg: (rowArg: IAccountRow) => void,
@@ -113,8 +117,9 @@ export const renderListRow = (
113
117
  ${meter
114
118
  ? plugins.deesElement.html`
115
119
  <span class="meterText"><span>${meter.label}</span><span class="meterValue">${meter.valueText}</span></span>
116
- <dees-meter .size=${'sm'} .value=${meter.percent} .threshold=${meter.threshold} .tone=${meterTone(meter.tone)}
117
- .label=${meter.label} .valueText=${meter.valueText}></dees-meter>`
120
+ ${meter.percent === null ? '' : plugins.deesElement.html`<dees-meter .size=${'sm'} .value=${meter.percent}
121
+ .threshold=${meter.threshold} .tone=${meterTone(meter.tone)} .label=${meter.label}
122
+ .valueText=${meter.valueText}></dees-meter>`}`
118
123
  : plugins.deesElement.html`<span class="meterText"><span>No reading</span><span class="meterValue">—</span></span>`}
119
124
  </span>
120
125
  <dees-badge data-status .type=${badgeType(rowArg.pill.tone)} .text=${rowArg.pill.text}></dees-badge>
@@ -144,17 +149,21 @@ export const renderSummary = (summaryArg: IAccountsSummary): plugins.deesElement
144
149
  </div>
145
150
  `;
146
151
 
147
- /** An account's usage windows, each with its reset, ticked at its maximum; or why there is no reading. */
152
+ /**
153
+ * An account's usage windows, each with its reset, ticked at its maximum; or why there is no reading. A key's month
154
+ * wants attention from `spendAttentionPercent` of its limit, with no tick: a key has no maximum usage.
155
+ */
148
156
  export const renderUsageMeters = (rowArg: IAccountRow, emptyArg: string): plugins.deesElement.TemplateResult => {
149
157
  const meters = rowArg.usage?.meters ?? [];
150
158
  const threshold = rowArg.maxUsagePercent !== null && rowArg.maxUsagePercent < 100 ? rowArg.maxUsagePercent : null;
159
+ const attentionAt = rowArg.apiKey ? spendAttentionPercent : threshold;
151
160
  return plugins.deesElement.html`
152
161
  <mp-usage-meters class=${rowArg.usage?.current === false ? 'stale' : ''} data-usage-meters
153
162
  .windows=${meters.map((meterArg) => ({
154
163
  label: meterArg.label,
155
164
  percent: meterArg.percent,
156
165
  resetText: meterArg.resetText,
157
- tone: meterTone(meterArg.percent >= 100 ? 'neutral' : threshold !== null && meterArg.percent >= threshold
166
+ tone: meterTone(meterArg.percent >= 100 ? 'neutral' : attentionAt !== null && meterArg.percent >= attentionAt
158
167
  ? 'attention' : 'positive'),
159
168
  }))}
160
169
  .threshold=${threshold}