diff --git a/README.md b/README.md index b3e38386..250f0644 100644 --- a/README.md +++ b/README.md @@ -397,14 +397,38 @@ copy is rotated and the others aren't. installing shell into `~/.arbiter/arbiter.env` automatically, same as the other captured secrets above. +#### Account-model path (requires `:provider_accounts_enabled`) + +If your install has enabled the provider-account model (see +`docs/provider-account-design.md` and `config/config.exs:131`), you can +instead attach workspace credentials to named provider accounts via the `arb +account` CLI: + +```sh +# Create or reference a provider account +arb account create claude my_account + +# Attach it to a workspace +arb account attach claude my_account + +# Install or rotate the credential +arb account rotate claude:my_account --kind oauth_token --env-var CLAUDE_CODE_OAUTH_TOKEN --secret +``` + +This path is particularly useful if you have **multiple Claude credentials** +(e.g., for different Anthropic accounts or organizations) and want to route +different workspaces to different accounts — the account model lets each +workspace reference its own account identity directly, without duplicating +tokens across workspaces or relying on install-wide environment fallbacks. + **Precedence when both are set:** a spawn can end up with both -`CLAUDE_CODE_OAUTH_TOKEN` (install-wide) and `ANTHROPIC_API_KEY` (workspace -`credentials_ref`/`api_keys` rotation) in its environment at once. Which one -the `claude` CLI honours is decided by the CLI itself, not by Arbiter — if it -prefers the OAuth token, a workspace that deliberately configured its own key -would silently authenticate against the install-wide account instead. If a -workspace's `ANTHROPIC_API_KEY` must win, verify the CLI's actual precedence -before relying on it, or unset the install-wide token for that install. +`CLAUDE_CODE_OAUTH_TOKEN` (install-wide) and credentials from the account model +in its environment at once. Which one the `claude` CLI honours is decided by +the CLI itself, not by Arbiter — if it prefers the OAuth token, a workspace +with an account-attached credential would silently authenticate against the +install-wide account instead. If a workspace's account credential must win, +verify the CLI's actual precedence before relying on it, or unset the +install-wide token for that install. **Redaction:** `Arbiter.Worker.ClaudeSession.start/1` adds `CLAUDE_CODE_OAUTH_TOKEN`/`ANTHROPIC_API_KEY` values to the session's diff --git a/apps/arbiter/lib/arbiter/quota.ex b/apps/arbiter/lib/arbiter/quota.ex index 2142c184..0472e959 100644 --- a/apps/arbiter/lib/arbiter/quota.ex +++ b/apps/arbiter/lib/arbiter/quota.ex @@ -384,10 +384,8 @@ defmodule Arbiter.Quota do shape (string-friendly, ISO-8601 timestamps), or `nil` when none exists. `:workspace_id` names the workspace whose gate config annotates the - `gating_*` fields. Thresholds are still workspace-scoped until P7, so a - caller that came in through `arb quota --workspace X` passes X here and - gets the same answer it did before the re-key; with none given the - account's alphabetically-first workspace stands in. + `gating_*` fields (for backward compatibility with `arb quota --workspace`). + When none is given, the account's alphabetically-first workspace stands in. `:spend_cache` optionally supplies a `spend_cache/1` memo so a caller that also lists the other providers pays for the ledger scan once — see diff --git a/apps/arbiter/lib/arbiter/quota/cloud_probe.ex b/apps/arbiter/lib/arbiter/quota/cloud_probe.ex index 6ac54e70..a483c903 100644 --- a/apps/arbiter/lib/arbiter/quota/cloud_probe.ex +++ b/apps/arbiter/lib/arbiter/quota/cloud_probe.ex @@ -21,7 +21,7 @@ defmodule Arbiter.Quota.CloudProbe do quota tables, could never show them, and there was no history to audit. This GenServer closes that gap. - ## What a refresh does per workspace + ## Refresh strategy * `Arbiter.Quota.Codex.fetch/2` — one GET to OpenAI's usage endpoint using the `codex` CLI's stored token; upserts `CodexQuota` + broadcasts. diff --git a/apps/arbiter/lib/arbiter/quota/gate.ex b/apps/arbiter/lib/arbiter/quota/gate.ex index 5932a0b7..42cb92ff 100644 --- a/apps/arbiter/lib/arbiter/quota/gate.ex +++ b/apps/arbiter/lib/arbiter/quota/gate.ex @@ -5,9 +5,9 @@ defmodule Arbiter.Quota.Gate do The gate is the single choke point the fleet dispatcher (`Arbiter.Worker.Dispatch.dispatch/2`) consults before mutating any task state, so a near-cap decision covers every dispatch path at once. It reads the - latest quota snapshot for the workspace **and the provider this dispatch will - actually run on** (bd-2mpo3f) and decides what to do when that provider nears - / crosses its primary window cap: + latest quota snapshot for the account whose credential this dispatch will + authenticate with, **and the provider this dispatch will actually run on** (bd-2mpo3f) + and decides what to do when that provider nears / crosses its primary window cap: * `:allow` — dispatch proceeds normally (there is headroom, or we are failing open because no snapshot exists). diff --git a/apps/arbiter/test/arbiter/board/drain_test.exs b/apps/arbiter/test/arbiter/board/drain_test.exs index 39e7688f..681c04ed 100644 --- a/apps/arbiter/test/arbiter/board/drain_test.exs +++ b/apps/arbiter/test/arbiter/board/drain_test.exs @@ -204,8 +204,10 @@ defmodule Arbiter.Board.DrainTest do Process.exit(pid, :kill) assert_receive {:DOWN, ^ref, :process, ^pid, :killed} - # Registry cleans up on its own :DOWN — sync on it before re-reading. - _ = :sys.get_state(Drain.Registry) + # The Registry's (single) pid partition is linked to the caller and + # cleans up on its :EXIT — sync on that process, not the Registry's + # supervisor (which is what the bare registry name resolves to). + _ = :sys.get_state(Module.concat(Drain.Registry, "PIDPartition0")) assert Drain.status(autopilot: ap, supervisor: sup).state == :quiescent end diff --git a/apps/arbiter_cli/lib/arbiter_cli/cmd/quota.ex b/apps/arbiter_cli/lib/arbiter_cli/cmd/quota.ex index b5c49a46..41dbba24 100644 --- a/apps/arbiter_cli/lib/arbiter_cli/cmd/quota.ex +++ b/apps/arbiter_cli/lib/arbiter_cli/cmd/quota.ex @@ -8,8 +8,9 @@ defmodule ArbiterCli.Cmd.Quota do * Claude: OAuth polling of Anthropic's `/api/oauth/usage` endpoint plus `anthropic-ratelimit-unified-*` headers captured from worker responses. - Stores the latest snapshot per workspace, including per-model weekly - breakdown and `extra_usage` overage (bd-8tpha6, bd-b0zody). + Stores the latest snapshot per account (P5, `docs/provider-account-design.md` + §6), including per-model weekly breakdown and `extra_usage` overage + (bd-8tpha6, bd-b0zody). * Codex: OpenAI session + weekly windows, refreshed by the quota probe using the `codex` CLI's stored token. Shows a short message until a snapshot has been captured (i.e. the CLI isn't authenticated on this host). diff --git a/apps/arbiter_web/lib/arbiter_web/live/workspace_detail/policy_config_component.ex b/apps/arbiter_web/lib/arbiter_web/live/workspace_detail/policy_config_component.ex index a4b8adb2..aaec981c 100644 --- a/apps/arbiter_web/lib/arbiter_web/live/workspace_detail/policy_config_component.ex +++ b/apps/arbiter_web/lib/arbiter_web/live/workspace_detail/policy_config_component.ex @@ -780,7 +780,7 @@ defmodule ArbiterWeb.WorkspaceDetail.PolicyConfigComponent do <.setting_row name="Max concurrent workers" - consequence="conductor.max_concurrent — per workspace; the effective cap is the lowest of this, the system cap and quota headroom" + consequence="conductor.max_concurrent — the effective cap is the lowest of this, the account ceiling (P8, if configured), the system cap and quota headroom (P7)" > <:control>