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.
- package/changelog.md +27 -0
- package/dist_serve/bundle.js +2228 -2177
- package/dist_serve/bundle.js.map +2 -2
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/aglapi/aglapisurface.document.js +2 -2
- package/dist_ts/classes.accountsactions.d.ts +11 -1
- package/dist_ts/classes.accountsactions.js +59 -2
- package/dist_ts/classes.controller.js +5 -5
- package/dist_ts/classes.flexaccountaccess.js +3 -2
- package/dist_ts/classes.harnessreleases.d.ts +14 -4
- package/dist_ts/classes.harnessreleases.js +28 -4
- package/dist_ts/classes.inferencegateway.d.ts +28 -3
- package/dist_ts/classes.inferencegateway.js +177 -61
- package/dist_ts/classes.inferenceplacement.d.ts +5 -3
- package/dist_ts/classes.inferenceplacement.js +2 -2
- package/dist_ts/classes.inferencerouter.d.ts +349 -91
- package/dist_ts/classes.inferencerouter.js +804 -297
- package/dist_ts/classes.inferenceservice.d.ts +14 -2
- package/dist_ts/classes.inferenceservice.js +53 -14
- package/dist_ts/classes.inferencespend.d.ts +339 -0
- package/dist_ts/classes.inferencespend.js +815 -0
- package/dist_ts/classes.inferenceusagecache.d.ts +8 -0
- package/dist_ts/classes.inferenceusagecache.js +9 -5
- package/dist_ts/classes.inferenceusagetap.d.ts +50 -9
- package/dist_ts/classes.inferenceusagetap.js +185 -31
- package/dist_ts/classes.proxycontrol.d.ts +3 -2
- package/dist_ts/classes.proxycontrol.js +1 -2
- package/dist_ts/classes.proxydaemon.js +3 -2
- package/dist_ts/classes.proxystore.d.ts +6 -0
- package/dist_ts/classes.proxystore.js +11 -2
- package/dist_ts/fleet/classes.claudeworkers.d.ts +5 -4
- package/dist_ts/fleet/classes.claudeworkers.js +10 -9
- package/dist_ts/functions.claudecoderelease.d.ts +12 -6
- package/dist_ts/functions.claudecoderelease.js +13 -7
- package/dist_ts/functions.inferencecli.js +12 -8
- package/dist_ts/interfaces.auth.d.ts +1 -1
- package/dist_ts/interfaces.auth.js +3 -1
- package/dist_ts/plugins.d.ts +3 -1
- package/dist_ts/plugins.js +7 -2
- package/dist_ts_authswitch/authority-apikeys.d.ts +32 -0
- package/dist_ts_authswitch/authority-apikeys.js +65 -0
- package/dist_ts_authswitch/authority-contract.d.ts +142 -28
- package/dist_ts_authswitch/authority-contract.js +48 -1
- package/dist_ts_authswitch/authority-overview.js +8 -1
- package/dist_ts_authswitch/authority-placement.d.ts +7 -2
- package/dist_ts_authswitch/authority-placement.js +10 -3
- package/dist_ts_authswitch/authority-runtime-contract.d.ts +2 -2
- package/dist_ts_authswitch/authority-spend.d.ts +103 -0
- package/dist_ts_authswitch/authority-spend.js +110 -0
- package/dist_ts_authswitch/authority-subscription.d.ts +12 -1
- package/dist_ts_authswitch/authority-subscription.js +24 -1
- package/dist_ts_authswitch/authority-usagelimit.d.ts +7 -4
- package/dist_ts_authswitch/authority-usagelimit.js +89 -39
- package/dist_ts_authswitch/authority-usagewindows.d.ts +4 -1
- package/dist_ts_authswitch/authority-usagewindows.js +41 -16
- package/dist_ts_authswitch/classes.authoritybroker.d.ts +30 -19
- package/dist_ts_authswitch/classes.authoritybroker.js +152 -61
- package/dist_ts_authswitch/classes.authoritycli.d.ts +9 -4
- package/dist_ts_authswitch/classes.authoritycli.js +94 -29
- package/dist_ts_authswitch/classes.authorityclient.d.ts +24 -7
- package/dist_ts_authswitch/classes.authorityclient.js +31 -11
- package/dist_ts_authswitch/classes.authoritydaemon.js +39 -12
- package/dist_ts_authswitch/classes.authoritydatabase.d.ts +28 -5
- package/dist_ts_authswitch/classes.authoritydatabase.js +171 -9
- package/dist_ts_authswitch/classes.authorityimport.js +2 -2
- package/dist_ts_authswitch/classes.authoritymodels.d.ts +71 -3
- package/dist_ts_authswitch/classes.authoritymodels.js +249 -20
- package/dist_ts_authswitch/classes.authorityregistry.js +4 -2
- package/dist_ts_authswitch/classes.authorityusage.d.ts +69 -7
- package/dist_ts_authswitch/classes.authorityusage.js +118 -4
- package/dist_ts_authswitch/classes.claudenative.d.ts +16 -7
- package/dist_ts_authswitch/classes.claudenative.js +29 -16
- package/dist_ts_authswitch/classes.claudesignin.js +5 -2
- package/dist_ts_authswitch/testsupport.d.ts +24 -5
- package/dist_ts_authswitch/testsupport.js +56 -12
- package/dist_ts_authswitch/ts_migration/0011_account_api_key_fields.d.ts +12 -0
- package/dist_ts_authswitch/ts_migration/0011_account_api_key_fields.js +21 -0
- package/dist_ts_authswitch/ts_migration/index.js +3 -1
- package/dist_ts_interfaces/functions.authorityscope.d.ts +18 -7
- package/dist_ts_interfaces/functions.authorityscope.js +35 -8
- package/dist_ts_interfaces/interfaces.d.ts +1 -1
- package/dist_ts_interfaces/interfaces.js +1 -1
- package/dist_ts_interfaces/plugins.d.ts +2 -1
- package/dist_ts_interfaces/plugins.js +7 -4
- package/dist_ts_interfaces/requests.accounts.d.ts +56 -0
- package/dist_ts_interfaces/requests.accounts.js +11 -1
- package/dist_ts_interfaces/requests.d.ts +2 -2
- package/dist_ts_interfaces/requests.js +1 -1
- package/docs/authswitch.md +75 -12
- package/package.json +5 -5
- package/readme.md +198 -21
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/aglapi/aglapisurface.document.ts +2 -2
- package/ts/classes.accountsactions.ts +75 -1
- package/ts/classes.controller.ts +4 -4
- package/ts/classes.flexaccountaccess.ts +2 -1
- package/ts/classes.harnessreleases.ts +25 -4
- package/ts/classes.inferencegateway.ts +184 -64
- package/ts/classes.inferenceplacement.ts +6 -4
- package/ts/classes.inferencerouter.ts +1026 -314
- package/ts/classes.inferenceservice.ts +56 -13
- package/ts/classes.inferencespend.ts +870 -0
- package/ts/classes.inferenceusagecache.ts +16 -4
- package/ts/classes.inferenceusagetap.ts +159 -34
- package/ts/classes.proxycontrol.ts +3 -3
- package/ts/classes.proxydaemon.ts +2 -1
- package/ts/classes.proxystore.ts +11 -1
- package/ts/fleet/classes.claudeworkers.ts +9 -8
- package/ts/functions.claudecoderelease.ts +13 -6
- package/ts/functions.inferencecli.ts +11 -7
- package/ts/interfaces.auth.ts +2 -0
- package/ts/plugins.ts +7 -0
- package/ts_authswitch/authority-apikeys.ts +85 -0
- package/ts_authswitch/authority-contract.ts +167 -29
- package/ts_authswitch/authority-overview.ts +7 -0
- package/ts_authswitch/authority-placement.ts +12 -4
- package/ts_authswitch/authority-runtime-contract.ts +2 -2
- package/ts_authswitch/authority-spend.ts +168 -0
- package/ts_authswitch/authority-subscription.ts +28 -1
- package/ts_authswitch/authority-usagelimit.ts +95 -40
- package/ts_authswitch/authority-usagewindows.ts +42 -17
- package/ts_authswitch/classes.authoritybroker.ts +161 -68
- package/ts_authswitch/classes.authoritycli.ts +108 -32
- package/ts_authswitch/classes.authorityclient.ts +41 -10
- package/ts_authswitch/classes.authoritydaemon.ts +43 -13
- package/ts_authswitch/classes.authoritydatabase.ts +178 -8
- package/ts_authswitch/classes.authorityimport.ts +1 -1
- package/ts_authswitch/classes.authoritymodels.ts +165 -19
- package/ts_authswitch/classes.authorityregistry.ts +3 -0
- package/ts_authswitch/classes.authorityusage.ts +171 -10
- package/ts_authswitch/classes.claudenative.ts +28 -15
- package/ts_authswitch/classes.claudesignin.ts +4 -1
- package/ts_authswitch/native-contracts.md +81 -20
- package/ts_authswitch/readme.md +1 -1
- package/ts_authswitch/testsupport.ts +58 -11
- package/ts_authswitch/ts_migration/0011_account_api_key_fields.ts +20 -0
- package/ts_authswitch/ts_migration/index.ts +2 -0
- package/ts_interfaces/functions.authorityscope.ts +43 -8
- package/ts_interfaces/interfaces.ts +1 -1
- package/ts_interfaces/plugins.ts +6 -3
- package/ts_interfaces/requests.accounts.ts +48 -0
- package/ts_interfaces/requests.ts +4 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/accounts.plugins.ts +3 -1
- package/ts_web/accounts.templates.ts +20 -11
- package/ts_web/classes.accountsreadmodel.ts +322 -70
- package/ts_web/elements.accounts.ts +219 -20
- package/ts_web/elements.codexconnections.ts +3 -1
- 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
|
|
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>]
|
|
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
|
|
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,
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
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.
|
|
386
|
-
|
|
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.
|
|
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,
|
|
420
|
-
|
|
421
|
-
-- passes through as it came, and so does every 402 of
|
|
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
|
|
992
|
-
and every newer
|
|
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.
|
|
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.
|
|
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
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -3783,7 +3783,7 @@ export const AGL_API_SURFACE: plugins.flexTypedRequest.IFlexTypedRequestSurface
|
|
|
3783
3783
|
},
|
|
3784
3784
|
"protocolVersion": {
|
|
3785
3785
|
"type": "number",
|
|
3786
|
-
"const":
|
|
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:
|
|
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,
|
package/ts/classes.controller.ts
CHANGED
|
@@ -335,7 +335,7 @@ import {
|
|
|
335
335
|
codexAccountInHostReason,
|
|
336
336
|
findAuthorityDaemonLogin,
|
|
337
337
|
findHostCodexAssignment,
|
|
338
|
-
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
}
|