feat(web): coding agents run on the account's own provider key (BYOK 2/3)
What
BYOK part 2 of 3, stacked on #2078 (the vault and endpoints). Coding agents now run on the account's own Anthropic or OpenAI key.
-
Per step (
coder-host/account-keys.ts): every step of an agent and of its workers reads the account's key rows.- If the account saved a key for the model's provider, the step runs on that key.
- Only if it saved none does the step run on Luke's key.
- Because the read happens every step, saving, replacing, or removing a key in Settings takes effect at the agent's next step.
-
Fail closed, never bill Luke silently: the step is refused, not moved onto Luke's key, when:
- the deployment's ring can't decrypt the account's key (wrong or rotated-away KEK, a tampered row), or
- the deployment holds no ring at all.
The refusal is
ACCOUNT_KEY_UNREADABLE: "your own API key … cannot be read. Replace it in Settings." Tests cover both cases and assert that no provider is reached on any key. -
Status from later failures: a step on the account's key goes through an AI SDK middleware.
- 401 →
invalid - OpenAI 429
insufficient_quota, or Anthropic's "credit balance too low" →quota_exceeded - a call that goes through →
verifiedagain (for example after topping up) - Each status is written only where the row still holds the exact key the step ran on, so a key replaced mid-step isn't marked by its predecessor's failure.
- The two refusals fail the step in words naming the account's key, not with the provider's raw error.
- A plain rate-limit 429, and anything on Luke's key, pass through untouched.
- 401 →
-
Clear agent state: the three refusals map to a new agent failure word,
provider_key. The desktop's transcript end line reads "Failed: your API key was refused or can't be read; check Settings › API keys". -
Unchanged: agent and subagent titles stay on Luke's Anthropic key. The planning brain and voice are untouched (out of scope per the approved design).
Wire golden change: codingAgentMessagesAnswerSchema and its view schema gain provider_key in the failureReason enum, re-recorded with LUKE_UPDATE_FIXTURES=1. Compatibility caveat: a desktop older than this release can't decode a page that carries the new word. The word only appears for an agent that failed on an account's own key, and keys can only be added from the desktop that ships the Settings page (PR 3).
Tests
tests/coder-host-account-keys.test.ts(real dialect, provider faked at itsfetch; no network):- a step runs on the account's key where it saved one, and on Luke's for the other provider
- an undecryptable key, or no ring, refuses the step and never reaches a provider on Luke's key
- 401 → step fails with
ACCOUNT_KEY_REFUSEDand the row reads invalid - quota (both providers' shapes) → quota_exceeded, then a success → verified
- a refusal on Luke's key names no account key and writes nothing
- a status for a replaced key marks nothing of its successor
tests/coder-host-model.test.ts: the unreadable refusal; the three refusals read asprovider_key.coding-agent-model.test.ts(desktop): the end line forprovider_key.
./scripts/check.sh exits 0. The only Mac change is one string in the transcript end line. verify.sh was not run (Linux VM), and CI cannot verify the Mac UI.
🤖 Generated with Claude Code