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
@@ -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, authSwitchClaudeSignInHoldersText, 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 =>
@@ -759,6 +812,10 @@ const renderClaudeSignIn = (operation: IAuthSwitchClaudeSignInOperation, json: b
759
812
  if (json) return JSON.stringify(operation) + '\n';
760
813
  const lines = [`Claude sign-in ${safe(operation.id)} — ${safe(operation.state)}, revision ${operation.revision}`];
761
814
  if (operation.prompt) lines.push(` Claude Code signs in with: ${safe(operation.prompt.command)}`);
815
+ if (operation.heldBy) {
816
+ lines.push(' Claude Code signed in; the login is added once these Claude Code processes have ended: '
817
+ + safe(authSwitchClaudeSignInHoldersText(operation.heldBy)));
818
+ }
762
819
  if (operation.result) {
763
820
  lines.push(` signed in account=${safe(operation.result.accountId)}; login=${safe(operation.result.loginId)}`);
764
821
  }
@@ -880,14 +937,17 @@ const renderCodexRelease = (result: IReq_AuthSwitchReleaseCodexNative['response'
880
937
  };
881
938
 
882
939
  /**
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.
940
+ * Runs Claude Code's own sign-in in the home the authority made for it, on this terminal, with its credential store
941
+ * pinned there too: an inherited `CLAUDE_SECURESTORAGE_CONFIG_DIR` would write the new login into another store, over
942
+ * whatever login that one holds, while the sign-in home never gets it. With `--json` its output goes to stderr, so
943
+ * stdout stays JSON Lines. Answers why it could not start, or null once it exited, with its code.
885
944
  */
886
945
  const runClaudeSignIn = (executable: string, directory: string, json: boolean,
887
946
  signal: AbortSignal): Promise<{ started: false; reason: string } | { started: true; exitCode: number | null }> =>
888
947
  new Promise(resolve => {
889
948
  const child = plugins.childProcess.spawn(executable, [...authSwitchClaudeSignInArguments], {
890
- env: { ...process.env, CLAUDE_CONFIG_DIR: directory }, stdio: ['inherit', json ? 2 : 'inherit', 'inherit'],
949
+ env: { ...process.env, CLAUDE_CONFIG_DIR: directory, CLAUDE_SECURESTORAGE_CONFIG_DIR: directory },
950
+ stdio: ['inherit', json ? 2 : 'inherit', 'inherit'],
891
951
  });
892
952
  const stop = (): void => { child.kill('SIGTERM'); };
893
953
  signal.addEventListener('abort', stop, { once: true });
@@ -1379,8 +1439,9 @@ export const runAuthSwitchAuthorityCli = async (argv: readonly string[],
1379
1439
  await writeDiagnostic('No authority account has this id. Run `authswitch account list` for the ids.\n');
1380
1440
  return 1;
1381
1441
  }
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');
1442
+ if (!login || !(login.purpose === 'anthropic_api_key' || login.purpose === 'openai_api_key'
1443
+ || (account.providerId === 'anthropic' && login.purpose === 'claude_host_native'))) {
1444
+ await writeDiagnostic('Only a Claude account with a Claude sign-in, or an API key, is re-checked.\n');
1384
1445
  return 1;
1385
1446
  }
1386
1447
  const recheck = await activeClient.recheckUsage(account.id, login.id);
@@ -1389,7 +1450,7 @@ export const runAuthSwitchAuthorityCli = async (argv: readonly string[],
1389
1450
  }
1390
1451
  await writeOutput(command.json ? JSON.stringify({ accountId: account.id, loginId: login.id, ...recheck }) + '\n'
1391
1452
  : renderRecheck(account, recheck));
1392
- return recheck.outcome === 'healthy' ? 0 : 1;
1453
+ return recheck.outcome === 'healthy' || recheck.outcome === 'cleared' ? 0 : 1;
1393
1454
  }
1394
1455
  if (command.kind === 'setMaxUsage') {
1395
1456
  const account = (await activeClient.snapshotAll()).accounts.find(item => item.id === command.accountId && !item.removed);
@@ -1418,10 +1479,29 @@ export const runAuthSwitchAuthorityCli = async (argv: readonly string[],
1418
1479
  await writeOutput(command.json ? JSON.stringify({ account: changed }) + '\n' : renderLapse(changed, now()));
1419
1480
  return 0;
1420
1481
  }
1482
+ if (command.kind === 'setSpendLimit') {
1483
+ const account = (await activeClient.snapshotAll()).accounts.find(item => item.id === command.accountId && !item.removed);
1484
+ if (!account) {
1485
+ await writeDiagnostic('No authority account has this id. Run `authswitch account list` for the ids.\n');
1486
+ return 1;
1487
+ }
1488
+ const changed = await activeClient.setAccountSpendLimit(account.id, account.revision, command.spendLimitMicroUsd);
1489
+ if (changed.id !== account.id || changed.spendLimitMicroUsd !== command.spendLimitMicroUsd) {
1490
+ throw new AuthorityCliFailure('response');
1491
+ }
1492
+ await writeOutput(command.json ? JSON.stringify({ account: changed }) + '\n' : renderSpendLimit(changed));
1493
+ return 0;
1494
+ }
1421
1495
  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');
1496
+ const apiKey = (await withinDeadline(() => (options.readApiKey ?? readStandardInputApiKey)(operationController.signal,
1497
+ command.providerId))).trim();
1498
+ const added = await activeClient.addApiKey(command.providerId, apiKey, {
1499
+ ...(command.label === undefined ? {} : { label: command.label }),
1500
+ ...(command.spendLimitMicroUsd === undefined ? {} : { spendLimitMicroUsd: command.spendLimitMicroUsd }) });
1501
+ if (added.account.providerId !== command.providerId || added.account.removed
1502
+ || (command.spendLimitMicroUsd !== undefined && added.account.spendLimitMicroUsd !== command.spendLimitMicroUsd)) {
1503
+ throw new AuthorityCliFailure('response');
1504
+ }
1425
1505
  await writeOutput(command.json ? JSON.stringify(added) + '\n' : renderApiKeyAdded(added.account));
1426
1506
  return 0;
1427
1507
  }
@@ -10,9 +10,12 @@ 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
- IReq_AuthSwitchCancelClaudeSignIn, IReq_AuthSwitchGetClaudeSignIn, IReq_AuthSwitchStartClaudeSignIn,
17
+ IAuthSwitchClaudeSignInOperation, IReq_AuthSwitchCancelClaudeSignIn, IReq_AuthSwitchGetClaudeSignIn,
18
+ IReq_AuthSwitchStartClaudeSignIn,
16
19
  IReq_AuthSwitchCodexNativeHandoff, IReq_AuthSwitchCodexNativeHandoffs, IReq_AuthSwitchSwitchCodexNative,
17
20
  IReq_AuthSwitchReleaseCodexNative,
18
21
  } from './authority-contract.js';
@@ -118,7 +121,8 @@ export class AuthSwitchDaemonOutdatedError extends Error {
118
121
  /**
119
122
  * A snapshot page carries every member of this release's contract. `schemaVersion` is not bumped for an
120
123
  * 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.
124
+ * on, its `lapsesAt` from 38.4.0 on, its `reserveLiftUntil` with reserve lifts, and its `spendLimitMicroUsd` and
125
+ * `keyHint` with API keys of both providers. Consumers still check their supported version.
122
126
  */
123
127
  const assertCurrentSnapshot = (snapshot: IAuthSwitchSnapshot): IAuthSwitchSnapshot => {
124
128
  const page: Partial<IAuthSwitchSnapshot> = snapshot;
@@ -126,12 +130,25 @@ const assertCurrentSnapshot = (snapshot: IAuthSwitchSnapshot): IAuthSwitchSnapsh
126
130
  || !Array.isArray(page.accounts)
127
131
  || page.accounts.some(account => (account as Partial<IAuthSwitchAccount>).maxUsagePercent === undefined
128
132
  || (account as Partial<IAuthSwitchAccount>).lapsesAt === undefined
129
- || (account as Partial<IAuthSwitchAccount>).reserveLiftUntil === undefined)) {
133
+ || (account as Partial<IAuthSwitchAccount>).reserveLiftUntil === undefined
134
+ || (account as Partial<IAuthSwitchAccount>).spendLimitMicroUsd === undefined
135
+ || (account as Partial<IAuthSwitchAccount>).keyHint === undefined)) {
130
136
  throw new AuthSwitchDaemonOutdatedError();
131
137
  }
132
138
  return snapshot;
133
139
  };
134
140
 
141
+ /**
142
+ * A Claude sign-in carries every member of this release's contract; one without `heldBy`, which names the processes
143
+ * holding a sign-in, comes from a daemon older than this client.
144
+ */
145
+ const assertCurrentClaudeSignIn = (operation: IAuthSwitchClaudeSignInOperation): IAuthSwitchClaudeSignInOperation => {
146
+ if ((operation as Partial<IAuthSwitchClaudeSignInOperation>).heldBy === undefined) {
147
+ throw new AuthSwitchDaemonOutdatedError();
148
+ }
149
+ return operation;
150
+ };
151
+
135
152
  /** A snapshot is usable only while state is current; lastVerifiedAt is the last successful daemon proof. */
136
153
  export interface IAuthSwitchSubscriptionStatus {
137
154
  state: 'current' | 'unavailable' | 'closed';
@@ -239,14 +256,27 @@ export class AuthSwitchClient {
239
256
  }
240
257
 
241
258
  /**
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.
259
+ * Reports that the provider refused an inference request on the login for money at `observedAt` -- Anthropic's
260
+ * billing error (HTTP 402) on a Claude login, or on an API key's login the refusal `options.cause` names -- and
261
+ * returns the held usage, which then carries the payment problem (see `IReq_AuthSwitchReportPaymentProblem`). An
262
+ * authority of 40.2.0 or older refuses the method, one of 40.3.0 a `cause`.
245
263
  */
246
264
  public async reportPaymentProblem(accountId: string, loginId: string, observedAt: string,
247
- signal?: AbortSignal): Promise<IReq_AuthSwitchReportPaymentProblem['response']['usage']> {
265
+ signal?: AbortSignal, options: { cause?: TAuthSwitchPaymentProblemCause } = {}): Promise<IReq_AuthSwitchReportPaymentProblem['response']['usage']> {
248
266
  return (await this.request<IReq_AuthSwitchReportPaymentProblem>('authswitch.authority.usage.paymentProblem',
249
- { accountId, loginId, observedAt }, 10_000, false, signal)).usage;
267
+ { accountId, loginId, observedAt, ...(options.cause === undefined ? {} : { cause: options.cause }) },
268
+ 10_000, false, signal)).usage;
269
+ }
270
+
271
+ /**
272
+ * Records what answers relayed on an API key's login cost (one to `authSwitchSpendAnswerBatchLimit`) and returns the
273
+ * key's usage once the batch committed (see `IReq_AuthSwitchRecordSpend`). An authority of 40.3.0 or older refuses
274
+ * the method.
275
+ */
276
+ public async recordSpend(accountId: string, loginId: string, answers: IAuthSwitchSpendAnswer[],
277
+ signal?: AbortSignal): Promise<IReq_AuthSwitchRecordSpend['response']['usage']> {
278
+ return (await this.request<IReq_AuthSwitchRecordSpend>('authswitch.authority.usage.spend',
279
+ { accountId, loginId, answers }, 10_000, false, signal)).usage;
250
280
  }
