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
@@ -2,7 +2,9 @@ import type { IAuthSwitchAccount, IAuthSwitchClaudeHostLogin, IAuthSwitchClaudeH
2
2
  IAuthSwitchDoctorPage, IAuthSwitchUsageRecheck, IAuthSwitchUsageSnapshot, IAuthSwitchOperation, IAuthSwitchPreuseOperation, IAuthSwitchSnapshot, IReq_AuthSwitchDoctor,
3
3
  IReq_AuthSwitchAddApiKey, IReq_AuthSwitchRemoveAccount, IReq_AuthSwitchStartPreuse,
4
4
  IReq_AuthSwitchSwitchCodexNative, IReq_AuthSwitchReleaseCodexNative } from './authority-contract.js';
5
- import { asAuthSwitchRefusal, authSwitchClaudeSignInArguments, isAuthSwitchLapsesAt } from './authority-contract.js';
5
+ import { asAuthSwitchRefusal, authSwitchClaudeSignInArguments, isAuthSwitchLapsesAt,
6
+ type TAuthSwitchApiKeyProvider } from './authority-contract.js';
7
+ import { authSwitchParseUsd, authSwitchUsdText } from './authority-spend.js';
6
8
  import type { IAuthSwitchImportInventory, IAuthSwitchImportResult, IAuthSwitchImportStatus,
7
9
  IReq_AuthSwitchImportStatus, TAuthSwitchImportAction } from './authority-import-contract.js';
8
10
  import { AuthSwitchClient } from './classes.authorityclient.js';
@@ -13,7 +15,8 @@ import { describePreuseFailure, describePreuseLimit } from './preuse-text.js';
13
15
  import { plainText } from './formatting.js';
14
16
  import { authSwitchLimitList, authSwitchUsageLogin, readAuthSwitchAccountOverview, renderAuthSwitchAccountOverview,
15
17
  renderAuthSwitchLimitList } from './authority-overview.js';
16
- import { authSwitchPaymentProblemText, authSwitchRecheckOutcomeText } from './authority-subscription.js';
18
+ import { authSwitchApiKeyPaymentProblemText, authSwitchPaymentProblemText,
19
+ authSwitchRecheckOutcomeText } from './authority-subscription.js';
17
20
  import { localDateTime } from './timetext.js';
18
21
  import { authSwitchAccountLifecycle, lapseHorizonMs } from './authority-placement.js';
19
22
  import { authSwitchPackageIdentity, authSwitchRelease } from './packageidentity.js';
@@ -31,7 +34,8 @@ export interface IAuthSwitchAuthorityCliClient {
31
34
  importStatus(request: IReq_AuthSwitchImportStatus['request']): Promise<IAuthSwitchImportStatus>;
32
35
  submitImport(sourceId: string, sourceDigest: string): Promise<IAuthSwitchImportResult>;
33
36
  beginAddOpenAi(operationId: string): Promise<IAuthSwitchOperation>;
34
- addAnthropicApiKey(apiKey: string, label?: string): Promise<IReq_AuthSwitchAddApiKey['response']>;
37
+ addApiKey(providerId: TAuthSwitchApiKeyProvider, apiKey: string,
38
+ options: { label?: string; spendLimitMicroUsd?: number }): Promise<IReq_AuthSwitchAddApiKey['response']>;
35
39
  beginReauthOpenAi(operationId: string, accountId: string, loginId: string,
36
40
  purpose: 'openai_managed'): Promise<IAuthSwitchOperation>;
37
41
  getOperation(operationId: string): Promise<IAuthSwitchOperation>;
@@ -55,6 +59,8 @@ export interface IAuthSwitchAuthorityCliClient {
55
59
  setAccountMaxUsage(accountId: string, expectedRevision: number,
56
60
  maxUsagePercent: number | null): Promise<IAuthSwitchAccount>;
57
61
  setAccountLapse(accountId: string, expectedRevision: number, lapsesAt: string | null): Promise<IAuthSwitchAccount>;
62
+ setAccountSpendLimit(accountId: string, expectedRevision: number,
63
+ spendLimitMicroUsd: number | null): Promise<IAuthSwitchAccount>;
58
64
  close(reason?: Error): void;
59
65
  }
60
66
 
@@ -69,10 +75,10 @@ export interface IAuthSwitchAuthorityCliOptions {
69
75
  /** The Claude Code `account add claude` runs to sign in: `claude` as found on PATH unless stated. */
70
76
  claudeExecutable?: string;
71
77
  /**
72
- * Reads the key `account add anthropic-api-key` adds: by default from standard input, a line typed at a terminal
73
- * without echo or everything piped in. The signal aborts when the command is cancelled.
78
+ * Reads the key `account add anthropic-api-key|openai-api-key` adds: by default from standard input, a line typed at
79
+ * a terminal without echo or everything piped in. The signal aborts when the command is cancelled.
74
80
  */
75
- readApiKey?: (signal: AbortSignal) => Promise<string>;
81
+ readApiKey?: (signal: AbortSignal, providerId: TAuthSwitchApiKeyProvider) => Promise<string>;
76
82
  }
77
83
 
78
84
  type TAuthorityCliCommand =
@@ -85,7 +91,7 @@ type TAuthorityCliCommand =
85
91
  | { kind: 'importSubmit'; sourceId: string; sourceDigest: string; json: boolean }
86
92
  | { kind: 'add'; json: boolean }
87
93
  | { kind: 'addClaude'; json: boolean }
88
- | { kind: 'addApiKey'; label?: string; json: boolean }
94
+ | { kind: 'addApiKey'; providerId: TAuthSwitchApiKeyProvider; label?: string; spendLimitMicroUsd?: number; json: boolean }
89
95
  | { kind: 'useCodex'; accountId: string; json: boolean }
90
96
  | { kind: 'releaseCodex'; accountId: string; endRunningTurns: boolean; json: boolean }
91
97
  | { kind: 'releaseClaude'; accountId: string; endNativeSessions: boolean; json: boolean }
@@ -95,6 +101,7 @@ type TAuthorityCliCommand =
95
101
  | { kind: 'remove'; accountId: string; revoke: boolean; json: boolean }
96
102
  | { kind: 'setMaxUsage'; accountId: string; maxUsagePercent: number | null; json: boolean }
97
103
  | { kind: 'setLapse'; accountId: string; lapsesAt: string | null; json: boolean }
104
+ | { kind: 'setSpendLimit'; accountId: string; spendLimitMicroUsd: number | null; json: boolean }
98
105
  | { kind: 'get'; operationId: string; json: boolean }
99
106
  | { kind: 'cancel'; operationId: string; json: boolean }
100
107
  | { kind: 'resume'; operationId: string; json: boolean }
@@ -111,7 +118,7 @@ class AuthorityCliFailure extends Error {
111
118
  }
112
119
  }
