Skip to content

feat(native-agent): Anthropic OAuth (Claude Pro/Max) login for openab-agent#1187

Open
canyugs wants to merge 10 commits into
openabdev:mainfrom
canyugs:feat/native-agent-anthropic-oauth
Open

feat(native-agent): Anthropic OAuth (Claude Pro/Max) login for openab-agent#1187
canyugs wants to merge 10 commits into
openabdev:mainfrom
canyugs:feat/native-agent-anthropic-oauth

Conversation

@canyugs

@canyugs canyugs commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

What problem does this solve?

openab-agent can only reach Anthropic via ANTHROPIC_API_KEY (pay-per-token). Codex already supports subscription login, but Claude Pro/Max subscribers cannot use their subscription with the native agent. This adds native Anthropic OAuth (Claude Pro/Max) so users run openab-agent on the Claude subscription they already pay for — no API key — matching the existing Codex experience.

Closes #1186

Discord Discussion URL: https://discord.com/channels/1491295327620169908/1519271476002291752

At a Glance

  openab-agent auth anthropic-oauth          ~/.openab/agent/auth.json
        │ PKCE + browser/paste                ┌────────────────────────────┐
        ▼                                      │ "codex"          (Token)   │
  claude.ai/oauth/authorize ──code──► token   │ "anthropic-oauth"(Token) ◄─┼─ new tenant
  platform.claude.com/v1/oauth/token          │ "<mcp server>"   (Mcp)     │
        │ JSON exchange + scope-less refresh   └────────────────────────────┘
        ▼
  AnthropicProvider (OAuth mode)
    Authorization: Bearer <sk-ant-oat…>
    anthropic-beta: claude-code-20250219,oauth-2025-04-20   x-app: cli
    system[0] = "You are Claude Code, …"   tools: read→Read …
        │
        ▼   api.anthropic.com/v1/messages
  provider select: ANTHROPIC_API_KEY → anthropic-oauth → codex

Prior Art & Industry Research

I looked at how two comparable self-hosted agents authenticate to Anthropic and Codex. The headline finding: neither implements a native Anthropic (Claude Pro/Max) OAuth login — both avoid the PKCE flow and instead lean on a setup-token or on reusing Claude Code's local credentials. This PR (following Pi) does the full native PKCE login, which is strictly more self-contained for pod deployments. Their surrounding architecture, however, validates the storage/refresh choices here.

OpenClaw — supports API keys and subscription OAuth.

  • Anthropic: no native OAuth login — either a user-supplied setup-token stored in an auth profile, or reuse of the local Claude CLI (claude -p).
  • Codex: full PKCEauth.openai.com/oauth/authorize → callback http://127.0.0.1:1455/auth/callback (or manual paste) → token exchange → accountId extracted from the access token. This is byte-for-byte the same flow openab-agent already uses for Codex, corroborating our approach as the de-facto standard.
  • Storage: ~/.openclaw/agents/<id>/agent/auth-profiles.json, one {access, refresh, expires, accountId} tuple per profile.
  • Refresh: treats the profile file as a "token sink", refreshes under a file lock, and is careful not to spend copied refresh tokens — the same rotation hazard we handle by persisting the rotated refresh token on every refresh.
  • Refs: https://docs.openclaw.ai/concepts/oauth , https://docs.openclaw.ai/gateway/authentication

Hermes AgentPROVIDER_REGISTRY dataclasses in hermes_cli/auth.py declare each provider's auth type + base URLs + env vars; resolve_runtime_provider() is the single resolution entry point.

Primary source ported: Pi (earendil-works/pi) — packages/ai/src/utils/oauth/anthropic.ts (PKCE flow, endpoints, scopes; verifier doubles as state) and packages/ai/src/api/anthropic-messages.ts (OAuth headers, Claude Code system block, tool-name normalisation). The OAuth client is Claude Code's public client.

How this PR compares: like both systems, openab-agent keeps a single namespaced credential file (~/.openab/agent/auth.json) with atomic writes + per-refresh rotation handling, and an existing Codex tenant identical to OpenClaw's Codex flow. Unlike both, it adds a native Anthropic PKCE login so subscribers need neither a setup-token nor a local Claude Code install.

Proposed Solution