251
281
 
252
282
  /**
@@ -429,13 +459,19 @@ export class AuthSwitchClient {
429
459
  return (await this.request<IReq_AuthSwitchBeginAdd>('authswitch.authority.add',
430
460
  { operationId, providerId: 'openai', flow: 'device' }, 35_000, false, signal)).operation;
431
461
  }
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,
462
+ /**
463
+ * Adds an Anthropic or OpenAI API key as an account (see `IReq_AuthSwitchAddApiKey`); the key is not answered back.
464
+ * It waits up to 35 seconds: the authority gives the provider 15 seconds to answer whether it accepts the key.
465
+ */
466
+ public addApiKey(providerId: TAuthSwitchApiKeyProvider, apiKey: string,
467
+ options: { label?: string; spendLimitMicroUsd?: number } = {},
434
468
  signal?: AbortSignal): Promise<IReq_AuthSwitchAddApiKey['response']> {
435
469
  return this.request<IReq_AuthSwitchAddApiKey>('authswitch.authority.apikey.add',
436
- label === undefined ? { providerId: 'anthropic', apiKey } : { providerId: 'anthropic', apiKey, label },
470
+ { providerId, apiKey, ...(options.label === undefined ? {} : { label: options.label }),
471
+ ...(options.spendLimitMicroUsd === undefined ? {} : { spendLimitMicroUsd: options.spendLimitMicroUsd }) },
437
472
  35_000, false, signal);
438
473
  }