113
120
 
114
- const usage = 'Usage: authswitch --help | authswitch --version [--json] | authswitch list [--json] | authswitch limits [--json] | authswitch account list [--json] | authswitch account add openai|claude [--json] | authswitch account add anthropic-api-key [--label <label>] [--json] | authswitch account use codex <account-id> [--json] | authswitch account release codex <account-id> [--end-running-turns] [--json] | authswitch account release claude <account-id> [--end-native-sessions] [--json] | authswitch account repair claude [--json] | authswitch account reauth <account-id> <login-id> [--json] | authswitch account recheck <account-id> [--json] | authswitch account remove <account-id> [--revoke] [--json] | authswitch account set <account-id> --max-usage <percent>|--clear|--lapses-at <date-time>|--clear-lapse [--json] | authswitch account operation get|cancel|resume <operation-id> [--json] | authswitch account preuse <account-id> <login-id>|--all [--prompt <text>] [--model <id>] [--json] | authswitch account preuse operation get|cancel|resume <operation-id> [--json] | authswitch authority doctor [--json] | authswitch authority import inventory [--json] | authswitch authority import status [--json] | authswitch authority import submit <source-id> --digest <source-digest> [--json] | authswitch authority service install|status|enable|start|stop | authswitch authority daemon';
121
+ const usage = 'Usage: authswitch --help | authswitch --version [--json] | authswitch list [--json] | authswitch limits [--json] | authswitch account list [--json] | authswitch account add openai|claude [--json] | authswitch account add anthropic-api-key|openai-api-key [--label <label>] [--spend-limit <dollars>] [--json] | authswitch account use codex <account-id> [--json] | authswitch account release codex <account-id> [--end-running-turns] [--json] | authswitch account release claude <account-id> [--end-native-sessions] [--json] | authswitch account repair claude [--json] | authswitch account reauth <account-id> <login-id> [--json] | authswitch account recheck <account-id> [--json] | authswitch account remove <account-id> [--revoke] [--json] | authswitch account set <account-id> --max-usage <percent>|--clear|--lapses-at <date-time>|--clear-lapse|--spend-limit <dollars>|--clear-spend-limit [--json] | authswitch account operation get|cancel|resume <operation-id> [--json] | authswitch account preuse <account-id> <login-id>|--all [--prompt <text>] [--model <id>] [--json] | authswitch account preuse operation get|cancel|resume <operation-id> [--json] | authswitch authority doctor [--json] | authswitch authority import inventory [--json] | authswitch authority import status [--json] | authswitch authority import submit <source-id> --digest <source-digest> [--json] | authswitch authority service install|status|enable|start|stop | authswitch authority daemon';
115
122
 
116
123
  /** Every command, one per line, with what it does; `authswitch --help` prints it. */
