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
package/readme.md CHANGED
@@ -167,7 +167,7 @@ The default ports are:
167
167
 
168
168
  Settings uses a section menu for **General**, **Session Intelligence**, **Configuration**, **Projects**, **Accounts**, and **Codex connections**. The modal keeps its size while each section scrolls independently of the heading and actions; navigation moves above the content on phones. General drafts and the Accounts screen's state survive section changes. The footer's **Accounts** entry opens Settings at Accounts. The theme button just left of **legal** switches the whole UI between the bright and the dark theme and back to the system's colour scheme; its glyph and tooltip say where it goes (a sun, a moon, or a monitor for **Follow the system theme**, offered once the picked theme is the one the system shows). Until it is used the UI follows the system's colour scheme, and once it is, the browser keeps the theme picked for the next visit.
169
169
 
170
- The accounts service (authswitch's authority daemon) is the one place accounts and their logins live; Flex, Codex, OpenCode and Claude Code only use its accounts. **Settings → Accounts** is one list of its accounts, grouped under ChatGPT and Claude, one row per account. A row names the account, its email and plan, and one status in plain words: **Ready**; **Needs sign-in** or **Needs a fresh sign-in**, with **Sign in again** on the row; **Renewal retrying**, with the next attempt; **Refreshed by the Codex app**, **Refreshed by Claude Code** or **Refreshed by OpenCode** for a login that tool renews itself, with where to sign it in again when it needs that; and, while the accounts service is not current, the service's state (**Accounts service not running**, **Accounts service needs a restart**, …) on each of its rows. A chip names each harness that uses the account, and where: **Flex · agl, website** for the projects whose Flex conversations run on it, **Codex · AGL** for an account whose own Codex app-server serves AGL projects set to it, **Claude Code · AGL terminals** for the account new Claude terminals run on, **Claude Code · agl** for a project whose Claude terminals still run on it, and **Codex · this host** or **Claude Code · this host** for the tool whose own sign-in on this host runs on it. A harness that does not use the account has no chip. The account's usage follows as one meter per window, with the reset in local date and time, and **As of 14:02** for when the provider last stated it; a window the accounts service estimated since its last read is marked **(est.)**, and a quiet note says when the service's last read was refused by the provider for too many requests, with the time of the next one. The screen asks the accounts service for what it holds when it opens and again with **Refresh**; it never makes the service read the provider. An account that cannot take work carries a second badge beside its status, judged by the rule the proxy and Flex place by: **Reserved (80% of weekly used)** once a window reached the account's maximum usage, **At its limit (weekly)** once one is used up, each with when it takes work again.
170
+ The accounts service (authswitch's authority daemon) is the one place accounts and their logins live; Flex, Codex, OpenCode and Claude Code only use its accounts. **Settings → Accounts** is one list of its accounts, grouped under ChatGPT, Claude, OpenAI API keys and Anthropic API keys, one row per account. A row names the account, its email and plan, and one status in plain words: **Ready**; **Needs sign-in** or **Needs a fresh sign-in**, with **Sign in again** on the row; **Renewal retrying**, with the next attempt; **Refreshed by the Codex app**, **Refreshed by Claude Code** or **Refreshed by OpenCode** for a login that tool renews itself, with where to sign it in again when it needs that; and, while the accounts service is not current, the service's state (**Accounts service not running**, **Accounts service needs a restart**, …) on each of its rows. A chip names each harness that uses the account, and where: **Flex · agl, website** for the projects whose Flex conversations run on it, **Codex · AGL** for an account whose own Codex app-server serves AGL projects set to it, **Claude Code · AGL terminals** for the account new Claude terminals run on, **Claude Code · agl** for a project whose Claude terminals still run on it, and **Codex · this host** or **Claude Code · this host** for the tool whose own sign-in on this host runs on it. A harness that does not use the account has no chip. The account's usage follows as one meter per window, with the reset in local date and time, and **As of 14:02** for when the provider last stated it; a window the accounts service estimated since its last read is marked **(est.)**, and a quiet note says when the service's last read was refused by the provider for too many requests, with the time of the next one. The screen asks the accounts service for what it holds when it opens and again with **Refresh**; it never makes the service read the provider. An account that cannot take work carries a second badge beside its status, judged by the rule the proxy and Flex place by: **Reserved (80% of weekly used)** once a window reached the account's maximum usage, **At its limit (weekly)** once one is used up, each with when it takes work again.
171
171
 
172
172
  An account may keep part of each usage window for use outside AGL -- to reserve 20% for Claude Design, its maximum usage is 80% (**Max usage** in its Details, or `authswitch account set <account-id> --max-usage 80`). Once any window of the account reaches it, the account is reserved, treated as at its limit until that window resets: the proxy places no new session on it and moves the sessions on it at their next request, and Flex holds a prompt for it. Unset, an account is judged on the windows its limit has always been read from; set, every window counts, a single model's as well, for the whole account. An account whose provider reports no usage has no window to reach it. **Lift reserve** in the account's Details opens a modal with a required choice of whole hours from **1 to 24**. The accounts service saves the expiry while keeping the maximum usage unchanged. Until that time, AGL may use the reserved share; the row shows **Reserve lifted until** with the expiry in local date and time. Refreshing or reopening Accounts reads the same lift. At the expiry, placement and usage reads automatically apply the saved maximum again, including after a browser, controller, proxy or accounts-service restart. The lift never bypasses a provider's exhausted window or refusal, a 429 cooldown, an ended or invalid sign-in, a lapsed subscription, or account permissions. The protected request is `controller.accounts.reserve.lift { accountId, expectedRevision, hours }`, audited as `accounts.reserve.lift`; concurrent account edits are refused as `account_changed`.
173
173
 
@@ -184,7 +184,7 @@ Flex uses every OpenAI account the service holds with a login of its own: each i
184
184
 
185
185
  A Flex session on a ChatGPT account runs through AGL's inference proxy, on the controller's lease of that one account's pool (proxy client `flex`, policy `accountIds: [<accountId>]`): the account the session chose serves it, and the proxy authenticates as the account, applies its maximum usage and limits, and counts every answer's tokens in the [token usage](#token-usage) under the client `flex`. At the account's limit the run reports the limit and its reset; it never moves to another account. The account's model catalog is read by the proxy on that account too. The Flex child is given only the proxy's address and the lease credential, never an account token. When the proxy is not running, a Flex request on a ChatGPT account fails with `INFERENCE_PROXY_UNAVAILABLE`; there is no direct route to ChatGPT. An account whose access ChatGPT keeps rejecting, or the accounts service cannot renew, fails with `PROVIDER_REAUTH_REQUIRED`, repaired by signing it in again in **Settings → Accounts**. Removing an account ends the controller's lease of it at once.
186
186
 
187
- Flex runs Claude models through AGL's inference proxy and only on an Anthropic API key: the proxy's Claude sign-ins serve Claude Code alone, never Flex. `authswitch account add anthropic-api-key [--label <label>]` adds a key: it reads the key from standard input (a line typed at a terminal is not echoed), has Anthropic confirm it, and keeps it sealed in the accounts service like every other credential, never printed again; each key is an account of its own, shown in **Settings → Accounts** with the login **Anthropic API key for Flex**. While a key serves in the proxy, the keys are one Flex connection, `flex:proxy:anthropic` (**Anthropic API keys**), whose models are the ones every key offers -- Opus, Sonnet and Haiku 5.5, each with the efforts it states as its variants -- read through the proxy and refreshed when a key joins, leaves or is added again. A Flex session on one runs on the controller's lease of the proxy's API-key pool (proxy client `flex`): the proxy places it on the key with the fewest requests in flight, with the in-flight limits and placements of every session it serves, and moves it off a key Anthropic rejects or answers with a 429 until that key's cooldown ends. The accounts service reads no usage for a key, so a maximum usage has no effect on it and a key's spend shows only in the Claude Console. A key Anthropic rejects needs a fresh key (add it again), and fails the request with that reason. With no key added, a Flex request for a Claude model is refused with `Flex runs Claude models only on an Anthropic API key, and none is added`; only the host's [driver session](#driver-session) falls back to a model that is not Claude.
187
+ Flex runs Claude models through AGL's inference proxy and only on an Anthropic API key: the proxy's Claude sign-ins serve Claude Code alone, never Flex. **Add account → Anthropic API key** in **Settings → Accounts**, or `authswitch account add anthropic-api-key [--label <label>] [--spend-limit <dollars>]`, adds a key (see [API keys and their spend limit](#api-keys-and-their-spend-limit)); each key is an account of its own, listed under **Anthropic API keys** with the login **Anthropic API key**. While a key serves in the proxy, the keys are one Flex connection, `flex:proxy:anthropic` (**Anthropic API keys**), whose models are the ones every key offers -- Opus, Sonnet and Haiku 5.5, each with the efforts it states as its variants -- read through the proxy and refreshed when a key joins, leaves or is added again. A Flex session on one runs on the controller's lease of the proxy's API-key pool (proxy client `flex`): the proxy places it on the key with the most of its monthly spend limit left for the requests in flight on it, the keys without a limit after those with one and the least busy of them first, with the in-flight limits and placements of every session it serves, and moves it off a key Anthropic rejects, answers with a 429 until that key's cooldown ends, or that reached its spend limit. A key's usage is its month's spend as the proxy reported it, against its spend limit; a maximum usage does not apply to a key. A key Anthropic rejects needs a fresh key (add it again), and fails the request with that reason. With no key added, a Flex request for a Claude model is refused with `Flex runs Claude models only on an Anthropic API key, and none is added`; only the host's [driver session](#driver-session) falls back to a model that is not Claude.
188
188
 
189
189
  A ChatGPT account whose login the accounts service renews offers **Use for Codex** on its row: it switches Codex's own sign-in on this host to the account (`authswitch.authority.codex.switch`), so the conversations of this host's Codex -- AGL's and every other client's -- continue on it. It asks first, saying what changes: Codex restarts for the switch, so Codex turns running now end, in AGL and everywhere else on this host, and existing conversations stay and continue on the chosen account. While the accounts service stops Codex's app-server, writes the account's sign-in, starts it again and has Codex confirm which account it runs on, which can take a few minutes, the row says so and the screen's other actions wait; then the screen says how the switch settled: done, undone with the reason -- Codex reported an API key or another login its environment or configuration selects, its app-server did not come back -- or held until it is resolved, with **Read diagnostics** to see what holds it. The account Codex runs on carries **Codex · this host** and offers **Hand to AGL** instead (see below). The service switches only a Codex sign-in it manages and only to a login it renews; a row says which is missing, and while a switch is under way or could not be settled, the others wait. A Claude account and a removed account are not offered it. See [Codex on an account of the accounts service](#codex-on-an-account-of-the-accounts-service).
190
190
 
@@ -198,6 +198,8 @@ Codex renews the login of the account it runs on, and a login has one renewer, s
198
198
 
199
199
  **Claude Code on this host**, below **New Claude terminals**, says what Claude Code's own home on this host holds (`controller.accounts.claude.host.get`, the service's `claude.host` read) and offers the one action it calls for, each confirmed with what it does first. Claude Code is not switched between accounts: once AGL Configuration routes it through the AGL proxy, its own sign-in is handed to AGL with **Hand to AGL** (`controller.accounts.claude.release { operationId, accountId, loginId, endNativeSessions }`, naming the account and sign-in the read named) -- the sign-in an earlier AGL adopted moves into the service's custody, and a sign-in of an account the service already renews is signed out of Claude Code's files, which keep everything else. Before Claude Code is routed the hand-over waits and **Open Inference settings** opens AGL Configuration on its Inference section. A Claude Code process that started before the routing may still use the sign-in: the screen names it by pid and start, and **Hand to AGL and end their sign-in** hands it over anyway, after saying those processes fail at their next request until restarted. A Claude Code process on a release whose credential handling AGL has not verified (2.1.280 to 2.1.282, 2.1.288, the one AGL's own Claude chats run through the Agent SDK, and 2.1.306 are; a newer release, a remote agent's build such as `2.1.295-8b2928c330c4` or another Agent SDK's build is not yet) blocks the hand-over, also when its sign-in would be ended: the screen names each such process by pid, start and release, to be closed or restarted on a verified release first. A process counts as running on the home when its credential store is there, also when `CLAUDE_SECURESTORAGE_CONFIG_DIR` puts it there from another configuration directory; one configured on the home whose store is elsewhere blocks the hand-over too. A sign-in an earlier AGL adopted that can no longer be handed over -- the service holds it after a hand-over or switch it could not settle, or Claude Code's files now hold another account, none, or no readable one -- is **Repair**ed instead (`controller.accounts.claude.repair { operationId }`): the service forgets it, leaves Claude Code's files as they are, and the adopted account needs a new sign-in unless a renewal the held hand-over kept is confirmed by the provider. The browser names each hand-over and repair by an operation id of its own, so one whose answer is lost is read again by it: a hand-over with `controller.accounts.claude.release.get`, a repair by sending it again with the same id, which the service answers with the repair it committed; both are recorded in AGL's audit trail (`accounts.claude.release`, `accounts.claude.repair`). While Claude Code renews an adopted sign-in in its routed home, its usage is not read, and its row says so.
200
200
 
201
+ **Add account** also offers **OpenAI API key** and **Anthropic API key**, which add a key as an account of its own; see [API keys and their spend limit](#api-keys-and-their-spend-limit).
202
+
201
203
  **Add ChatGPT account** starts a device sign-in in the accounts service, and **Sign in again** starts one for a login the service renews. The sign-in waits in the list -- above it for a new account, in the account's row otherwise -- with where to confirm it and the code, and can be cancelled there; once it ends the screen says how, and a new account appears in the list. One new-account sign-in runs at a time.
202
204
 
203
205
  A Claude account Anthropic refused with a billing error carries a **Payment problem** badge beside its sign-in status, with a line saying when it was seen and that AGL routes nothing to it until a usage read shows its subscription changed or a re-check reads it healthy (see [Payment problems](#payment-problems)); **Re-check** sits next to **Show latest usage** in its Details while the problem is held (`controller.accounts.usage.recheck { accountId, loginId }`, a read, not audited), and the screen says what the read found. A Claude account's Details also list what its profile states of the subscription: **Subscription status**, the value as received followed by what it means or `(not described yet)` -- no value is described yet -- and **Subscription created**, as received. AGL derives no renewal or cancellation date from either.
@@ -208,12 +210,99 @@ The accounts service's own panel sits collapsed at the top, headed by its status
208
210
 
209
211
  Nothing on the screen polls: it reads again when the controller announces a change, after its own actions, and when asked. When the service is not installed or not running, the screen names that state and what it means for running work instead of showing an empty list; when it runs an account-service protocol older than AGL requires — it was not restarted after an upgrade — the screen names the restart that repairs it (`authswitch authority service stop`, then `authswitch authority service start`) and connects again by itself once it is done; when it stopped answering, the last values it delivered stay on screen, dimmed and timestamped.
210
212
 
211
- The accounts service owns provider sign-in, the switches of this host's Codex sign-in and the custody of Claude Code's own sign-in on this host. In a terminal, `authswitch account add openai|claude` signs an account in, `authswitch account add anthropic-api-key` adds an Anthropic API key for Flex, `authswitch account use codex <account-id>` switches this host's Codex to it, `authswitch account release codex <account-id> [--end-running-turns]` hands the sign-in this host's Codex runs on to the service and leaves Codex on no account (once Codex runs through the AGL proxy), `authswitch account release claude <account-id>` hands Claude Code's own sign-in to the service, `authswitch account repair claude` forgets an adopted home that can no longer be handed over, and `authswitch list` shows where each account is used. AGL's own passkey authentication remains separate. AGL runs no account manager of its own; it is only a client of the accounts service. Remote Codex connections keep their own account and are not changed by this host's accounts service.
213
+ The accounts service owns provider sign-in, the switches of this host's Codex sign-in and the custody of Claude Code's own sign-in on this host. In a terminal, `authswitch account add openai|claude` signs an account in, `authswitch account add openai-api-key|anthropic-api-key [--label <label>] [--spend-limit <dollars>]` adds an API key, `authswitch account set <account-id> --spend-limit <dollars>|--clear-spend-limit` sets or clears its monthly spend limit, `authswitch account use codex <account-id>` switches this host's Codex to it, `authswitch account release codex <account-id> [--end-running-turns]` hands the sign-in this host's Codex runs on to the service and leaves Codex on no account (once Codex runs through the AGL proxy), `authswitch account release claude <account-id>` hands Claude Code's own sign-in to the service, `authswitch account repair claude` forgets an adopted home that can no longer be handed over, and `authswitch list` shows where each account is used. AGL's own passkey authentication remains separate. AGL runs no account manager of its own; it is only a client of the accounts service. Remote Codex connections keep their own account and are not changed by this host's accounts service.
214
+
215
+ ### API keys and their spend limit
216
+
217
+ An OpenAI or Anthropic API key is an account of its own in the accounts service, beside the ChatGPT and Claude sign-ins.
218
+ **Add account** in **Settings → Accounts** offers **OpenAI API key** and **Anthropic API key**; the key's dialog asks
219
+ for the key (masked, with no autofill, never written into the page), an optional name and an optional monthly spend
220
+ limit in dollars; `authswitch account add openai-api-key|anthropic-api-key [--label <label>] [--spend-limit <dollars>]`
221
+ does the same in a terminal and reads the key from standard input (a line typed at a terminal is not echoed), never from
222
+ an argument. A key that begins the way the other provider's keys do is refused before anything is sent. The accounts
223
+ service then asks the provider for the models the key may use and keeps nothing unless the provider accepts it; while it
224
+ asks, the dialog's fields are locked and it says so. A rejected key, an OpenAI key that may not read models (a
225
+ restricted key needs read access to models) and a provider that cannot be asked are refused in the dialog with what to
226
+ do. An accepted key is sealed like every other credential and never shown again: the dialog closes, the screen says the
227
+ spend limit the account has, which is the one it had when a removed key is added again without a new one, and the key's
228
+ row, under **OpenAI API keys** or **Anthropic API keys**, reads **OpenAI API key ending …abcd**. A key is one account:
229
+ adding a held key again is refused, adding a removed or rejected one brings its account back. **Remove** does not revoke
230
+ the key at the provider, as its confirmation says; revoke it in the OpenAI platform or the Claude Console. A key its
231
+ provider no longer accepts reads **Key rejected** and needs a new key: add one, then remove the old account.
232
+
233
+ ```sh
234
+ authswitch account add openai-api-key --label "Codex key" --spend-limit 50 # asks for the key, not echoed
235
+ authswitch account set <account-id> --spend-limit 80
236
+ authswitch account set <account-id> --clear-spend-limit
237
+ authswitch account recheck <account-id> # lift a key's payment hold
238
+ ```
239
+
240
+ AGL's inference proxy relays requests on keys when no subscription can take them (see [Claude accounts first, then API
241
+ keys](#claude-accounts-first-then-api-keys) and [ChatGPT accounts first, then OpenAI API
242
+ keys](#chatgpt-accounts-first-then-openai-api-keys)), and Flex's Claude models run on the Anthropic keys alone. A key has no
243
+ maximum usage and no reserve to lift; its limit is its spend. **Spend limit** under Limits on the key's page (its own
244
+ **Save**, or **No limit**), or `authswitch account set <account-id> --spend-limit <dollars>` (`--clear-spend-limit`
245
+ removes it), sets what AGL's own traffic on the key may cost in a calendar month (UTC), a whole number of cents from
246
+ $0.01 to $1,000,000. The proxy prices each answer it
247
+ relays on a key once, as the answer ends, with the model registry's price table (FlexHarness): by the model that
248
+ answered and what its usage states -- input, cache reads and writes, output, service tier, Fast mode, a region's
249
+ surcharge and server-tool calls such as web searches -- never by the request. An answer whose stream the client cuts
250
+ counts the usage it stated by then; Anthropic may bill more for it, and the Claude Console's own limit is the backstop.
251
+ The answer counts against the key's limit from then on: the proxy writes it to its own database at once and reports it
252
+ to the accounts service, which records it in the key's monthly ledger, and until the accounts service has acknowledged
253
+ it the proxy judges the key on the ledger's reading plus the answers it still holds, those of the current UTC month
254
+ only, across a restart of the proxy too. At the limit the proxy places nothing more on the key until 00:00 UTC on the first of
255
+ the next month, and its sessions move to another account; answers already running finish, so a month can end over the
256
+ limit by what the answers in flight at that moment cost. An answer the proxy could not price stops a key with a limit
257
+ until the month ends or the limit is removed, and goes to the proxy's journal. So a key with a limit is sent the request
258
+ as the price check read it, encoded again with `service_tier: "standard_only"` (Priority Tier has no published price),
259
+ and takes no request the price table cannot price -- a model it does not list, another service tier,
260
+ a speed or `inference_geo` without a price, a server tool whose exact type has no price (code execution, the advisor,
261
+ an undated tool search alias or the MCP connector; web search, web fetch and the dated tool search tools are priced): such
262
+ a request goes to an account that can take it, else it is refused with HTTP 400
263
+ `invalid_request_error` naming what has no price and the fix. A key without a limit takes it, and its answer counts as
264
+ one that could not be priced. A key with a limit whose spend the accounts service cannot read takes nothing until it
265
+ can; a key without a limit keeps serving. While the accounts service cannot be read, the proxy judges a key on the limit
266
+ of the last reading it holds, so a limit lowered meanwhile applies at the next successful read. If the accounts service
267
+ refuses the proxy's report of a key's answers, the proxy keeps them and counts them, says so in its journal and sends
268
+ them again every ten minutes and at every start, never dropping them; meanwhile a key with a limit takes nothing. So it
269
+ does while an answer is held in neither the proxy's database, which could not write it, nor the accounts service's
270
+ ledger. A key's answers are reported on the login they were served on, so a key removed before they were reported still
271
+ has them in its month when it is added again; an answer for which no login of the key can be named still counts and
272
+ holds a key with a limit until the accounts service names one. The limit counts AGL's traffic only, never the key's use elsewhere, and the
273
+ provider's own monthly limit (the OpenAI platform's, the Claude Console's) stays the backstop: set AGL's limit below it.
274
+ A key's month survives its removal.
275
+
276
+ A key's row in the list shows its month as its meter, in dollars (without a limit its spend alone), and the key's page a
277
+ **Monthly spend** meter with a line such as **$12.40 of $50.00 this month · AGL traffic only · as of 14:02**, or **Spent
278
+ $12.40 this month · no limit · as of 14:02** without a limit, with how many answers could not be priced. Both turn to
279
+ the attention colour from 80% of the limit; at it the row's status reads **At spend limit · back …**, grey like any
280
+ limit and counted as held back by usage (**Unpriced answer · back …** for an answer that could not be priced), and the
281
+ page says **At its spend limit, resets …**. The overview counts the keys together (**1 ChatGPT · 2 API keys**). The
282
+ figure is the one the accounts service held when the screen last read it, at the time it names. The screen reads it
283
+ again on **Refresh**, on **Show latest usage** and whenever the controller announces the account as changed: at a new
284
+ month's first spend, when the limit changes and when the key reaches or leaves its limit. Nothing polls, so between
285
+ those the figure, and the 80% tone, are those of the last reading. `authswitch list` shows the same month in dollars.
286
+
287
+ A key its provider refuses for billing or spend is held with a payment problem that says why (see [Payment
288
+ problems](#payment-problems)): a billing error, the provider's monthly spend cap (lifted at the start of the next
289
+ month), or a used-up credit or spend limit, its row's status **Spend cap reached** for the cap. **Re-check** on its page
290
+ (`authswitch account recheck <account-id>`) lifts the hold at once, a spend cap's too, without asking the provider; if
291
+ the provider still refuses, the next request moves to another account before a byte reaches the client and the hold is
292
+ set again.
293
+
294
+ The first start of this release's accounts service creates the spend ledger and gives every account its spend limit and
295
+ key hint. There is no downgrade past this release except restoring the accounts service's store
296
+ (`~/.local/share/authswitch/authority`, or `$XDG_DATA_HOME/authswitch/authority`) from a backup taken before the upgrade:
297
+ an older accounts service refuses to start on it.
212
298
 
213
299
  ## Inference proxy
214
300
 
215
301
  `agl proxy` routes inference over the accounts held by AGL's internal account authority: ChatGPT accounts
216
- for Codex, OpenCode and other OpenAI clients, and Claude accounts for Claude Code. The authority must be
302
+ for Codex, OpenCode and other OpenAI clients, whose Responses requests fall back to the OpenAI API keys when no ChatGPT
303
+ account can take one (see [ChatGPT accounts first, then OpenAI API keys](#chatgpt-accounts-first-then-openai-api-keys)),
304
+ and Claude accounts for Claude Code, which falls back to the Anthropic API
305
+ keys when no Claude account can take a request (see [Claude accounts first, then API keys](#claude-accounts-first-then-api-keys)). The authority must be
217
306
  running and signed in; the controller does not need to be running. The requested model stays the same.
218
307
  Eligibility comes from each account's live model catalog, current usage and sign-in ownership. A plan
219
308
  label alone is not proof of an active subscription. Accounts whose usage is stale, whose sign-in needs
@@ -279,10 +368,19 @@ find it -- naming as of when, whether its
279
368
  newest values came from provider answers or the account service's own read, whether a window is estimated since, and
280
369
  whether the service's last read was rate-limited -- with its lifecycle (canceled with its lapse date, or lapsed), its
281
370
  placement score and its rank for the next new session (`--json`: `lifecycle`, `lapsesAt`, `placementScore`,
282
- `placementRank`, `lastResort`; an Anthropic API key shows as available, with no usage read for it; an account a
283
- request would refuse for a reason of its own shows as unavailable with that reason, `unavailable`, and takes no new
284
- session), the subscription
285
- account each pool's next new client session goes to (`nextPlacement`), the latest 20
371
+ `placementRank`, `lastResort`; an OpenAI or Anthropic API key shows its month in place of a maximum usage, as of when
372
+ the account service read its spend: `available ($12.40 of $50.00 this month)` with what is left of its limit as its
373
+ headroom and placement score, `available ($3.20 this month, no spend limit)` with neither, `at its spend limit` (or
374
+ `held until its month ends`, for an answer that could not be priced) with the month's end -- the month counts the
375
+ answers the accounts service has not recorded yet -- unavailable with `unavailable.reason: spend_unread` while it has a
376
+ limit and its spend could not be read, or `spend_unrecorded` while it has a limit and spend relayed on it is recorded
377
+ nowhere yet (a refused report, an answer neither the proxy's database nor the accounts service took, or an answer for
378
+ which no login of the key could be named to report it on), cooling down
379
+ after a 429, or held with a payment problem (the proxy's own or the account service's while it
380
+ holds), which shows it unavailable with `unavailable.cause` (`billing`, `spend_cap` or `quota`) and, for a spend cap,
381
+ `retryAt`; an account a request would refuse for a reason of its own shows as unavailable with that reason,
382
+ `unavailable`, and takes no new session; a provider's Claude or ChatGPT accounts rank before its API keys), the account each provider's next new
383
+ session of a client that names no credential goes to (`nextPlacement`), the latest 20
286
384
  sessions placed or moved with why (`recentPlacements`), the count of usage reports handed to the account service and
287
385
  of hand-overs that failed, and exits non-zero while the proxy is down.
288
386
 
@@ -371,9 +469,11 @@ every scored one. Within the client's pool, only current quota exhaustion, a def
371
469
  forwarding output, the account lapsing or leaving the pool, or a Claude account taking no request for a reason of its
372
470
  own (below) permits moving the session to another eligible account.
373
471
  The proxy records every session it places and every move, with why (`new`, `exhausted`, `reserved`, `cooling`,
374
- `provider_429`, `left_pool`, `outside_restriction`, `lapsed`, `unavailable`), in its own database for 30 days; a failed record is
472
+ `provider_429`, `left_pool`, `outside_restriction`, `lapsed`, `unavailable`, `returned`), in its own database for 30 days; a failed record is
375
473
  reported on its standard error and never fails the request. The replacement stays selected until it
376
- also reaches its limit; an earlier account recovering does not pull the session back. An account
474
+ also reaches its limit; an earlier account recovering does not pull the session back, except that a session on an
475
+ Anthropic API key returns to a Claude account (below). An earlier AGL's `agl proxy status` fails while a `returned`
476
+ placement is among the latest 20; routing is unaffected. An account
377
477
  restriction is the operator's choice: a session whose account `--account` leaves out moves the same way to
378
478
  an eligible account of the restriction, and is refused only when none of them can serve it. Plan filter changes,
379
479
  transport failures, 5xx responses and Anthropic's 529 overload leave the mapping unchanged and return an error; so do a
@@ -382,8 +482,9 @@ carry no state bound to an account, moves instead (`unavailable`) when its accou
382
482
  no usage reading to route on (`usage_unread`), no model list (`catalog_failed`, with the HTTP status or failure), a
383
483
  sign-in to renew (`needs_login`), no access from the account service (`access_failed`), a model list without the
384
484
  requested model (`model_missing`), or a payment problem (`payment_problem`, see [Payment problems](#payment-problems));
385
- the refusal of a session that cannot move names the account and the reason. Limits
386
- and cool-downs are judged before an account's model list is read, so an account at its limit is never read for a
485
+ the refusal of a session that cannot move names the account and the reason. When it moves and no other account takes
486
+ the request, it gets the 429 only while another account is at its limit; otherwise the 503 for want of an account (the
487
+ 404 when its account lacked the model, the 402 for a payment problem). Limits and cool-downs are judged before an account's model list is read, so an account at its limit is never read for a
387
488
  request: its last model list says whether it would serve the model, and without one it counts as at its limit. A model
388
489
  list is read again once it is a minute old; a model-list read that fails keeps the account on the list read before, and
389
490
  with none the account is unavailable. A sign-in or access failure, met reading the list or resolving the chosen
@@ -403,6 +504,74 @@ with the earliest time one takes work again ("All eligible accounts are at their
403
504
  answers it even at its limit -- not with a payment problem -- and the session never moves for it. Each request tries at most the 64 accounts
404
505
  allowed in its pool.
405
506
 
507
+ #### Claude accounts first, then API keys
508
+
509
+ A Claude pool whose client names no credential -- Claude Code's own lease, `agl proxy run claude` and the `claude`
510
+ client -- is served by its Claude accounts first and by the Anthropic API keys only when no Claude account can take a
511
+ request, for whatever reason: every one at its limit or reserve, cooling down, without a usage reading, model list or
512
+ access, or with a payment problem. A new session then goes to the key with the most of its monthly spend limit left
513
+ for the requests in flight on it, the keys without a limit after those with one and the least busy of them first, and
514
+ a session on a Claude account moves to one at its next request. A session on a key returns to a Claude account at the
515
+ next request one can take (`returned` in the placement journal), with no waiting period; Claude Code's request reaches
516
+ the key unchanged, as it reaches an account (thinking signatures carry; the prompt cache starts cold). `count_tokens` never moves a session. A key at its
517
+ monthly spend limit takes nothing until the month ends, and a key with a limit whose spend could not be read takes
518
+ nothing until it can (see [API keys and their spend limit](#api-keys-and-their-spend-limit)). When every Claude account
519
+ is at its limit and every key is cooling down after a provider 429 or at its spend limit, Claude Code gets the 429 with
520
+ the earliest time any of them takes work again, naming each key at its spend limit with its month in dollars; a 503
521
+ for want of an account names the keys whose spend could not be read or recorded, and a 400 `invalid_request_error`
522
+ names what of a request a key with a limit could not price, when nothing else takes it. The
523
+ pool's models (`/anthropic/v1/models`) are those of its best tier with an account whose model list could be read: the
524
+ Claude accounts' while one of them answers its list, else the keys'. A pool that names its credential keeps to it:
525
+ Flex's API-key pool never takes a Claude account, a pool of the sign-ins alone (`credential: 'subscription'`) never
526
+ takes a key, and a lease with a plan restriction never reaches a key, which has no plan.
527
+
528
+ #### ChatGPT accounts first, then OpenAI API keys
529
+
530
+ A ChatGPT pool whose client names no credential -- Codex's, OpenCode's and `agl proxy run codex|opencode`'s -- is served
531
+ by its ChatGPT accounts first and by the OpenAI API keys only when no ChatGPT account can take a request, as a Claude
532
+ pool falls back to its keys; a pool of keys alone (`credential: 'api_key'`, or no ChatGPT account at all) is served by
533
+ its keys. A key takes Responses requests (`/v1/responses`) alone: a translated Messages request (Claude Code on ChatGPT
534
+ models) never goes to a key, and the Codex catalog (`/v1/models?client_version=…`) is the ChatGPT accounts' only, so on
535
+ a pool of keys alone Codex answers 503 there and runs on the catalog it bundles. The proxy's own model list
536
+ (`/v1/models`, and an `agl proxy run` lease) is the ChatGPT accounts' while one of them answers its catalog, else the
537
+ models every key lists (OpenAI's `GET /v1/models`, read again after a minute) that the model registry describes.
538
+
539
+ A request goes to OpenAI's API (`https://api.openai.com/v1/responses`) on the key's own credential, as the client sent
540
+ it with only its `model` rewritten: no ChatGPT, organization or project header, and a Codex turn token only to the
541
+ account that issued it. A key with a monthly spend limit is sent the request as the price check read it, encoded again
542
+ with the service tier it was checked for -- `default` (Standard) when the client sent `auto` or none, else the tier it
543
+ named if the model has a price for it (`flex`, `priority`, `fast`; `scale` has none) -- and takes no request the price
544
+ table cannot price: a model or dated snapshot it does not list, a background response (`background: true`), or a server
545
+ tool whose exact type has no call fee (`web_search_preview`, a dated web search, code interpreter, image generation, a
546
+ hosted MCP server, hosted tool search, a shell in OpenAI's container). Tools the client runs itself cost tokens only:
547
+ functions, custom tools, the local shell, apply patch, computer use, and tool search the client runs
548
+ (`execution: "client"`, as Codex sends it); web search and file search are priced per call. Such a request goes to an
549
+ account that can take it, else it is refused with HTTP 400 `model_unpriced` naming what has no price. A key without a
550
+ limit takes it.
551
+
552
+ An answer is priced as it ends by the model and the service tier OpenAI states for it, with every `web_search_call` and
553
+ `file_search_call` item one call whatever its action -- so AGL's figure can exceed OpenAI's bill for searches OpenAI does
554
+ not charge -- and a completed, incomplete or failed answer is priced on the usage it states. An answer as a model the
555
+ price table does not list, a dated snapshot included, one with no service tier, and one whose final event is too large
556
+ to read (over 8 MiB) cannot be priced: it holds a key with a limit until the month ends, and the proxy's journal names
557
+ the model. An answer that ends without OpenAI stating its usage -- the client left, the stream was cut, or the response
558
+ failed stating none -- is not counted at all and no usage is estimated for it; the journal says so for each one. So AGL's figure can be below OpenAI's
559
+ bill, and the OpenAI project's own budget is the backstop: OpenAI's 429 `project_spend_limit_exceeded` (or any quota
560
+ refusal) holds the key with a payment problem and its sessions move.
561
+
562
+ A session on a key moves to another account when the key reaches its limit or OpenAI refuses it for its quota, as a
563
+ session on a ChatGPT account at its limit does: its conversation state stays behind (`x-agl-account-state-reset`). A key
564
+ that takes no request for a reason of its own -- its spend could not be read or recorded, its model list could not be
565
+ read, the accounts service answered no access for it -- refuses the session's request with a 503 instead, since the
566
+ session's state is bound to it; a new session goes to the next key. A session on a key
567
+ returns to a ChatGPT account at a request one can take, but only while the request carries no account state (no
568
+ reference, encrypted content or item id, which a Codex conversation carries from its second turn on). A key OpenAI
569
+ rejects (401) is ended: the request gets a 503
570
+ `account_unauthorized` saying to add a valid key and remove the rejected one. A key OpenAI refuses with 403 during a request
571
+ (it lacks a permission that request needs) answers that request with 403 `provider_forbidden`; the key is not held and
572
+ the session does not move, so grant the key the permission or remove it. When every ChatGPT account and key is at
573
+ its limit, Codex gets `usage_limit_reached` naming each key at its spend limit with its month in dollars.
574
+
406
575
  Clients see limits in their own terms. Codex receives the serving account's `x-codex-*` rate-limit headers,
407
576
  and `usage_limit_reached` with the earliest reset and plan once every account is exhausted. Claude Code
408
577
  receives the serving account's `anthropic-ratelimit-unified-*` headers, and once every Claude account is
@@ -413,13 +582,21 @@ exhausted a `rate_limit_error` with `anthropic-ratelimit-unified-status: rejecte
413
582
  #### Payment problems
414
583
 
415
584
  A Claude account that Anthropic answers with HTTP 402 whose JSON body names `error.type: billing_error` (the proxy reads
416
- at most 64 KiB of a 402) has a payment problem. The proxy takes it out of rotation at once and tells the accounts service
585
+ at most 64 KiB of a 402) has a payment problem. So has an API key that its provider refuses for its organization's
586
+ billing or spend: Anthropic's 402 `billing_error`, Anthropic's 429 `rate_limit_error` with `error.details.error_code:
587
+ enforced_spend_limit_reached` (the organization reached its usage tier's monthly spend cap, which holds until 00:00 UTC
588
+ on the first day of the next month), or OpenAI's 429 whose `error.code` is `credit_balance_exhausted`,
589
+ `organization_spend_limit_exceeded`, `project_spend_limit_exceeded` or `organization_usage_limit_exceeded`, or whose
590
+ `error.type` is `insufficient_quota`. The proxy takes the key out of rotation the same way and tells the accounts service, which holds the
591
+ problem on the key's usage with its cause: a spend cap until it ends, anything else until a re-check reads the key
592
+ healthy. An accounts service that does not hold a key's payment problem leaves it to the proxy, which holds a spend cap
593
+ until it ends and anything else until it restarts. A spend limit set in the Claude Console answers HTTP 400 and
594
+ is not read as a payment problem: set AGL's own limit below it. The proxy takes it out of rotation at once and tells the accounts service
417
595
  (`authswitch.authority.usage.paymentProblem { accountId, loginId, observedAt }`), which holds the problem on the
418
596
  account's usage record, so it survives a restart of either. The request goes to another eligible account before a byte
419
- reaches Claude Code and is never sent to that account again; with no other account to take it, Claude Code gets
420
- Anthropic's 402 unchanged. Any other 402 -- a body that is not JSON, names another error type or is larger than 64 KiB
421
- -- passes through as it came, and so does every 402 of an Anthropic API key and of `count_tokens`, which is never
422
- reported. A session on the account moves at its next request (`unavailable` in the placement journal), and
597
+ reaches Claude Code and is never sent to that account again; with no other account to take it, the client gets the
598
+ provider's own answer unchanged. Any other 402 -- a body that is not JSON, names another error type or is larger than 64 KiB
599
+ -- passes through as it came, and so does every 402 of `count_tokens`, which is never reported. A session on the account moves at its next request (`unavailable` in the placement journal), and
423
600
  `count_tokens` skips the account too. When payment problems are all that is left, Claude Code gets a 402
424
601
  `billing_error` that names the accounts; while another account is merely at its limit, the 429 `rate_limit_error`
425
602
  stands: a payment problem never counts as a limit. `agl proxy status` shows such an account as `unavailable (Anthropic
@@ -988,19 +1165,19 @@ ready-to-release or `Handoff` line, a line addressed to the driver: `driver:`, `
988
1165
  `spec question:`); a turn that continues past its report keeps that report.
989
1166
  Claude API error records are never reports: login, organization and `apiKeyHelper` failures
990
1167
  raise `worker.credential.failed` when watch reads the record. Reports and permission checks
991
- use an isolated transcript adapter verified on Claude Code 2.1.282. The fleet runs that release
992
- and every newer one: `claude --version` must print `X.Y.Z (Claude Code)` at 2.1.282 or above,
1168
+ use an isolated transcript adapter verified on Claude Code 2.1.306 and on 2.1.282. The fleet runs
1169
+ 2.1.282 and every newer release: `claude --version` must print `X.Y.Z (Claude Code)` at 2.1.282 or above,
993
1170
  and an older release, other output or a failed run refuses, naming what it printed. On a newer
994
1171
  release the adapter's own checks are where a change in Claude Code's output shows: an inventory,
995
1172
  note or transcript record it cannot read fails visibly, never as a resend. Every command checks
996
1173
  the current executable identity, and a repointed launcher or changed binary requires version
997
1174
  verification again. `plan` mode cannot currently prove peer-relay compatibility and refuses steering.
998
1175
 
999
- The first time AGL meets a Claude Code release newer than 2.1.282 (the fleet's version check or
1176
+ The first time AGL meets a Claude Code release newer than 2.1.306 (the fleet's version check or
1000
1177
  the configuration's), it records the release in its database and publishes one
1001
1178
  `harness.release.discovered` fleet event to every owner, with the release as `version`: the
1002
1179
  built-in Driver receives it, and `agl fleet watch` shows it
1003
- (`10:41:07Z fleet: harness.release.discovered 2.1.306 Claude Code 2.1.306 is newer than 2.1.282, …`).
1180
+ (`10:41:07Z fleet: harness.release.discovered 2.1.307 Claude Code 2.1.307 is newer than 2.1.306, …`).
1004
1181
  Its text names the release, whether handing Claude Code's own sign-in to the accounts service
1005
1182
  works on it (only once its credential handling was read, as 2.1.306's was) or stays refused, and the
1006
1183
  "Claude Code compatibility check" list in
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: 'agl',
6
- version: '40.4.0',
6
+ version: '40.5.0',
7
7
  description: 'Agent Gateway Layer for OpenCode, FlexHarness, and Codex sessions'
8
8
  }
@@ -3783,7 +3783,7 @@ export const AGL_API_SURFACE: plugins.flexTypedRequest.IFlexTypedRequestSurface
3783
3783
  },
3784
3784
  "protocolVersion": {
3785
3785
  "type": "number",
3786
- "const": 43
3786
+ "const": 44
3787
3787
  },
3788
3788
  "upgradeManagementVersion": {
3789
3789
  "type": "number"
@@ -3905,7 +3905,7 @@ export const AGL_API_SURFACE: plugins.flexTypedRequest.IFlexTypedRequestSurface
3905
3905
  ],
3906
3906
  "additionalProperties": false
3907
3907
  },
3908
- "declaration": "interface IControllerStatus {\n packageName: string;\n packageVersion: string;\n protocolVersion: 43;\n upgradeManagementVersion: number;\n controllerPid: number;\n processGroupId: number;\n processFingerprint: string;\n processMode: TControllerProcessMode;\n lifecycleState: TControllerLifecycleState;\n startedAt: number;\n setupRequired: boolean;\n /** Present when the controller runs as the systemd user unit (`agl service install`); absent for a detached or foreground one. */\n serviceUnit?: \"agl-controller.service\";\n /** The TLS mode of the runtime configuration the controller runs with; absent until it is resolved. */\n tlsMode?: TControllerTlsMode;\n upgrade?: IControllerUpgradeStatus;\n /** AGL's inference proxy, by state only; details need a signed-in owner (`controller.proxy.status.get`). */\n proxy?: { state: TControllerProxyState };\n harnesses: Array<{\n harnessId: TControllerSessionHarnessId;\n pid?: number;\n state: TControllerHarnessLifecycleState;\n healthy: boolean;\n version?: string;\n supportsLocalAttachments?: boolean;\n connectionMode?: 'local' | 'shared' | 'remote';\n diagnostic?: IControllerCodexDiagnostic;\n /** Codex only: automatic reconnection of this entry's profile to the local shared app-server. */\n rejoin?: IControllerCodexRejoinStatus;\n /**\n * Codex only, on the controller status: every connected or required profile. The entry's\n * `healthy` is then true only when each of them is, apart from a profile on an account Codex\n * cannot run on now, listed `stopped` with an `account_unavailable` diagnostic that says why;\n * its other members describe the default profile.\n */\n profiles?: IControllerCodexProfileStatus[];\n }>;\n}"
3908
+ "declaration": "interface IControllerStatus {\n packageName: string;\n packageVersion: string;\n protocolVersion: 44;\n upgradeManagementVersion: number;\n controllerPid: number;\n processGroupId: number;\n processFingerprint: string;\n processMode: TControllerProcessMode;\n lifecycleState: TControllerLifecycleState;\n startedAt: number;\n setupRequired: boolean;\n /** Present when the controller runs as the systemd user unit (`agl service install`); absent for a detached or foreground one. */\n serviceUnit?: \"agl-controller.service\";\n /** The TLS mode of the runtime configuration the controller runs with; absent until it is resolved. */\n tlsMode?: TControllerTlsMode;\n upgrade?: IControllerUpgradeStatus;\n /** AGL's inference proxy, by state only; details need a signed-in owner (`controller.proxy.status.get`). */\n proxy?: { state: TControllerProxyState };\n harnesses: Array<{\n harnessId: TControllerSessionHarnessId;\n pid?: number;\n state: TControllerHarnessLifecycleState;\n healthy: boolean;\n version?: string;\n supportsLocalAttachments?: boolean;\n connectionMode?: 'local' | 'shared' | 'remote';\n diagnostic?: IControllerCodexDiagnostic;\n /** Codex only: automatic reconnection of this entry's profile to the local shared app-server. */\n rejoin?: IControllerCodexRejoinStatus;\n /**\n * Codex only, on the controller status: every connected or required profile. The entry's\n * `healthy` is then true only when each of them is, apart from a profile on an account Codex\n * cannot run on now, listed `stopped` with an `account_unavailable` diagnostic that says why;\n * its other members describe the default profile.\n */\n profiles?: IControllerCodexProfileStatus[];\n }>;\n}"
3909
3909
  },
3910
3910
  {
3911
3911
  "name": "IControllerTerminalResource",
@@ -25,6 +25,8 @@ import type {
25
25
  IReq_ControllerAccountsRename,
26
26
  IReq_ControllerAccountsMaxUsage,
27
27
  IReq_ControllerAccountsReserveLift,
28
+ IReq_ControllerAccountsApiKeyAdd,
29
+ IReq_ControllerAccountsSpendLimit,
28
30
  IReq_ControllerAccountsUsageGet,
29
31
  IReq_ControllerAccountsUsageRecheck,
30
32
  TControllerAccountBinding,
@@ -37,7 +39,7 @@ import type {
37
39
  TControllerClaudeSignIn,
38
40
  TControllerCodexNativeHandoff,
39
41
  } from '../ts_interfaces/index.js';
40
- import { findAuthorityDaemonLogin, findHostCodexAssignment } from '../ts_interfaces/index.js';
42
+ import { controllerApiKeyMaxLength, findAuthorityDaemonLogin, findHostCodexAssignment } from '../ts_interfaces/index.js';
41
43
 
42
44
  /**
43
45
  * The prompt of a login test. Fixed and minimal on purpose: the test proves that the login can make
@@ -126,6 +128,29 @@ const assertRequestShape = (
126
128
  return request;
127
129
  };
128
130
 
131
+ /**
132
+ * An API key add: the provider, the key, and an optional label and spend limit, each of its type and nothing else. The
133
+ * key is bounded by the service's own maximum, beyond the 256 characters other members are; the service decides the
134
+ * rest -- the key's shape, the label rule, the limit rule -- and answers with its instruction.
135
+ */
136
+ const assertApiKeyAddShape = (requestArg: unknown): IReq_ControllerAccountsApiKeyAdd['request'] => {
137
+ if (typeof requestArg !== 'object' || requestArg === null || Array.isArray(requestArg)) {
138
+ throw invalidRequest('The account request is invalid.');
139
+ }
140
+ const request = requestArg as Record<string, unknown>;
141
+ if (Object.keys(request).some((nameArg) => !['providerId', 'apiKey', 'label', 'spendLimitMicroUsd'].includes(nameArg))
142
+ || (request.providerId !== 'anthropic' && request.providerId !== 'openai')
143
+ || typeof request.apiKey !== 'string' || request.apiKey.length === 0
144
+ || request.apiKey.length > controllerApiKeyMaxLength
145
+ || (request.label !== undefined && (typeof request.label !== 'string' || request.label.length === 0
146
+ || request.label.length > 256))
147
+ || (request.spendLimitMicroUsd !== undefined && (typeof request.spendLimitMicroUsd !== 'number'
148
+ || !Number.isSafeInteger(request.spendLimitMicroUsd) || request.spendLimitMicroUsd < 0))) {
149
+ throw invalidRequest('The account request is invalid.');
150
+ }
151
+ return request as unknown as IReq_ControllerAccountsApiKeyAdd['request'];
152
+ };
153
+
129
154
  /**
130
155
  * The account actions of the Accounts screen: adding an account and signing a login in again with a
131
156
  * device sign-in, reading and cancelling that sign-in, rename, the usage threshold, remove, usage, the
@@ -183,6 +208,27 @@ export class AccountsActions {
183
208
  }));
184
209
  },
185
210
  );
211
+ routerArg.addHandler<IReq_ControllerAccountsApiKeyAdd>(
212
+ 'controller.accounts.apikey.add',
213
+ async (requestArg, toolsArg) => {
214
+ const request = assertApiKeyAddShape(requestArg);
215
+ // The audit names the action and the peer; the key is in no record, journal or answer.
216
+ return hostArg.runAudited(toolsArg?.localData, 'accounts.apikey.add', async (signalArg) => ({
217
+ answer: await this.addApiKey(request, signalArg),
218
+ }));
219
+ },
220
+ );
221
+ routerArg.addHandler<IReq_ControllerAccountsSpendLimit>(
222
+ 'controller.accounts.spendlimit',
223
+ async (requestArg, toolsArg) => {
224
+ const request = assertRequestShape(requestArg, {
225
+ accountId: 'string', expectedRevision: 'number', spendLimitMicroUsd: 'number | null',
226
+ }) as IReq_ControllerAccountsSpendLimit['request'];
227
+ return hostArg.runAudited(toolsArg?.localData, 'accounts.spendlimit', async (signalArg) => ({
228
+ answer: await this.setSpendLimit(request, signalArg),
229
+ }));
230
+ },
231
+ );
186
232
  routerArg.addHandler<IReq_ControllerAccountsRemove>(
187
233
  'controller.accounts.remove',
188
234
  async (requestArg, toolsArg) => {
@@ -434,6 +480,34 @@ export class AccountsActions {
434
480
  return answer;
435
481
  }
436
482
 
483
+ /**
484
+ * Adds an API key as an account through the accounts service, which asks the provider first, and answers once the
485
+ * projection shows the account. The key goes to the service and nowhere else.
486
+ */
487
+ public async addApiKey(
488
+ requestArg: IReq_ControllerAccountsApiKeyAdd['request'],
489
+ signalArg: AbortSignal,
490
+ ): Promise<TControllerAccountsAnswer<{ account: plugins.authswitchAuthority.IAuthSwitchAccount; loginId: string }>> {
491
+ const answer = await this.answer((clientArg) => clientArg.addApiKey(requestArg.providerId, requestArg.apiKey, {
492
+ ...(requestArg.label === undefined ? {} : { label: requestArg.label }),
493
+ ...(requestArg.spendLimitMicroUsd === undefined ? {} : { spendLimitMicroUsd: requestArg.spendLimitMicroUsd }),
494
+ }, signalArg));
495
+ if (answer.state === 'done') await this.waitForAccountRevision(answer.value.account, signalArg);
496
+ return answer;
497
+ }
498
+
499
+ /** Sets or clears an API key account's monthly spend limit, and answers once the projection shows it. */
500
+ public async setSpendLimit(
501
+ requestArg: IReq_ControllerAccountsSpendLimit['request'],
502
+ signalArg: AbortSignal,
503
+ ): Promise<TControllerAccountsAnswer<plugins.authswitchAuthority.IAuthSwitchAccount>> {
504
+ const answer = await this.answer((clientArg) => clientArg.setAccountSpendLimit(
505
+ requestArg.accountId, requestArg.expectedRevision, requestArg.spendLimitMicroUsd, signalArg,
506
+ ));
507
+ if (answer.state === 'done') await this.waitForAccountRevision(answer.value, signalArg);
508
+ return answer;
509
+ }
510
+
437
511
  /** Waits until the controller's projection shows the account at `accountArg`'s revision or later. */
438
512
  private async waitForAccountRevision(
439
513
  accountArg: plugins.authswitchAuthority.IAuthSwitchAccount,
@@ -335,7 +335,7 @@ import {
335
335
  codexAccountInHostReason,
336
336
  findAuthorityDaemonLogin,
337
337
  findHostCodexAssignment,
338
- findInferencePool,
338
+ isFlexClaudePoolMember,
339
339
  flexAuthorityConnectionId,
340
340
  flexClaudePoolConnectionId,
341
341
  flexConnectionProviderId,
@@ -19858,7 +19858,7 @@ export class OpenCodeController {
19858
19858
  const snapshot = projection.snapshot;
19859
19859
  if (projection.state !== 'ready' || !snapshot) return [];
19860
19860
  const claudePool: IFlexPublicProviderConnection[] = snapshot.accounts
19861
- .some((accountArg) => findInferencePool(projection, accountArg.id)?.credential === 'api_key')
19861
+ .some((accountArg) => isFlexClaudePoolMember(projection, accountArg.id))
19862
19862
  ? [{ loginId: flexClaudePoolConnectionId, providerId: 'anthropic', status: 'active' }]
19863
19863
  : [];
19864
19864
  return [...snapshot.accounts
@@ -19961,7 +19961,7 @@ export class OpenCodeController {
19961
19961
  const projection = this.authorityState.getProjection();
19962
19962
  if (projection.state !== 'ready' || !projection.snapshot) return;
19963
19963
  const members = projection.snapshot.accounts
19964
- .filter((accountArg) => findInferencePool(projection, accountArg.id)?.credential === 'api_key')
19964
+ .filter((accountArg) => isFlexClaudePoolMember(projection, accountArg.id))
19965
19965
  .map((accountArg) => `${accountArg.id}:${accountArg.revision}`)
19966
19966
  .sort()
19967
19967
  .join(',');
@@ -27504,7 +27504,7 @@ export class OpenCodeController {
27504
27504
  private flexHasAnthropicApiKey(): boolean | undefined {
27505
27505
  const projection = this.authorityState.getProjection();
27506
27506
  if (projection.state !== 'ready' || !projection.snapshot) return undefined;
27507
- return projection.snapshot.accounts.some((accountArg) => findInferencePool(projection, accountArg.id)?.credential === 'api_key');
27507
+ return projection.snapshot.accounts.some((accountArg) => isFlexClaudePoolMember(projection, accountArg.id));
27508
27508
  }
27509
27509
 
27510
27510
  /**
@@ -497,7 +497,8 @@ export class FlexAccountAccess {
497
497
  ? gatewayLogin(this.options.authority.getProjection(), targetArg.accountId)
498
498
  : authorityDaemonLogin(this.options.authority.getProjection(), targetArg.accountId);
499
499
  const purpose = login.purpose;
500
- if (purpose !== 'openai_managed' && purpose !== 'claude_host_native' && purpose !== 'anthropic_api_key') {
500
+ if (purpose !== 'openai_managed' && purpose !== 'claude_host_native' && purpose !== 'anthropic_api_key'
501
+ && purpose !== 'openai_api_key') {
501
502
  throw new FlexServiceError('PROVIDER_REAUTH_REQUIRED');
502
503
  }
503
504
  const bound = await client.bindAccount({
@@ -7,7 +7,8 @@ export type THarnessReleaseSource = 'fleet' | 'configuration';
7
7
 
8
8
  /**
9
9
  * A Claude Code release newer than the one AGL verified, recorded the first time AGL met it. `pending` until its one
10
- * mention to the driver was published, `announced` after; a `pending` record is announced at the next start.
10
+ * mention to the driver was published, `announced` after; a `pending` record is announced at the next start while its
11
+ * release is still newer than the verified one, and dropped unannounced once it is not.
11
12
  */
12
13
  export interface IHarnessReleaseDocument {
13
14
  /** `claude:<version>`. */
@@ -81,8 +82,12 @@ export interface IHarnessReleaseNoticesOptions {
81
82
  /**
82
83
  * Mentions each Claude Code release newer than the verified one to the driver once, durably: the first sighting
83
84
  * records it `pending`, publishes one `harness.release.discovered` fleet event and marks it `announced`. A later
84
- * sighting finds the record and publishes nothing; one a crash left `pending` is announced by the next `start`.
85
- * Until `start` runs, sightings are only recorded, because the fleet events they are published to do not exist yet.
85
+ * sighting finds the record and publishes nothing; one a crash left `pending` is announced by the next `start`, unless
86
+ * AGL has verified a release at least as new meanwhile (an earlier AGL recorded it against an older verified release):
87
+ * such a record is dropped without a mention. `announced` would claim a mention that never happened (`announcedAt`),
88
+ * and left `pending` it would claim one is still owed; nothing else reads it, and a binary that still finds the release
89
+ * new records it again at its next sighting. Until `start` runs, sightings are only recorded, because the fleet events
90
+ * they are published to do not exist yet.
86
91
  * The auth store owns the model manager and database; this owns no connection.
87
92
  */
88
93
  export class HarnessReleaseNotices {
@@ -156,11 +161,15 @@ export class HarnessReleaseNotices {
156
161
  await this.announce(idArg);
157
162
  }
158
163
 
159
- /** Publishes the mention of a `pending` release and marks it `announced`; an announced or missing one is left alone. */
164
+ /**
165
+ * Publishes the mention of a `pending` release and marks it `announced`; an announced or missing one is left alone,
166
+ * and a `pending` one that is no longer newer than the verified release is dropped without a mention.
167
+ */
160
168
  private async announce(idArg: string): Promise<void> {
161
169
  const current = await HarnessReleaseModel.exact.findStoredOne({ id: idArg });
162
170
  if (!current || current.state !== 'pending') return;
163
171
  const { version } = HarnessReleaseModel.exact.toPersisted(current);
172
+ if (!isNewerClaudeCodeRelease(version)) return await this.drop(current, version);
164
173
  this.options.publish({ kind: 'harness.release.discovered', version, text: claudeCodeReleaseNotice(version) });
165
174
  const updateId = plugins.crypto.randomUUID();
166
175
  try {
@@ -175,4 +184,16 @@ export class HarnessReleaseNotices {
175
184
  if (observed?.state === 'announced') return;
176
185
  throw new Error(`Claude Code ${version} was announced, but the announcement was not recorded; it is announced again at the next start.`);
177
186
  }
187
+
188
+ /** Deletes the `pending` record `currentArg` of a release that is no longer new, only while it is unchanged. */
189
+ private async drop(currentArg: plugins.nosqldb.TStoredDocument<IHarnessReleaseDocument>, versionArg: string): Promise<void> {
190
+ try {
191
+ if ((await HarnessReleaseModel.exact.delete({ current: currentArg })).status === 'deleted') return;
192
+ } catch (error) {
193
+ if (!exactError(error, 'ambiguous_write')) throw error;
194
+ }
195
+ const observed = await HarnessReleaseModel.exact.findStoredOne({ id: `claude:${versionArg}` });
196
+ if (!observed || observed.state !== 'pending') return;
197
+ throw new Error(`The pending record of Claude Code ${versionArg}, no newer than the verified ${claudeCodeVerifiedRelease}, could not be dropped; it is dropped at the next start.`);
198
+ }
178
199
  }