Add an anthropic-oauth tenant alongside the existing codex tenant in ~/.openab/agent/auth.json:

  • auth.rslogin_anthropic_browser_flow() (PKCE; verifier doubles as state per Claude's flow); namespaced token store (load/save/get_valid_token/force_refresh _for(provider)); per-provider refresh encoding (Anthropic = JSON, no scope; Codex = form); shared loopback-callback helpers; show_status lists all tenants.
  • llm.rsAnthropicProvider gains AnthropicAuth { ApiKey | OAuth }. OAuth mode sends Bearer + Claude Code identity headers (anthropic-beta: claude-code-20250219,oauth-2025-04-20, x-app: cli), prepends the required "You are Claude Code…" system block, normalises built-in tool names to Claude Code casing (read↔Read), and refreshes once on a mid-flight 401. select_provider gains anthropic-oauth; anthropic/auto fall back API-key → OAuth.
  • acp.rs — session/model selection via AnthropicProvider::auto*() (covers both auth modes); model catalog shows Anthropic models when an API key or OAuth token is present.
  • main.rsopenab-agent auth anthropic-oauth [--no-browser].

Also bumps the stale default model claude-sonnet-4-20250514claude-opus-4-8 (the old dated snapshot returns 404 on the subscription endpoint).

Why this approach?

  • Consistency: reuses the existing Codex OAuth tenant pattern in the same auth.json, so the two subscription logins coexist without new storage mechanisms.
  • Proven flow: endpoints/headers/system block are ported verbatim from Pi (a shipping Anthropic-OAuth client), de-risking the Claude-Code-beta specifics.
  • Refresh safety: each refresh persists the rotated refresh token (OpenClaw/Hermes both flag this as a sharp edge).

Tradeoffs / limitations: depends on Claude Code's public OAuth client and the claude-code-20250219,oauth-2025-04-20 beta headers — if Anthropic changes these, the OAuth path needs updating (API-key path is unaffected). The claude-opus-4-8 default now also applies to API-key mode (Opus is pricier per-token; overridable via OPENAB_AGENT_MODEL). The legacy Dockerfile.native is unrelated and intentionally out of scope (canonical Dockerfile.unified builds the native variant correctly).

Alternatives Considered

  • API key only (status quo): excludes every Pro/Max subscriber — the whole point.
  • Reuse Claude Code's local credential store (what Hermes does via ~/.claude/.credentials.json, and OpenClaw via claude -p): rejected — openab-agent runs in a pod with no Claude Code install and no ~/.claude. Owning an anthropic-oauth tenant in our own auth.json keeps it self-contained and matches the Codex tenant already present.
  • Setup-token paste (OpenClaw's other Anthropic path): rejected — a full PKCE login is a better UX (no manual token minting) and reuses the exact callback/paste plumbing the Codex flow already has.
  • Separate credential file per provider: rejected — the namespaced single auth.json already serves Codex + MCP; a new file would fragment storage and duplicate the atomic-write/rotation logic.

Validation

Rust:

  • cargo check / cargo build pass (0 warnings)
  • cargo test passes — 194 passed, incl. 4 new (authorize-URL, namespaced storage disjointness, OAuth request-body identity+tool-casing, name round-trip)
  • cargo clippy — no new warnings from this change (6 pre-existing in test-module ENV_LOCK-across-await + mcp/runtime.rs; the OAuth code is clippy-clean)

Manual (real Claude Pro/Max account):

  • auth anthropic-oauth --no-browser login → token stored under anthropic-oauth, auth status shows valid
  • Live chat on claude-opus-4-8 (default) / claude-sonnet-4-6 / 4-5 → correct responses
  • Real tool call — bash executes in-sandbox (echo …$((6*7))42), confirming tool-name normalisation round-trips
  • Token refresh — forced expiry → live JSON refresh succeeds, access+refresh tokens rotate, request still completes
  • Canonical image — built Dockerfile.unified --target native; ran the in-image agent end-to-end (chat + tool call) via the stored OAuth token

canyugs and others added 2 commits June 24, 2026 17:08
…ider

Phase 1 of porting Pi's auth methods into openab-agent. Adds an
`anthropic-oauth` tenant alongside Codex: PKCE browser/paste login against
platform.claude.com, JSON token exchange + scope-less refresh, and an OAuth
mode on AnthropicProvider (Bearer + Claude Code identity headers/system block,
tool-name normalisation). Wires provider selection in acp.rs/llm.rs and a new
`auth anthropic-oauth` CLI subcommand.

Verified: cargo build clean (0 warnings), 194 tests pass incl. 4 new.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fallback default was claude-sonnet-4-20250514 (Sonnet 4.0, ~13mo old),
which 404s on Claude Pro/Max OAuth subscriptions. Bump the three default-model
fallbacks to the current claude-opus-4-8 (verified live via OAuth). The model
catalog already listed it; only the fallback was stale.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@canyugs canyugs requested a review from thepagent as a code owner June 24, 2026 09:48
Copilot AI review requested due to automatic review settings June 24, 2026 09:48
@openab-app openab-app Bot added the closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. label Jun 24, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first-class Anthropic (Claude Pro/Max) OAuth login support to openab-agent, storing credentials as a new anthropic-oauth tenant alongside the existing codex tenant in ~/.openab/agent/auth.json. This extends provider auto-detection and request shaping so Claude subscription users can run the native agent without an ANTHROPIC_API_KEY.

Changes:

  • Introduces openab-agent auth anthropic-oauth [--no-browser] PKCE login flow and namespaced token load/save/refresh helpers.
  • Extends AnthropicProvider to support OAuth auth mode (Bearer + Claude Code identity headers/system block + tool-name casing normalization).
  • Updates ACP provider/model selection and available-model listing to recognize Anthropic OAuth credentials; bumps default Anthropic model to claude-opus-4-8.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
openab-agent/src/main.rs Adds the auth anthropic-oauth CLI subcommand wiring.
openab-agent/src/auth.rs Implements Anthropic PKCE/OAuth flow and namespaced token storage/refresh.
openab-agent/src/llm.rs Adds Anthropic OAuth request behavior (headers/system/tool name normalization) and provider selection updates.
openab-agent/src/acp.rs Uses Anthropic auto* selection (API key or OAuth), updates default model and model availability gating.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread openab-agent/src/auth.rs
Comment on lines +249 to 264
/// Load the LLM token stored under `namespace` (`codex` / `anthropic-oauth`).
pub fn load_tokens_for(namespace: &str) -> Result<TokenStore> {
let path = auth_path();
let map = read_auth_file(&path).map_err(|_| {
anyhow!(
"No credentials found at {}. Run `openab-agent auth codex-oauth` first.",
"No credentials found at {}. Run `openab-agent auth` first.",
path.display()
)
})?;
match map.get(CODEX_NAMESPACE) {
match map.get(namespace) {
Some(AuthEntry::Token(t)) => Ok(t.clone()),
_ => Err(anyhow!(
"No codex credentials in {}. Run `openab-agent auth codex-oauth` first.",
"No {namespace} credentials in {}. Run `openab-agent auth` first.",
path.display()
)),
}
Comment thread openab-agent/src/auth.rs Outdated
Comment on lines +656 to +658
/// Block on the loopback listener for the OAuth redirect, reply 200, return the
/// authorization code. ponytail: the Codex flow above predates this helper and
/// still inlines the same logic; fold it in if that path is ever touched again.
Comment thread openab-agent/src/llm.rs
Comment on lines +104 to 111
_ => match AnthropicProvider::auto() {
Ok(p) => Ok(Box::new(p)),
Err(_) => match OpenAiProvider::from_auth_store() {
Ok(p) => Ok(Box::new(p)),
Err(e) => Err(format!(
"No credentials: set ANTHROPIC_API_KEY or run `openab-agent auth codex-oauth`. {e}"
"No credentials: set ANTHROPIC_API_KEY, or run `openab-agent auth anthropic-oauth` / `auth codex-oauth`. {e}"
)),
},
Comment thread openab-agent/src/acp.rs
Comment on lines 347 to 351
return self.error_response(
id,
-32000,
&format!("No credentials: set ANTHROPIC_API_KEY or run `openab-agent auth codex-oauth`. {e}"),
&format!("No credentials: set ANTHROPIC_API_KEY, or run `openab-agent auth anthropic-oauth` / `auth codex-oauth`. {e}"),
)
@openab-app openab-app Bot removed the closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. label Jun 24, 2026
@chaodu-agent

This comment has been minimized.

@chaodu-agent

This comment has been minimized.

… UX)

- F1 (blocker): root Cargo.toml `exclude = ["openab-agent"]` so
  `cd openab-agent && cargo fmt/clippy/test` resolves standalone. The workspace
  restructure left openab-agent neither a member nor excluded; CI openab-agent
  only runs on openab-agent/** so it was dormant on main — this PR is the first
  change to trigger it. Also ran `cargo fmt`.
- F2: use an independent 32-byte random PKCE `state` instead of reusing the
  verifier, keeping the verifier back-channel-only (claude.ai rejects a short
  state as "Invalid request format"; 32 bytes matches the verifier length).
  Verified end-to-end with a real Pro/Max login + chat.
- F3: credential-error messages now name fully-qualified subcommands
  (`openab-agent auth anthropic-oauth` / `... codex-oauth`) and preserve the
  underlying read/parse error.
- F4: drop the `ponytail:` placeholder tag from a comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@canyugs

canyugs commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — addressed in 73bf9a2.

F1 (🔴 CI workspace) — fixed, with a root-cause correction. The "rebase onto main" suggestion doesn't apply: this branch is already based on current main (d1b0cfb), and the breakage is present-but-dormant there. ci-openab-agent.yml only runs on paths: openab-agent/**; its last run was 2026-06-17 (#1133) — before the workspace restructure (#1146/#1170) made the repo root a workspace without excluding openab-agent. Nothing has touched openab-agent/ on main since, so the check never re-ran — this PR is the first openab-agent/ change to expose it. Fix: exclude = ["openab-agent"] in the root Cargo.toml (the cargo error's own suggestion), plus cargo fmt. Locally the full CI sequence now passes: cargo fmt --check, cargo clippy -- -D warnings, cargo test (194), cargo test -- --ignored (11). Note: committing [workspace] into openab-agent/Cargo.toml instead would have collided with Dockerfile.unified's build-time [workspace] injection (duplicate-key error), so the root exclude is the non-conflicting fix.

F2 (🟡 PKCE state) — fixed + verified live. Now uses an independent 32-byte random state; the verifier stays back-channel-only. Worth recording: claude.ai's authorize rejects a short independent state with Invalid request format, so the state is sized to match the verifier (32 bytes) — value still independent. Verified end-to-end against a real Pro/Max account (browser login → token exchange with state ≠ verifier → live chat).

F3 (🟡 error UX) — fixed. Credential errors now name fully-qualified subcommands (openab-agent auth anthropic-oauth / openab-agent auth codex-oauth) and preserve the underlying read/parse error.

F4 (🟡 comment tag) — fixed. ponytail:Note:.

Also confirmed the canonical native image still builds: Dockerfile.unified --target native (the path build-images.yml uses) builds clean and runs the OAuth flow end-to-end in-container. The legacy Dockerfile.native is a separate pre-existing issue (missing the [workspace] injection that Dockerfile.unified does) and is intentionally out of scope here.

@canyugs canyugs closed this Jun 24, 2026
@canyugs canyugs reopened this Jun 24, 2026
@canyugs canyugs closed this Jun 24, 2026
@canyugs canyugs reopened this Jun 24, 2026
The dispatch loop fed responses to a detached stdout-drain task; on stdin EOF
the loop ended and `#[tokio::main]` aborted the drain before it flushed the
last queued line, so a one-shot `initialize` could return nothing. This was a
latent race (main wins it by timing); this branch's slightly different startup
timing made the binary lose it ~85% locally, surfacing as the red `CI
openab-agent` ACP smoke test. Capture the drain handle and, after the loop,
drop the senders and bounded-await the drain so queued output is flushed before
return. Race test: 20/20 after (was 3/20).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@canyugs

canyugs commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up: the CI openab-agent check went green only after one more fix (4ef7c49).

With the workspace exclude in place, fmt/clippy/test ran and passed, but the ACP initialize smoke test then failed (empty response). Root cause: a latent flush-on-shutdown race — the dispatch loop fed responses to a detached stdout-drain task, and on stdin-EOF #[tokio::main] aborted the drain before it flushed the last line. main wins this race by timing; this branch's slightly different startup timing lost it ~85% of the time locally (origin/main binary: 20/20 pass; this branch pre-fix: 3/20). Fix captures the drain handle and bounded-awaits it after the loop so queued output is flushed before return — 20/20 after. All CI openab-agent steps now pass.

@chaodu-agent

This comment has been minimized.

canyugs and others added 2 commits June 24, 2026 22:50
Non-blocking polish from the PR re-review:

- #6 (acp.rs): on ACP model switch, an OAuth-forced session was rebuilt
  via `auto_with_model`, which prefers ANTHROPIC_API_KEY and silently
  dropped the forced anthropic-oauth provider when a key was also present.
  Rebuild now preserves the session's auth mode via a new
  LlmProvider::is_oauth() (Agent::provider_is_oauth()).

- #7 (llm.rs): the OAuth 401 branch swallowed force_refresh_for errors
  (`let _ = ...`) and retried with the stale token. Bubble the error.

- #11 (auth.rs): refresh_token failure message named bare
  `openab-agent auth`; now names the tenant subcommand via a shared
  auth_subcommand() helper (also dedupes load_tokens_for).

Deferred as follow-up (noted in PR): #8 --no-browser state validation,
#9 save_tokens_for keying, #10 non-Unix atomic write.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XHjGw1uBHXoVPB3dXYEy7h
`--no-browser` bare-code paste defaulted the pasted state to the expected
value when no `#state` was present, so the `st != state` check passed
trivially and CSRF state was never verified. Require the `code#state`
form (or a full redirect URL) and reject a bare code with a clear message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XHjGw1uBHXoVPB3dXYEy7h
@chaodu-agent

This comment has been minimized.

@brettchien

Copy link
Copy Markdown
Contributor

Supplementary architectural review (forward-looking — this PR is already LGTM'd and the line-level items
are well covered by the automated review + the author's fixes; CI is green, state is now independent, the
workspace exclude is in). None of the below blocks merging — they're direction/follow-up points for the
planned OAuth revamp + a forthcoming ADR, worth anchoring on record before this lands as the first vendor.

Findings

# Severity Finding Location
1 🟡 auth.json read-modify-write is unlocked across processes — concurrent refresh can trip OAuth 2.1 §10.4 token-family revocation. Not introduced here; follow-up, not blocking. auth.rs storage layer
2 🟢 Add CLAUDE_CODE_OAUTH_TOKEN env support — unblocks headless/pod/CI now, and is race-free llm.rs / auth.rs
3 🟢 Keep per-vendor specifics in a descriptor (sets up the shared multi-vendor adapter as a refactor, not a rewrite) auth.rs / llm.rs
4 🟡 Hardcoded default claude-opus-4-8 is a recurring-staleness/404 risk (no evergreen alias in 4.6+) — recommend dropping the hardcoded default and requiring the model via config/env (fail-loud) llm.rs:152, acp.rs:385/446
5 🟢 Stale doc comment still says "PKCE with the verifier doubling as state" though the code now uses an independent state auth.rs:692
6 🟢 getrandom::fill(...).expect(...) in a Result-returning fn → prefer ? auth.rs:461/472
Detail — F1: cross-process auth.json race (follow-up)

auth.json is multi-tenant (codex / anthropic-oauth / mcp:*) and every writer does
read_auth_file → mutate → write_auth_file with no lock. openab-agent runs one process per session
thread
(SessionPoolconnection.rs spawns a child per thread), so concurrent threads = concurrent
processes doing that RMW. When a token expires, multiple processes refresh in the same window; with
refresh-token rotation the later ones present an already-rotated token, which under OAuth 2.1 §10.4 reuse
detection can revoke the whole token family → every session logged out at once.

This is not introduced by this PR (Codex already shares the same store), but OAuth widens the exposure,
and API-key users never hit it (no refresh writes). Not merge-blocking for this PR. The fix — a
flock-guarded RMW funnel that all writers (including the MCP CredentialStore) share, with the network
refresh done outside the lock + a re-read inside for single-flight — is planned for the revamp PR. Until
then, F2 (env token) gives a race-free path for fleet/pod use.

Detail — F4: default model staleness

This PR replaces the old default claude-sonnet-4-20250514 precisely because it 404s on the subscription
endpoint
(retired there). The replacement claude-opus-4-8 will eventually hit the same fate when it's
retired from the Claude Code surface — i.e. this is a recurring 404 timebomb, not a one-off. And there's no
escape via a floating alias: in the 4.6+ generation, dateless IDs (claude-opus-4-8, claude-sonnet-4-6)
are fixed canonical IDs, not evergreen pointers
— Anthropic ships new versions under new IDs and never
re-points an old one. So the default must be bumped by hand each generation.

Recommendation — no hardcoded model default; require it via config/env and fail loud. Since Messages V1
mandates a model and there is no evergreen alias to fall back to, any in-code default is a 404 timebomb.
Resolve model as: ACP/CLI model_overrideOPENAB_AGENT_MODELerror ("no model configured; set
OPENAB_AGENT_MODEL or select one") instead of the hardcoded claude-opus-4-8. This also removes the
silent Opus cost bump for API-key users.

Note this is a behavior change: today's zero-config default goes away, so a model must be set
(deployments via values.yaml/env already do; zero-config npx/local users will need to set
OPENAB_AGENT_MODEL). Worth a clear error message + CHANGELOG line. Drops the three duplicated default
sites (llm.rs:152, acp.rs:385/446).

Detail — F2: support CLAUDE_CODE_OAUTH_TOKEN

claude setup-token mints a 1-year subscription OAuth token. Reading it as a Bearer source — precedence
ANTHROPIC_API_KEYCLAUDE_CODE_OAUTH_TOKEN → stored anthropic-oauth tenant (same shape as pi,
earendil-works/pi#3591) — is only a few lines and:

  • makes Claude subscription auth usable in headless / pod / CI today (no browser, no loopback, no stdin
    paste), which the interactive PKCE flow can't cover in a container; and
  • is race-free (env token, no auth.json refresh writes) — see F1.

For ops-managed deployments this is arguably the primary path; interactive PKCE is for local self-service.

Detail — F3: per-vendor descriptor

client_id / client_secret / endpoints / scope / token-body-format are the only things that vary between
Codex, Anthropic, and upcoming vendors (Gemini, agy, grok…). Holding them in a small per-vendor struct now
(rather than inlined in the Anthropic flow) makes the planned shared OAuth adapter a clean extraction
instead of a rewrite. No need to build the adapter in this PR — just don't entrench Claude-only assumptions.

Direction / roadmap (tracked in a forthcoming ADR)

A short ADR is being drafted for multi-vendor LLM-provider OAuth + credential storage; this PR is the first
vendor under it. Explicitly out of scope here, listed so the follow-ups are on record:

@brettchien

Copy link
Copy Markdown
Contributor

Follow-up on F4 — one thing worth doing in this PR before it merges: please don't pin claude-opus-4-8 as a hardcoded default.

This PR exists because the previous hardcoded default (claude-sonnet-4-20250514) 404'd on the subscription endpoint after retirement — and claude-opus-4-8 will eventually hit the same fate. There's no escape via an alias: in the 4.6+ generation, dateless IDs are fixed canonical IDs, not evergreen pointers, so a hardcoded default is a recurring 404 timebomb that has to be chased by hand every generation.

Since Messages V1 mandates a model, the cleanest fix is to require it and fail loud rather than bake in a new pin:

  • resolve model as ACP/CLI model_overrideOPENAB_AGENT_MODELerror ("no model configured; set OPENAB_AGENT_MODEL or select one")
  • drop the three hardcoded default sites: llm.rs:153, acp.rs:385, acp.rs:446
  • (the ModelOption catalog entry at acp.rs:509 is fine — that's offering the model, not defaulting to it)

It's a small change, and it also removes the silent Opus cost bump for API-key users. Behavior note: this drops the zero-config default, so a model must be set — deployments via values.yaml/env already do; worth a clear error message + CHANGELOG line for zero-config/local users.

brettchien added a commit to brettchien/openab that referenced this pull request Jun 24, 2026
Proposed ADR for the openab-agent LLM-provider OAuth revamp: a two-axis
OAuthVendor adapter (auth flow vs inference transport), a cross-process
flock-guarded credential-store invariant for auth.json, the
CLAUDE_CODE_OAUTH_TOKEN env route, a 14-variant vendor feasibility matrix,
and the /auth (PR openabdev#1185) auth-trigger model. Surfaced while reviewing
PR openabdev#1187 (first OAuth vendor).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per @brettchien: dateless 4.6+ model IDs are fixed canonical IDs, not
evergreen pointers, so a hardcoded default (claude-opus-4-8) is a
per-generation 404 timebomb — the same failure that retired the previous
claude-sonnet-4-20250514 default. It also silently bumped API-key users
onto pricier Opus (review #5).

Resolve the Anthropic model as: explicit override → OPENAB_AGENT_MODEL →
error ("no model configured; set OPENAB_AGENT_MODEL or select a model").

- llm.rs: `anthropic_model()` is now fallible (no default); constructors
  refactored (`build`/`api_key_from_env`/`ensure_oauth_token`) so a model
  override never requires OPENAB_AGENT_MODEL, and credential errors still
  precede the model error. `auto()` only falls through to OAuth when no
  API key is present.
- acp.rs: session new/load report the provider's resolved model instead
  of a hardcoded fallback. Removed the opus/gpt default sites.
- Kept the claude-opus-4-8 entry in the model catalog (offering ≠ default).
- docs/native-agent.md: document OPENAB_AGENT_MODEL is required for
  Anthropic (zero-config now fails loud).

Behavior change: no zero-config default model. Deployments set it via
env/values.yaml; local/zero-config users must export OPENAB_AGENT_MODEL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XHjGw1uBHXoVPB3dXYEy7h
@canyugs

canyugs commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

@brettchien Great call — this is exactly the right framing, thank you. The dateless 4.6+ IDs being fixed canonical IDs (not evergreen pointers) makes any hardcoded default a recurring 404 timebomb, and pinning Opus also quietly raised costs for API-key users. Implemented your fail-loud approach in b28c312:

  • Model resolution is now: explicit ACP/CLI override → OPENAB_AGENT_MODELerror (no model configured; set OPENAB_AGENT_MODEL or select a model). No baked-in default.
  • Dropped the three hardcoded sites (llm.rs anthropic_model_from_env, acp.rs session new/load). anthropic_model() is now fallible; session new/load report the provider's actually-resolved model instead of a fallback.
  • Kept the claude-opus-4-8 entry in the model catalog (acp.rs) — offering, not defaulting, as you noted.
  • Two implementation details worth flagging: a model override (CLI/ACP) does not require OPENAB_AGENT_MODEL, and credentials are still checked before the model so a missing key/token surfaces its own error rather than the generic "no model configured". auto() only falls through to OAuth when no API key is present.
  • Behavior note + CHANGELOG-equivalent: documented in docs/native-agent.md that OPENAB_AGENT_MODEL is required for Anthropic — zero-config now fails loud with a clear message; env/values.yaml deployments are unaffected.

Tests: cargo fmt/clippy -D warnings clean, cargo test 196 + 11 ignored. Added test_session_new_requires_model to lock in the fail-loud contract. This also resolves the earlier #5 (silent Opus cost bump for API-key users).

@chaodu-agent

This comment has been minimized.

The doc comment on login_anthropic_browser_flow still said the verifier
doubles as `state` (Pi's old convention); since the PKCE fix the state is
an independent 32-byte random value. Correct the comment to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XHjGw1uBHXoVPB3dXYEy7h
@chaodu-agent

This comment has been minimized.

@chaodu-agent

This comment has been minimized.

…ODEL

ModelRef::parse + resolve_provider_choice let OPENAB_AGENT_MODEL carry
`provider/model_id` (e.g. anthropic/claude-sonnet-4-6) as a single source
of truth for both provider and model. Bare model ids and the existing
OPENAB_AGENT_PROVIDER var remain fully backward compatible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@chaodu-agent

This comment has been minimized.

Stable clippy 1.96 added manual_is_multiple_of; pre-existing modulo checks
in openab-core and openab-gateway now fail `clippy --workspace -D warnings`.
Mechanical fix; unblocks CI. Unrelated to the OAuth change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@chaodu-agent

Copy link
Copy Markdown
Collaborator

LGTM ✅ — Two clean follow-up commits: ModelRef parser (architectural review F1) and Rust 1.96 clippy fix. All CI now green.

What This PR Does

Adds native Anthropic OAuth (Claude Pro/Max) subscription login to openab-agent (openab-agent auth anthropic-oauth [--no-browser]), stored as a new anthropic-oauth tenant beside the existing codex tenant in auth.json. Also removes hardcoded model defaults in favour of fail-loud OPENAB_AGENT_MODEL requirement, and adds a canonical provider/model_id format parser.

How It Works

  • PKCE browser flow with independent random CSRF state (verifier stays back-channel-only) against claude.ai/oauth/authorize + platform.claude.com/v1/oauth/token
  • AnthropicProvider gains AnthropicAuth::{ApiKey, OAuth}. OAuth mode: Bearer + Claude Code identity headers, system block, tool-name normalization (read↔Read)
  • Provider auto-detection: ANTHROPIC_API_KEY → stored anthropic-oauth → codex OAuth
  • Model resolution: ACP/CLI override → OPENAB_AGENT_MODELerror (no hardcoded default)
  • ModelRef::parse() accepts both provider/model_id (canonical) and bare model_id (legacy); resolve_provider_choice() infers provider from the env var prefix

Findings

# Severity Finding Location
1 🟢 ModelRef parser correctly splits on first /, guards against degenerate cases (empty provider/model), strips prefix before hitting the API llm.rs:100-130
2 🟢 resolve_provider_choice() unifies OPENAB_AGENT_PROVIDER and provider/ prefix of OPENAB_AGENT_MODEL into a single selection path llm.rs:132-142
3 🟢 clippy manual_is_multiple_of fix uses the idiomatic .is_multiple_of() — simple, correct, unblocks CI format.rs:322, wecom.rs:140
4 🟢 PKCE with independent random state, fail-loud model resolution, namespace-keyed multi-tenant auth, OAuth-mode preservation on model switch, bounded drain-flush entire PR
What's Good (🟢)
  • ModelRef addresses the architectural review's F1 request cleanly — typed parser, unit-tested (parse + provider-strip), no over-engineering
  • resolve_provider_choice() eliminates duplicated env-resolution logic in acp.rs and default_provider()
  • clippy fix is minimal and correct — only changes the two sites flagged by Rust 1.96
  • All previous review findings remain addressed (CI workspace, PKCE state, error UX, OAuth-mode preservation, 401 refresh propagation, bare-code state verification, shutdown race)
  • Strong test coverage: 196+ tests green including the new test_model_ref_parse and test_provider_build_strips_prefix
Addressing External Reviewer Feedback

@brettchien (Architectural Review)

F1 (🟡 ModelRef parser/normalizer): Accept provider/model_id and legacy bare IDs

Addressed in c0292fc: ModelRef::parse() splits on first /; resolve_provider_choice() extracts provider from OPENAB_AGENT_MODEL prefix. Both Anthropic and OpenAI providers strip the prefix via ModelRef before calling the API.

F4 (🟡 hardcoded default model → 404 timebomb)

Addressed in b28c312: no hardcoded default; OPENAB_AGENT_MODEL required for Anthropic.

F1 (cross-process race), F2 (CLAUDE_CODE_OAUTH_TOKEN), F3 (per-vendor descriptor), F5 (doc comment), F6 (getrandom): Follow-ups / non-blocking.

ℹ️ Accepted: Tracked for follow-up ADR/revamp.

@Copilot (Automated Review)

All findings addressed in earlier commits.

Baseline Check
  • Reviewed at e24156b (9 commits; prior LGTM at 5b768a6)
  • Delta since last review: ModelRef parser + resolve_provider_choice() (addresses architectural F1), clippy fix for Rust 1.96 lint in openab-core/openab-gateway (unblocks CI)
  • Main already had: Codex OAuth, API-key Anthropic, multi-entry auth.json
  • Net-new value: native Anthropic subscription OAuth + ModelRef canonical format + fail-loud model resolution

Reviewed at e24156b | CI: all checks ✅ (clippy fix included) | Diff

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(native-agent): Anthropic OAuth (Claude Pro/Max) login for openab-agent

4 participants