117
124
  const help = (): string => `authswitch ${authSwitchRelease.version}
@@ -126,9 +133,11 @@ Usage
126
133
  authswitch account add openai|claude [--json]
127
134
  sign in a new account: a ChatGPT device sign-in, or Claude
128
135
  Code's own sign-in on this terminal
129
- authswitch account add anthropic-api-key [--label <label>] [--json]
130
- add an Anthropic API key, read from standard input and never
131
- shown; AGL's proxy runs Flex's Claude models on it alone
136
+ authswitch account add anthropic-api-key|openai-api-key [--label <label>] [--spend-limit <dollars>] [--json]
137
+ add an Anthropic or OpenAI API key, read from standard input
138
+ and never shown, once its provider accepts it; AGL's proxy
139
+ relays requests on it at API prices, up to its monthly spend
140
+ limit in dollars when one is given
132
141
  authswitch account use codex <account-id> [--json]
133
142
  switch Codex on this host to that account
134
143
  authswitch account release codex <account-id> [--end-running-turns] [--json]
@@ -149,7 +158,8 @@ Usage
149
158
  authswitch account recheck <account-id> [--json]
150
159
  read a Claude account's profile and usage now; a healthy
151
160
  read clears a payment problem (a 402 billing error), so the
152
- AGL proxy routes to it again
161
+ AGL proxy routes to it again; on an API key it lifts the
162
+ hold, and the provider's next answer shows whether it holds
153
163
  authswitch account remove <account-id> [--revoke] [--json]
154
164
  remove an account; --revoke also revokes its ChatGPT sign-in
155
165
  authswitch account set <account-id> --max-usage <percent>|--clear [--json]
@@ -161,6 +171,11 @@ Usage
161
171
  new sessions on it only as a last resort in its last day,
162
172
  and from then on routes nothing to it and moves its
163
173
  sessions; --clear-lapse makes it active again
174
+ authswitch account set <account-id> --spend-limit <dollars>|--clear-spend-limit [--json]
175
+ an API key's monthly spend limit (calendar month, UTC) on
176
+ what AGL's proxy relayed on it: at the limit the proxy places
177
+ nothing more on the key until the month ends;
178
+ --clear-spend-limit removes it
164
179
  authswitch account operation get|cancel|resume <operation-id> [--json]
165
180
  follow a sign-in that is still running
166
181
  authswitch account preuse <account-id> <login-id>|--all [--prompt <text>] [--model <id>] [--json]
@@ -286,13 +301,27 @@ const parse = (argv: readonly string[]): TAuthorityCliCommand | null => {
286
301
  && (argv.length === 3 || (argv.length === 4 && argv[3] === '--json'))) {
287
302
  return { kind: argv[2] === 'openai' ? 'add' : 'addClaude', json: argv[3] === '--json' };
288
303
  }
289
- if (argv[0] === 'account' && argv[1] === 'add' && argv[2] === 'anthropic-api-key') {
304
+ if (argv[0] === 'account' && argv[1] === 'add' && (argv[2] === 'anthropic-api-key' || argv[2] === 'openai-api-key')) {
290
305
  // The key itself is never an argument: the shell history and the process list would keep it.
291
306
  const json = argv.at(-1) === '--json';
292
307
  const options = argv.slice(3, json ? -1 : undefined);
293
- if (options.length === 0) return { kind: 'addApiKey', json };
294
- if (options.length === 2 && options[0] === '--label') return { kind: 'addApiKey', label: options[1], json };
295
- return null;
308
+ const command: Extract<TAuthorityCliCommand, { kind: 'addApiKey' }> = { kind: 'addApiKey',
309
+ providerId: argv[2] === 'anthropic-api-key' ? 'anthropic' : 'openai', json };
310
+ for (let index = 0; index < options.length; index += 2) {
311
+ const value = options[index + 1];
312
+ if (value === undefined) return null;
313
+ if (options[index] === '--label' && command.label === undefined) {
314
+ command.label = value;
315
+ } else if (options[index] === '--spend-limit' && command.spendLimitMicroUsd === undefined) {
316
+ // The authority states the range; a value it refuses comes back as its instruction.
317
+ const spendLimitMicroUsd = authSwitchParseUsd(value);
318
+ if (spendLimitMicroUsd === null) return null;
319
+ command.spendLimitMicroUsd = spendLimitMicroUsd;
320
+ } else {
321
+ return null;
322
+ }
323
+ }
324
+ return command;
296
325
  }
297
326
  if (argv[0] === 'account' && argv[1] === 'use' && argv[2] === 'codex' && isId(argv[3])
298
327
  && (argv.length === 4 || (argv.length === 5 && argv[4] === '--json'))) {
@@ -350,6 +379,12 @@ const parse = (argv: readonly string[]): TAuthorityCliCommand | null => {
350
379
  if (options.length === 2 && options[0] === '--lapses-at' && isAuthSwitchLapsesAt(options[1])) {
351
380
  return { kind: 'setLapse', accountId: argv[2], lapsesAt: options[1], json };
352
381
  }
382
+ if (options.length === 1 && options[0] === '--clear-spend-limit') {
383
+ return { kind: 'setSpendLimit', accountId: argv[2], spendLimitMicroUsd: null, json };
384
+ }
385
+ if (options.length === 2 && options[0] === '--spend-limit' && authSwitchParseUsd(options[1]!) !== null) {
386
+ return { kind: 'setSpendLimit', accountId: argv[2], spendLimitMicroUsd: authSwitchParseUsd(options[1]!), json };
387
+ }
353
388
  return null;
354
389
  }
355
390
  if (argv[0] === 'account' && argv[1] === 'operation'
@@ -475,8 +510,14 @@ class ScopedAuthorityCliClient implements IAuthSwitchAuthorityCliClient {
475
510
  return this.client.setAccountLapse(accountId, expectedRevision, lapsesAt, this.controller.signal);
476
511
  }
477
512
 
478
- public addAnthropicApiKey(apiKey: string, label?: string): Promise<IReq_AuthSwitchAddApiKey['response']> {
479
- return this.client.addAnthropicApiKey(apiKey, label, this.controller.signal);
513
+ public addApiKey(providerId: TAuthSwitchApiKeyProvider, apiKey: string,
514
+ options: { label?: string; spendLimitMicroUsd?: number }): Promise<IReq_AuthSwitchAddApiKey['response']> {
515
+ return this.client.addApiKey(providerId, apiKey, options, this.controller.signal);
516
+ }
517
+
518
+ public setAccountSpendLimit(accountId: string, expectedRevision: number,
519
+ spendLimitMicroUsd: number | null): Promise<IAuthSwitchAccount> {
520
+ return this.client.setAccountSpendLimit(accountId, expectedRevision, spendLimitMicroUsd, this.controller.signal);
480
521
  }
481
522
 
482
523
  public close(reason = new Error('Authswitch authority CLI closed.')): void {
@@ -491,16 +532,26 @@ const revocationLines: Record<IReq_AuthSwitchRemoveAccount['response']['revoked'
491
532
  not_applicable: 'The authority held no ChatGPT sign-in of its own for it, so nothing was revoked.',
492
533
  };
493
534
 
535
+ /** An API key account's spend limit in words, as `account add` and `account set --spend-limit` state it. */
536
+ const spendLimitText = (account: IAuthSwitchAccount): string => account.spendLimitMicroUsd === null
537
+ ? 'no monthly spend limit'
538
+ : `monthly spend limit ${authSwitchUsdText(account.spendLimitMicroUsd)} (calendar month, UTC); at it AGL places `
539
+ + 'nothing more on the key until the month ends';
540
+
494
541
  const renderApiKeyAdded = (account: IAuthSwitchAccount): string =>
495
- `Added the Anthropic API key as account ${safe(account.label)} (${safe(account.id)}). AGL's inference proxy runs `
496
- + 'Flex\'s Claude models on it; Claude Code keeps its own sign-ins.\n';
542
+ `Added the ${account.providerId === 'openai' ? 'OpenAI' : 'Anthropic'} API key`
543
+ + `${account.keyHint === null ? '' : ` ending ${safe(account.keyHint)}`} as account ${safe(account.label)} `
544
+ + `(${safe(account.id)}): ${spendLimitText(account)}. AGL's inference proxy relays requests on it at API prices.\n`;
545
+
546
+ const renderSpendLimit = (account: IAuthSwitchAccount): string =>
547
+ `Account ${safe(account.label)} (${safe(account.id)}): ${spendLimitText(account)}.\n`;
497
548
 