474
+
439
475
  public async beginReauthOpenAi(operationId: string, accountId: string, loginId: string,
440
476
  purpose: 'openai_managed', signal?: AbortSignal): Promise<IReq_AuthSwitchBeginReauth['response']['operation']> {
441
477
  return (await this.request<IReq_AuthSwitchBeginReauth>('authswitch.authority.reauth',
@@ -484,6 +520,13 @@ export class AuthSwitchClient {
484
520
  return (await this.request<IReq_AuthSwitchRenameAccount>('authswitch.authority.rename',
485
521
  { accountId, expectedRevision, label }, 35_000, false, signal)).account;
486
522
  }
523
+ /** Sets an API key account's monthly spend limit, or clears it with `null` (see `IReq_AuthSwitchSetAccountSpendLimit`). */
524
+ public async setAccountSpendLimit(accountId: string, expectedRevision: number, spendLimitMicroUsd: number | null,
525
+ signal?: AbortSignal): Promise<IReq_AuthSwitchSetAccountSpendLimit['response']['account']> {
526
+ return (await this.request<IReq_AuthSwitchSetAccountSpendLimit>('authswitch.authority.spendlimit',
527
+ { accountId, expectedRevision, spendLimitMicroUsd }, 10_000, false, signal)).account;
528
+ }
529
+
487
530
  /** Sets an account's usage threshold, or clears it with `null` (see `IReq_AuthSwitchSetAccountMaxUsage`). */
488
531
  public async setAccountMaxUsage(accountId: string, expectedRevision: number, maxUsagePercent: number | null,
489
532
  signal?: AbortSignal): Promise<IReq_AuthSwitchSetAccountMaxUsage['response']['account']> {
@@ -597,13 +640,15 @@ export class AuthSwitchClient {
597
640
  /** Starts a Claude sign-in; see `IAuthSwitchClaudeSignInOperation`. The same operation id answers the same sign-in. */
598
641
  public async startClaudeSignIn(operationId: string,
599
642
  signal?: AbortSignal): Promise<IReq_AuthSwitchStartClaudeSignIn['response']['operation']> {
600
- return (await this.request<IReq_AuthSwitchStartClaudeSignIn>('authswitch.authority.claude.signin.start',
601
- { operationId }, 35_000, false, signal)).operation;
643
+ return assertCurrentClaudeSignIn((await this.request<IReq_AuthSwitchStartClaudeSignIn>(
644
+ 'authswitch.authority.claude.signin.start', { operationId }, 35_000, false,
645
+ signal)).operation);
602
646
  }
603
647
  public async getClaudeSignIn(operationId: string,
604
648
  signal?: AbortSignal): Promise<IReq_AuthSwitchGetClaudeSignIn['response']['operation']> {
605
- return (await this.request<IReq_AuthSwitchGetClaudeSignIn>('authswitch.authority.claude.signin.operation',
606
- { operationId }, 35_000, false, signal)).operation;
649
+ return assertCurrentClaudeSignIn((await this.request<IReq_AuthSwitchGetClaudeSignIn>(
650
+ 'authswitch.authority.claude.signin.operation', { operationId }, 35_000, false,
651
+ signal)).operation);
607
652
  }
608
653
  /**
609
654
  * Waits up to `waitMs` for the Claude sign-in to change past `afterRevision`, as `watchOperation` does for a device
@@ -611,13 +656,15 @@ export class AuthSwitchClient {
611
656
  */
612
657
  public async watchClaudeSignIn(operationId: string, afterRevision: number, waitMs = 30_000,
613
658
  signal?: AbortSignal): Promise<IReq_AuthSwitchGetClaudeSignIn['response']['operation']> {
614
- return (await this.request<IReq_AuthSwitchGetClaudeSignIn>('authswitch.authority.claude.signin.operation',
615
- { operationId, afterRevision, waitMs }, waitMs + 5_000, false, signal)).operation;
659
+ return assertCurrentClaudeSignIn((await this.request<IReq_AuthSwitchGetClaudeSignIn>(
660
+ 'authswitch.authority.claude.signin.operation', { operationId, afterRevision, waitMs }, waitMs + 5_000, false,
661
+ signal)).operation);
616
662
  }
617
663
  public async cancelClaudeSignIn(operationId: string,
618
664
  signal?: AbortSignal): Promise<IReq_AuthSwitchCancelClaudeSignIn['response']['operation']> {
619
- return (await this.request<IReq_AuthSwitchCancelClaudeSignIn>('authswitch.authority.claude.signin.cancel',
620
- { operationId }, 35_000, false, signal)).operation;
665
+ return assertCurrentClaudeSignIn((await this.request<IReq_AuthSwitchCancelClaudeSignIn>(
666
+ 'authswitch.authority.claude.signin.cancel', { operationId }, 35_000, false,
667
+ signal)).operation);
621
668
  }
622
669
  /** Registers a Claude home the caller created for a container; see `IReq_AuthSwitchRegisterClaudeHome`. */
623
670
  public async registerClaudeHome(input: Omit<IReq_AuthSwitchRegisterClaudeHome['request'], 'runtime'>,
@@ -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);