498
549
  /**
499
550
  * The key `account add anthropic-api-key` adds, from standard input: a line typed at a terminal, which is not echoed
500
551
  * (a muted output for the line editor), or everything a pipe or file supplies. A cancelled command stops reading, so
501
552
  * neither an open pipe nor the terminal holds it.
502
553
  */
503
- const readStandardInputApiKey = async (signal: AbortSignal): Promise<string> => {
554
+ const readStandardInputApiKey = async (signal: AbortSignal, providerId: TAuthSwitchApiKeyProvider): Promise<string> => {
504
555
  signal.throwIfAborted();
505
556
  const input = process.stdin;
506
557
  if (!input.isTTY) {
@@ -516,7 +567,7 @@ const readStandardInputApiKey = async (signal: AbortSignal): Promise<string> =>
516
567
  for (const chunk of chunks) chunk.fill(0);
517
568
  }
518
569
  }
519
- process.stderr.write('Anthropic API key (not shown): ');
570
+ process.stderr.write(`${providerId === 'openai' ? 'OpenAI' : 'Anthropic'} API key (not shown): `);
520
571
  const muted = new plugins.stream.Writable({ write: (_chunk, _encoding, done) => done() });
521
572
  const lines = plugins.readline.createInterface({ input, output: muted, terminal: true });
522
573
  let cancel = (): void => undefined;
@@ -558,7 +609,9 @@ const renderLapse = (account: IAuthSwitchAccount, nowArg: number): string =>
558
609
  const renderRecheck = (account: IAuthSwitchAccount, recheck: IAuthSwitchUsageRecheck): string =>
559
610
  `Account ${safe(account.email ?? account.label)} (${safe(account.id)}): ${authSwitchRecheckOutcomeText(recheck.outcome)}\n`
560
611
  + (recheck.usage.paymentProblem
561
- ? `Payment problem since ${localDateTime(recheck.usage.paymentProblem.at)}: ${authSwitchPaymentProblemText}\n`
612
+ ? `Payment problem since ${localDateTime(recheck.usage.paymentProblem.at)}: ${account.organizationId === 'api-key'
613
+ ? authSwitchApiKeyPaymentProblemText(recheck.usage.paymentProblem, account.providerId)
614
+ : authSwitchPaymentProblemText}\n`
562
615
  : 'No payment problem is held; the AGL proxy routes to it as its usage allows.\n');
563
616
 
564
617
  const renderRemoval = (removal: IReq_AuthSwitchRemoveAccount['response']): string =>
@@ -880,14 +933,17 @@ const renderCodexRelease = (result: IReq_AuthSwitchReleaseCodexNative['response'
880
933
  };
881
934
 
882
935
  /**
883
- * Runs Claude Code's own sign-in in the home the authority made for it, on this terminal. With `--json` its output
884
- * goes to stderr, so stdout stays JSON Lines. Answers why it could not start, or null once it exited, with its code.
936
+ * Runs Claude Code's own sign-in in the home the authority made for it, on this terminal, with its credential store
937
+ * pinned there too: an inherited `CLAUDE_SECURESTORAGE_CONFIG_DIR` would write the new login into another store, over
938
+ * whatever login that one holds, while the sign-in home never gets it. With `--json` its output goes to stderr, so
939
+ * stdout stays JSON Lines. Answers why it could not start, or null once it exited, with its code.
885
940
  */
886
941
  const runClaudeSignIn = (executable: string, directory: string, json: boolean,
887
942
  signal: AbortSignal): Promise<{ started: false; reason: string } | { started: true; exitCode: number | null }> =>
888
943
  new Promise(resolve => {
889
944
  const child = plugins.childProcess.spawn(executable, [...authSwitchClaudeSignInArguments], {
890
- env: { ...process.env, CLAUDE_CONFIG_DIR: directory }, stdio: ['inherit', json ? 2 : 'inherit', 'inherit'],
945
+ env: { ...process.env, CLAUDE_CONFIG_DIR: directory, CLAUDE_SECURESTORAGE_CONFIG_DIR: directory },
946
+ stdio: ['inherit', json ? 2 : 'inherit', 'inherit'],
891
947
  });
892
948
  const stop = (): void => { child.kill('SIGTERM'); };
893
949
  signal.addEventListener('abort', stop, { once: true });
@@ -1379,8 +1435,9 @@ export const runAuthSwitchAuthorityCli = async (argv: readonly string[],
1379
1435
  await writeDiagnostic('No authority account has this id. Run `authswitch account list` for the ids.\n');
1380
1436
  return 1;
1381
1437
  }
1382
- if (account.providerId !== 'anthropic' || !login || login.purpose !== 'claude_host_native') {
1383
- await writeDiagnostic('Only a Claude account with a Claude sign-in is re-checked.\n');
1438
+ if (!login || !(login.purpose === 'anthropic_api_key' || login.purpose === 'openai_api_key'
1439
+ || (account.providerId === 'anthropic' && login.purpose === 'claude_host_native'))) {
1440
+ await writeDiagnostic('Only a Claude account with a Claude sign-in, or an API key, is re-checked.\n');
1384
1441
  return 1;
1385
1442
  }
1386
1443
  const recheck = await activeClient.recheckUsage(account.id, login.id);
@@ -1389,7 +1446,7 @@ export const runAuthSwitchAuthorityCli = async (argv: readonly string[],
1389
1446
  }
1390
1447
  await writeOutput(command.json ? JSON.stringify({ accountId: account.id, loginId: login.id, ...recheck }) + '\n'
1391
1448
  : renderRecheck(account, recheck));
1392
- return recheck.outcome === 'healthy' ? 0 : 1;
1449
+ return recheck.outcome === 'healthy' || recheck.outcome === 'cleared' ? 0 : 1;
1393
1450
  }
1394
1451
  if (command.kind === 'setMaxUsage') {
1395
1452
  const account = (await activeClient.snapshotAll()).accounts.find(item => item.id === command.accountId && !item.removed);
@@ -1418,10 +1475,29 @@ export const runAuthSwitchAuthorityCli = async (argv: readonly string[],
1418
1475
  await writeOutput(command.json ? JSON.stringify({ account: changed }) + '\n' : renderLapse(changed, now()));
1419
1476
  return 0;
1420
1477
  }
1478
+ if (command.kind === 'setSpendLimit') {
1479
+ const account = (await activeClient.snapshotAll()).accounts.find(item => item.id === command.accountId && !item.removed);
1480
+ if (!account) {
1481
+ await writeDiagnostic('No authority account has this id. Run `authswitch account list` for the ids.\n');
1482
+ return 1;
1483
+ }
1484
+ const changed = await activeClient.setAccountSpendLimit(account.id, account.revision, command.spendLimitMicroUsd);
1485
+ if (changed.id !== account.id || changed.spendLimitMicroUsd !== command.spendLimitMicroUsd) {
1486
+ throw new AuthorityCliFailure('response');
1487
+ }
1488
+ await writeOutput(command.json ? JSON.stringify({ account: changed }) + '\n' : renderSpendLimit(changed));
1489
+ return 0;
1490
+ }
1421
1491
  if (command.kind === 'addApiKey') {
1422
- const apiKey = (await withinDeadline(() => (options.readApiKey ?? readStandardInputApiKey)(operationController.signal))).trim();
1423
- const added = await activeClient.addAnthropicApiKey(apiKey, command.label);
1424
- if (added.account.providerId !== 'anthropic' || added.account.removed) throw new AuthorityCliFailure('response');
1492
+ const apiKey = (await withinDeadline(() => (options.readApiKey ?? readStandardInputApiKey)(operationController.signal,
1493
+ command.providerId))).trim();
1494
+ const added = await activeClient.addApiKey(command.providerId, apiKey, {
1495
+ ...(command.label === undefined ? {} : { label: command.label }),
1496
+ ...(command.spendLimitMicroUsd === undefined ? {} : { spendLimitMicroUsd: command.spendLimitMicroUsd }) });
1497
+ if (added.account.providerId !== command.providerId || added.account.removed
1498
+ || (command.spendLimitMicroUsd !== undefined && added.account.spendLimitMicroUsd !== command.spendLimitMicroUsd)) {
1499
+ throw new AuthorityCliFailure('response');
1500
+ }
1425
1501
  await writeOutput(command.json ? JSON.stringify(added) + '\n' : renderApiKeyAdded(added.account));
1426
1502
  return 0;
1427
1503
  }
@@ -10,6 +10,8 @@ import type {
10
10
  IReq_AuthSwitchRemoveAccount, IReq_AuthSwitchRenameAccount, IReq_AuthSwitchSetAccountLapse, IReq_AuthSwitchSetAccountMaxUsage, IReq_AuthSwitchLiftAccountReserve,
11
11
  IReq_AuthSwitchCancelPreuse, IReq_AuthSwitchGetPreuse, IReq_AuthSwitchSnapshot,
12
12
  IReq_AuthSwitchStartPreuse, IReq_AuthSwitchUsage, IReq_AuthSwitchObserveUsage, IReq_AuthSwitchReportPaymentProblem,
13
+ IReq_AuthSwitchRecordSpend, IAuthSwitchSpendAnswer, TAuthSwitchPaymentProblemCause, TAuthSwitchApiKeyProvider,
14
+ IReq_AuthSwitchSetAccountSpendLimit,
13
15
  IReq_AuthSwitchRecheckUsage, IReq_AuthSwitchMoveClaudeHomeIn, IReq_AuthSwitchMoveClaudeHomeOut,
14
16
  IReq_AuthSwitchRegisterClaudeHome, IReq_AuthSwitchResolveClaudeHome, IReq_AuthSwitchUnregisterClaudeHome,
15
17
  IReq_AuthSwitchCancelClaudeSignIn, IReq_AuthSwitchGetClaudeSignIn, IReq_AuthSwitchStartClaudeSignIn,
@@ -118,7 +120,8 @@ export class AuthSwitchDaemonOutdatedError extends Error {
118
120
  /**
119
121
  * A snapshot page carries every member of this release's contract. `schemaVersion` is not bumped for an
120
122
  * additive member; version 3 adds the gateway runtime, every account states its `maxUsagePercent` from 38.3.0
121
- * on, its `lapsesAt` from 38.4.0 on, and its `reserveLiftUntil` with reserve lifts. Consumers still check their supported version.
123
+ * on, its `lapsesAt` from 38.4.0 on, its `reserveLiftUntil` with reserve lifts, and its `spendLimitMicroUsd` and
124
+ * `keyHint` with API keys of both providers. Consumers still check their supported version.
122
125
  */
123
126
  const assertCurrentSnapshot = (snapshot: IAuthSwitchSnapshot): IAuthSwitchSnapshot => {
124
127
  const page: Partial<IAuthSwitchSnapshot> = snapshot;
@@ -126,7 +129,9 @@ const assertCurrentSnapshot = (snapshot: IAuthSwitchSnapshot): IAuthSwitchSnapsh
126
129
  || !Array.isArray(page.accounts)
127
130
  || page.accounts.some(account => (account as Partial<IAuthSwitchAccount>).maxUsagePercent === undefined
128
131
  || (account as Partial<IAuthSwitchAccount>).lapsesAt === undefined
129
- || (account as Partial<IAuthSwitchAccount>).reserveLiftUntil === undefined)) {
132
+ || (account as Partial<IAuthSwitchAccount>).reserveLiftUntil === undefined
133
+ || (account as Partial<IAuthSwitchAccount>).spendLimitMicroUsd === undefined
134
+ || (account as Partial<IAuthSwitchAccount>).keyHint === undefined)) {
130
135
  throw new AuthSwitchDaemonOutdatedError();
131
136
  }
132
137
  return snapshot;
@@ -239,14 +244,27 @@ export class AuthSwitchClient {
239
244
  }
240
245
 
241
246
  /**
242
- * Reports that Anthropic refused an inference request on the Claude login with a billing error (HTTP 402) at
243
- * `observedAt`, and returns the held usage, which then carries the payment problem (see
244
- * `IReq_AuthSwitchReportPaymentProblem`). An authority of 40.2.0 or older refuses the method.
247
+ * Reports that the provider refused an inference request on the login for money at `observedAt` -- Anthropic's
248
+ * billing error (HTTP 402) on a Claude login, or on an API key's login the refusal `options.cause` names -- and
249
+ * returns the held usage, which then carries the payment problem (see `IReq_AuthSwitchReportPaymentProblem`). An
250
+ * authority of 40.2.0 or older refuses the method, one of 40.3.0 a `cause`.
245
251
  */
246
252
  public async reportPaymentProblem(accountId: string, loginId: string, observedAt: string,
247
- signal?: AbortSignal): Promise<IReq_AuthSwitchReportPaymentProblem['response']['usage']> {
253
+ signal?: AbortSignal, options: { cause?: TAuthSwitchPaymentProblemCause } = {}): Promise<IReq_AuthSwitchReportPaymentProblem['response']['usage']> {
248
254
  return (await this.request<IReq_AuthSwitchReportPaymentProblem>('authswitch.authority.usage.paymentProblem',
249
- { accountId, loginId, observedAt }, 10_000, false, signal)).usage;
255
+ { accountId, loginId, observedAt, ...(options.cause === undefined ? {} : { cause: options.cause }) },
256
+ 10_000, false, signal)).usage;
257
+ }
258
+
259
+ /**
260
+ * Records what answers relayed on an API key's login cost (one to `authSwitchSpendAnswerBatchLimit`) and returns the
261
+ * key's usage once the batch committed (see `IReq_AuthSwitchRecordSpend`). An authority of 40.3.0 or older refuses
262
+ * the method.
263
+ */
264
+ public async recordSpend(accountId: string, loginId: string, answers: IAuthSwitchSpendAnswer[],
265
+ signal?: AbortSignal): Promise<IReq_AuthSwitchRecordSpend['response']['usage']> {
266
+ return (await this.request<IReq_AuthSwitchRecordSpend>('authswitch.authority.usage.spend',
267
+ { accountId, loginId, answers }, 10_000, false, signal)).usage;
250
268
  }
251
269
 
252
270
  /**
@@ -429,13 +447,19 @@ export class AuthSwitchClient {
429
447
  return (await this.request<IReq_AuthSwitchBeginAdd>('authswitch.authority.add',
430
448
  { operationId, providerId: 'openai', flow: 'device' }, 35_000, false, signal)).operation;
431
449
  }
432
- /** Adds an Anthropic API key as an account (see `IReq_AuthSwitchAddApiKey`); the key is not answered back. */
433
- public addAnthropicApiKey(apiKey: string, label?: string,
450
+ /**
451
+ * Adds an Anthropic or OpenAI API key as an account (see `IReq_AuthSwitchAddApiKey`); the key is not answered back.
452
+ * It waits up to 35 seconds: the authority gives the provider 15 seconds to answer whether it accepts the key.
453
+ */
454
+ public addApiKey(providerId: TAuthSwitchApiKeyProvider, apiKey: string,
455
+ options: { label?: string; spendLimitMicroUsd?: number } = {},
434
456
  signal?: AbortSignal): Promise<IReq_AuthSwitchAddApiKey['response']> {
435
457
  return this.request<IReq_AuthSwitchAddApiKey>('authswitch.authority.apikey.add',
436
- label === undefined ? { providerId: 'anthropic', apiKey } : { providerId: 'anthropic', apiKey, label },
458
+ { providerId, apiKey, ...(options.label === undefined ? {} : { label: options.label }),
459
+ ...(options.spendLimitMicroUsd === undefined ? {} : { spendLimitMicroUsd: options.spendLimitMicroUsd }) },
437
460
  35_000, false, signal);
438
461
  }
462
+
439
463
  public async beginReauthOpenAi(operationId: string, accountId: string, loginId: string,
440
464
  purpose: 'openai_managed', signal?: AbortSignal): Promise<IReq_AuthSwitchBeginReauth['response']['operation']> {
441
465
  return (await this.request<IReq_AuthSwitchBeginReauth>('authswitch.authority.reauth',
@@ -484,6 +508,13 @@ export class AuthSwitchClient {
484
508
  return (await this.request<IReq_AuthSwitchRenameAccount>('authswitch.authority.rename',
485
509
  { accountId, expectedRevision, label }, 35_000, false, signal)).account;
486
510
  }
511
+ /** Sets an API key account's monthly spend limit, or clears it with `null` (see `IReq_AuthSwitchSetAccountSpendLimit`). */
512
+ public async setAccountSpendLimit(accountId: string, expectedRevision: number, spendLimitMicroUsd: number | null,
513
+ signal?: AbortSignal): Promise<IReq_AuthSwitchSetAccountSpendLimit['response']['account']> {
514
+ return (await this.request<IReq_AuthSwitchSetAccountSpendLimit>('authswitch.authority.spendlimit',
515
+ { accountId, expectedRevision, spendLimitMicroUsd }, 10_000, false, signal)).account;
516
+ }
517
+
487
518
  /** Sets an account's usage threshold, or clears it with `null` (see `IReq_AuthSwitchSetAccountMaxUsage`). */
488
519
  public async setAccountMaxUsage(accountId: string, expectedRevision: number, maxUsagePercent: number | null,
489
520
  signal?: AbortSignal): Promise<IReq_AuthSwitchSetAccountMaxUsage['response']['account']> {
@@ -8,9 +8,9 @@ import type {
8
8
  IReq_AuthSwitchRepairClaudeHost,
9
9
  IReq_AuthSwitchEvents, IReq_AuthSwitchGetBinding, IReq_AuthSwitchGetOperation, IReq_AuthSwitchListOperations,
10
10
  IReq_AuthSwitchRemoveAccount,
11
- IReq_AuthSwitchRenameAccount, IReq_AuthSwitchSetAccountLapse, IReq_AuthSwitchSetAccountMaxUsage, IReq_AuthSwitchLiftAccountReserve, IReq_AuthSwitchSnapshot,
11
+ IReq_AuthSwitchRenameAccount, IReq_AuthSwitchSetAccountLapse, IReq_AuthSwitchSetAccountMaxUsage, IReq_AuthSwitchSetAccountSpendLimit, IReq_AuthSwitchLiftAccountReserve, IReq_AuthSwitchSnapshot,
12
12
  IReq_AuthSwitchCancelPreuse, IReq_AuthSwitchGetPreuse, IReq_AuthSwitchStartPreuse, IReq_AuthSwitchUsage,
13
- IReq_AuthSwitchObserveUsage, IReq_AuthSwitchReportPaymentProblem, IReq_AuthSwitchRecheckUsage,
13
+ IReq_AuthSwitchObserveUsage, IReq_AuthSwitchReportPaymentProblem, IReq_AuthSwitchRecheckUsage, IReq_AuthSwitchRecordSpend,
14
14
  IReq_AuthSwitchMoveClaudeHomeIn, IReq_AuthSwitchMoveClaudeHomeOut, IReq_AuthSwitchRegisterClaudeHome,
15
15
  IReq_AuthSwitchResolveClaudeHome, IReq_AuthSwitchUnregisterClaudeHome,
16
16
  IReq_AuthSwitchCancelClaudeSignIn, IReq_AuthSwitchGetClaudeSignIn, IReq_AuthSwitchStartClaudeSignIn,
@@ -20,14 +20,15 @@ import type {
20
20
  import type { IAuthSwitchManagedCodexRuntime, IReq_AuthSwitchBindAccount, IReq_AuthSwitchReleaseBinding,
21
21
  IReq_AuthSwitchResolveAccess, IReq_AuthSwitchStartManagedCodex, IReq_AuthSwitchStopManagedCodex,
22
22
  IReq_AuthSwitchUnbind } from './authority-runtime-contract.js';
23
- import { AuthSwitchRefusal } from './authority-contract.js';
23
+ import { AuthSwitchRefusal, authSwitchMalformedRequestText, isAuthSwitchPaymentProblemCause,
24
+ isAuthSwitchSpendAnswerBatch } from './authority-contract.js';
24
25
  import type { IReq_AuthSwitchImportInventory, IReq_AuthSwitchImportStatus, IReq_AuthSwitchImportSubmit,
25
26
  TAuthSwitchImportSubmission } from './authority-import-contract.js';
26
27
  import { AuthSwitchAuthorityImport, AuthSwitchGrantImport,
27
28
  type IAuthSwitchAuthorityImportOptions } from './classes.authorityimport.js';
28
29
  import { authSwitchBindingCapabilityHash, AuthSwitchAuthorityBroker,
29
30
  type IAuthSwitchAuthorityBrokerOptions } from './classes.authoritybroker.js';
30
- import { AuthSwitchAuthorityDatabase, type IAuthSwitchAuthorityDatabaseOptions } from './classes.authoritydatabase.js';
31
+ import { AuthSwitchAuthorityDatabase, authSwitchBindPurposes, type IAuthSwitchAuthorityDatabaseOptions } from './classes.authoritydatabase.js';
31
32
  import { ClaudeNativeAuthority, type IClaudeNativeAuthorityOptions } from './classes.claudeauthority.js';
32
33
  import { ClaudeHostCustody, publicClaudeHostRepair, publicClaudeNativeRelease } from './classes.claudehostcustody.js';
33
34
  import { ClaudeContainerAuthority, publicClaudeContainerHome,
@@ -124,9 +125,13 @@ function assertStatedLegacyImport(value: unknown): asserts value is TAuthSwitchA
124
125
  const hasKeys = (value: unknown, keys: readonly string[]): value is Record<string, unknown> =>
125
126
  value !== null && typeof value === 'object' && !Array.isArray(value)
126
127
  && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
128
+ /** Every one of `keys`, any of `optional`, and nothing else. */
129
+ const hasOptionalKeys = (value: unknown, keys: readonly string[], optional: readonly string[]): value is Record<string, unknown> =>
130
+ value !== null && typeof value === 'object' && !Array.isArray(value) && keys.every(key => Object.hasOwn(value, key))
131
+ && Object.keys(value).every(key => keys.includes(key) || optional.includes(key));
127
132
  // A malformed request is a caller's own bug, not an instruction for an owner, so it stays what it was:
128
133
  // its own text, no refusal marker. A client must not present it as something the owner can act on.
129
- const invalid = (): never => { throw new plugins.typedrequest.TypedResponseError('Invalid authswitch authority request.'); };
134
+ const invalid = (): never => { throw new plugins.typedrequest.TypedResponseError(authSwitchMalformedRequestText); };
130
135
  const validBindingId = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
131
136
  const validBindingCapability = (value: unknown): value is string => typeof value === 'string'
132
137
  && /^[A-Za-z0-9_-]{43}$/.test(value);
@@ -241,6 +246,14 @@ export class AuthSwitchAuthorityDaemon {
241
246
  return this.claudeNative.withNativeUsageSource(context, action);
242
247
  },
243
248
  claudeStatus: options.claudeStatus ?? new ClaudeAccountStatus(),
249
+ spend: {
250
+ read: (accountId, loginId, nowArg) => this.database.readSpend(accountId, loginId, nowArg),
251
+ record: async (accountId, loginId, answers, nowArg) => {
252
+ const recorded = await this.database.recordSpend(accountId, loginId, answers, nowArg);
253
+ if (recorded.changed) this.broker.notifyChanged();
254
+ return recorded.reading;
255
+ },
256
+ },
244
257
  store: {
245
258
  read: (accountId, loginId) => this.database.readUsage(accountId, loginId),
246
259
  publishIfCurrent: async (context, previousUpdateId, next) => {
@@ -421,11 +434,19 @@ export class AuthSwitchAuthorityDaemon {
421
434
  }));
422
435
  this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchReportPaymentProblem>(
423
436
  'authswitch.authority.usage.paymentProblem', async request => {
424
- if (!hasKeys(request, ['accountId', 'loginId', 'observedAt']) || !validHash(request.accountId)
437
+ if (!hasOptionalKeys(request, ['accountId', 'loginId', 'observedAt'], ['cause']) || !validHash(request.accountId)
425
438
  || !validHash(request.loginId) || typeof request.observedAt !== 'string'
426
439
  || !Number.isFinite(Date.parse(request.observedAt))
427
- || new Date(request.observedAt).toISOString() !== request.observedAt) invalid();
428
- return { usage: await this.usage.reportPaymentProblem(request.accountId, request.loginId, request.observedAt) };
440
+ || new Date(request.observedAt).toISOString() !== request.observedAt
441
+ || (request.cause !== undefined && !isAuthSwitchPaymentProblemCause(request.cause))) invalid();
442
+ return { usage: await this.usage.reportPaymentProblem(request.accountId, request.loginId, request.observedAt,
443
+ request.cause) };
444
+ }));
445
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchRecordSpend>(
446
+ 'authswitch.authority.usage.spend', async request => {
447
+ if (!hasKeys(request, ['accountId', 'loginId', 'answers']) || !validHash(request.accountId)
448
+ || !validHash(request.loginId) || !isAuthSwitchSpendAnswerBatch(request.answers)) invalid();
449
+ return { usage: await this.usage.recordSpend(request.accountId, request.loginId, request.answers) };
429
450
  }));
430
451
  this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchRecheckUsage>(
431
452
  'authswitch.authority.usage.recheck', async request => {
@@ -611,10 +632,11 @@ export class AuthSwitchAuthorityDaemon {
611
632
  }));
612
633
  this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchAddApiKey>(
613
634
  'authswitch.authority.apikey.add', async request => {
614
- if ((!hasKeys(request, ['providerId', 'apiKey']) && !hasKeys(request, ['providerId', 'apiKey', 'label']))
615
- || request.providerId !== 'anthropic' || typeof request.apiKey !== 'string'
616
- || (request.label !== undefined && typeof request.label !== 'string')) invalid();
617
- return this.broker.addAnthropicApiKey(request.apiKey, request.label);
635
+ if (!hasOptionalKeys(request, ['providerId', 'apiKey'], ['label', 'spendLimitMicroUsd'])
636
+ || (request.providerId !== 'anthropic' && request.providerId !== 'openai') || typeof request.apiKey !== 'string'
637
+ || (request.label !== undefined && typeof request.label !== 'string')
638
+ || (request.spendLimitMicroUsd !== undefined && typeof request.spendLimitMicroUsd !== 'number')) invalid();
639
+ return this.broker.addApiKey(request.providerId, request.apiKey, request.label, request.spendLimitMicroUsd);
618
640
  }));
619
641
  this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchBeginReauth>(
620
642
  'authswitch.authority.reauth', async request => {
@@ -703,6 +725,14 @@ export class AuthSwitchAuthorityDaemon {
703
725
  return { account: await this.broker.setAccountMaxUsage(request.accountId, request.expectedRevision,
704
726
  request.maxUsagePercent) };
705
727
  }));
728
+ this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchSetAccountSpendLimit>(
729
+ 'authswitch.authority.spendlimit', async request => {
730
+ if (!hasKeys(request, ['accountId', 'expectedRevision', 'spendLimitMicroUsd']) || typeof request.accountId !== 'string'
731
+ || typeof request.expectedRevision !== 'number'
732
+ || (request.spendLimitMicroUsd !== null && typeof request.spendLimitMicroUsd !== 'number')) invalid();
733
+ return { account: await this.broker.setAccountSpendLimit(request.accountId, request.expectedRevision,
734
+ request.spendLimitMicroUsd) };
735
+ }));
706
736
  this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchLiftAccountReserve>(
707
737
  'authswitch.authority.reserve.lift', async request => {
708
738
  if (!hasKeys(request, ['accountId', 'expectedRevision', 'hours']) || typeof request.accountId !== 'string'
@@ -743,7 +773,7 @@ export class AuthSwitchAuthorityDaemon {
743
773
  'authswitch.authority.bind', async request => {
744
774
  if (!hasKeys(request, ['accountId', 'loginId', 'purpose', 'runtime', 'scopeId', 'incarnationId'])
745
775
  || typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
746
- || !['openai_managed', 'claude_host_native', 'anthropic_api_key'].includes(request.purpose)
776
+ || !authSwitchBindPurposes.includes(request.purpose)
747
777
  || typeof request.scopeId !== 'string'
748
778
  || typeof request.incarnationId !== 'string') invalid();
749
779
  return this.broker.bindAccount(request);