From 06e90fffb9b82535977d87f5857eadf0cc19b2d3 Mon Sep 17 00:00:00 2001 From: Can Yu Date: Wed, 24 Jun 2026 12:26:13 +0800 Subject: [PATCH 1/9] feat(native-agent): add Anthropic OAuth (Claude Pro/Max) login + provider 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) --- openab-agent/src/acp.rs | 28 ++-- openab-agent/src/auth.rs | 351 +++++++++++++++++++++++++++++++++------ openab-agent/src/llm.rs | 225 +++++++++++++++++++++---- openab-agent/src/main.rs | 12 ++ 4 files changed, 525 insertions(+), 91 deletions(-) diff --git a/openab-agent/src/acp.rs b/openab-agent/src/acp.rs index 1113c8ad4..0d866812f 100644 --- a/openab-agent/src/acp.rs +++ b/openab-agent/src/acp.rs @@ -302,10 +302,14 @@ impl AcpServer { let model_override = self.active_model.as_deref(); let (provider, active_provider): (Box, &str) = match provider_choice.as_str() { - "anthropic" => { - let res = match model_override { - Some(m) => AnthropicProvider::from_env_with_model(m), - None => AnthropicProvider::from_env(), + // `auto*` covers both ANTHROPIC_API_KEY and a stored Claude + // subscription OAuth token; `anthropic-oauth` forces the latter. + "anthropic" | "anthropic-oauth" | "claude" => { + let res = match (provider_choice.as_str(), model_override) { + ("anthropic", Some(m)) => AnthropicProvider::auto_with_model(m), + ("anthropic", None) => AnthropicProvider::auto(), + (_, Some(m)) => AnthropicProvider::from_oauth_store_with_model(m), + (_, None) => AnthropicProvider::from_oauth_store(), }; match res { Ok(p) => (Box::new(p), "anthropic"), @@ -323,10 +327,10 @@ impl AcpServer { } } _ => { - // Auto-detect: try API key first, then OAuth token + // Auto-detect: Anthropic (API key or OAuth) first, then codex. let anthropic_res = match model_override { - Some(m) => AnthropicProvider::from_env_with_model(m), - None => AnthropicProvider::from_env(), + Some(m) => AnthropicProvider::auto_with_model(m), + None => AnthropicProvider::auto(), }; match anthropic_res { Ok(p) => (Box::new(p), "anthropic"), @@ -343,7 +347,7 @@ impl AcpServer { 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}"), ) } } @@ -457,7 +461,9 @@ impl AcpServer { fn static_available_models() -> Vec { let mut models = Vec::new(); - if std::env::var("ANTHROPIC_API_KEY").is_ok() { + if std::env::var("ANTHROPIC_API_KEY").is_ok() + || crate::auth::load_tokens_for(crate::auth::ANTHROPIC_NAMESPACE).is_ok() + { models.extend(Self::static_anthropic_models()); } if crate::auth::load_tokens().is_ok() { @@ -598,7 +604,7 @@ impl AcpServer { let new_provider: Result, String> = match provider_name { "anthropic" => { - AnthropicProvider::from_env_with_model(value).map(|p| Box::new(p) as _) + AnthropicProvider::auto_with_model(value).map(|p| Box::new(p) as _) } _ => crate::llm::OpenAiProvider::from_auth_store_with_model(value) .map(|p| Box::new(p) as _), @@ -918,7 +924,7 @@ mod tests { // Insert a dummy session using anthropic key unsafe { std::env::set_var("ANTHROPIC_API_KEY", "test-key") }; - let provider = AnthropicProvider::from_env_with_model("claude-sonnet-4-20250514").unwrap(); + let provider = AnthropicProvider::auto_with_model("claude-sonnet-4-20250514").unwrap(); let agent = Agent::new_boxed(Box::new(provider), "/tmp".to_string(), None); server.sessions.insert("test-session".to_string(), agent); diff --git a/openab-agent/src/auth.rs b/openab-agent/src/auth.rs index d9a701237..ff71ad6ee 100644 --- a/openab-agent/src/auth.rs +++ b/openab-agent/src/auth.rs @@ -12,6 +12,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; /// Namespace key for the existing Codex single-tenant credential. /// Lives next to future `mcp:` entries inside `auth.json`. const CODEX_NAMESPACE: &str = "codex"; +/// Namespace key for the Anthropic (Claude Pro/Max) OAuth credential. +pub const ANTHROPIC_NAMESPACE: &str = "anthropic-oauth"; const REFRESH_SKEW_SECONDS: u64 = 120; @@ -22,15 +24,45 @@ const CODEX_DEVICE_TOKEN_URL: &str = "https://auth.openai.com/api/accounts/devic const CODEX_DEVICE_REDIRECT_URI: &str = "https://auth.openai.com/deviceauth/callback"; const REDIRECT_PORT: u16 = 1455; +// Anthropic OAuth (Claude Pro/Max). Values mirror Claude Code's public client so +// `platform.claude.com` accepts the flow. Token bodies are JSON (Codex uses form) +// and the refresh body omits `scope` (Pi #2169). +const ANTHROPIC_AUTHORIZE_URL: &str = "https://claude.ai/oauth/authorize"; +const ANTHROPIC_TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token"; +const ANTHROPIC_REDIRECT_PORT: u16 = 53692; +const ANTHROPIC_SCOPE: &str = + "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"; + fn codex_client_id() -> String { std::env::var("OPENAB_AGENT_OAUTH_CLIENT_ID") .unwrap_or_else(|_| "app_EMoamEEZ73f0CkXaXp7hrann".to_string()) } +fn anthropic_client_id() -> String { + std::env::var("OPENAB_AGENT_ANTHROPIC_CLIENT_ID") + .unwrap_or_else(|_| "9d1c250a-e61b-44d9-88ed-5944d1962f5e".to_string()) +} + fn redirect_uri() -> String { format!("http://localhost:{REDIRECT_PORT}/auth/callback") } +fn anthropic_redirect_uri() -> String { + format!("http://localhost:{ANTHROPIC_REDIRECT_PORT}/callback") +} + +/// Build the Anthropic authorize URL. Pure so it can be unit-tested. Pi reuses +/// the PKCE verifier as the `state` value, so callers pass `state == verifier`. +fn anthropic_authorize_url(challenge: &str, state: &str) -> String { + let client_id = anthropic_client_id(); + let redirect = anthropic_redirect_uri(); + let redir = urlencoding::encode(&redirect); + let scope = urlencoding::encode(ANTHROPIC_SCOPE); + format!( + "{ANTHROPIC_AUTHORIZE_URL}?code=true&client_id={client_id}&response_type=code&redirect_uri={redir}&scope={scope}&code_challenge={challenge}&code_challenge_method=S256&state={state}" + ) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TokenStore { pub access_token: String, @@ -214,30 +246,37 @@ fn write_auth_file(path: &Path, map: &HashMap) -> Result<()> Ok(()) } -pub fn load_tokens() -> Result { +/// Load the LLM token stored under `namespace` (`codex` / `anthropic-oauth`). +pub fn load_tokens_for(namespace: &str) -> Result { 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() )), } } -fn save_tokens(store: &TokenStore) -> Result<()> { +/// Save a token under its own `provider` field as the namespace key, leaving +/// every other tenant in `auth.json` untouched. +fn save_tokens_for(store: &TokenStore) -> Result<()> { let path = auth_path(); let mut map = read_auth_file(&path).unwrap_or_default(); - map.insert(CODEX_NAMESPACE.to_string(), AuthEntry::Token(store.clone())); + map.insert(store.provider.clone(), AuthEntry::Token(store.clone())); write_auth_file(&path, &map) } +pub fn load_tokens() -> Result { + load_tokens_for(CODEX_NAMESPACE) +} + /// rmcp [`CredentialStore`] backed by the shared `auth.json` file (ADR §6.1 /// storage-format decision A). One instance is bound to a single MCP server's /// bare-name key (e.g. `linear`); rmcp's `AuthorizationManager` owns the @@ -328,38 +367,60 @@ impl CredentialStore for McpCredentialStore { } } -pub async fn get_valid_token() -> Result { - let mut store = load_tokens()?; +pub async fn get_valid_token_for(namespace: &str) -> Result { + let mut store = load_tokens_for(namespace)?; if store.is_expired() { store = refresh_token(&store).await?; - save_tokens(&store)?; + save_tokens_for(&store)?; } Ok(store.access_token) } -pub async fn force_refresh() -> Result { - let store = load_tokens()?; +pub async fn force_refresh_for(namespace: &str) -> Result { + let store = load_tokens_for(namespace)?; let new_store = refresh_token(&store).await?; - save_tokens(&new_store)?; + save_tokens_for(&new_store)?; Ok(new_store.access_token) } +pub async fn get_valid_token() -> Result { + get_valid_token_for(CODEX_NAMESPACE).await +} + +pub async fn force_refresh() -> Result { + force_refresh_for(CODEX_NAMESPACE).await +} + async fn refresh_token(store: &TokenStore) -> Result { - let client_id = codex_client_id(); let client = reqwest::Client::new(); - let resp = client - .post(&store.token_endpoint) - .form(&[ - ("grant_type", "refresh_token"), - ("refresh_token", store.refresh_token.as_str()), - ("client_id", client_id.as_str()), - ]) - .send() - .await?; + // Anthropic's token endpoint takes a JSON body and rejects a `scope` field + // on refresh (Pi #2169); Codex takes a form body. Branch on the stored + // provider so each tenant refreshes the way its AS expects. + let resp = if store.provider == ANTHROPIC_NAMESPACE { + client + .post(&store.token_endpoint) + .json(&serde_json::json!({ + "grant_type": "refresh_token", + "refresh_token": store.refresh_token, + "client_id": anthropic_client_id(), + })) + .send() + .await? + } else { + client + .post(&store.token_endpoint) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", store.refresh_token.as_str()), + ("client_id", codex_client_id().as_str()), + ]) + .send() + .await? + }; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - return Err(anyhow!("Token refresh failed (HTTP {status}): {body}. Run `openab-agent auth codex-oauth` again.")); + return Err(anyhow!("Token refresh failed (HTTP {status}): {body}. Run `openab-agent auth` again.")); } let payload: serde_json::Value = resp.json().await?; let access_token = payload["access_token"] @@ -474,7 +535,7 @@ pub async fn login_browser_flow(no_browser: bool) -> Result<()> { token_endpoint: CODEX_TOKEN_URL.to_string(), provider: "codex".to_string(), }; - save_tokens(&store)?; + save_tokens_for(&store)?; println!( "\n\u{2705} Login successful! Token saved to {:?}", auth_path() @@ -557,7 +618,105 @@ pub async fn login_browser_flow(no_browser: bool) -> Result<()> { token_endpoint: CODEX_TOKEN_URL.to_string(), provider: "codex".to_string(), }; - save_tokens(&store)?; + save_tokens_for(&store)?; + println!( + "\n\u{2705} Login successful! Token saved to {:?}", + auth_path() + ); + Ok(()) +} + +/// Extract the OAuth `code` from a parsed redirect URL, validating `state`. +/// Shared by every loopback-callback OAuth flow. +fn code_from_redirect(url: &url::Url, expected_state: &str) -> Result { + let code = url + .query_pairs() + .find(|(k, _)| k == "code") + .map(|(_, v)| v.to_string()) + .ok_or_else(|| { + let error = url + .query_pairs() + .find(|(k, _)| k == "error") + .map(|(_, v)| v.to_string()); + anyhow!( + "No code in redirect. Error: {}", + error.unwrap_or_else(|| "unknown".into()) + ) + })?; + let cb_state = url + .query_pairs() + .find(|(k, _)| k == "state") + .map(|(_, v)| v.to_string()); + if cb_state.as_deref() != Some(expected_state) { + return Err(anyhow!("State mismatch")); + } + Ok(code) +} + +/// 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. +fn accept_callback_code(listener: &TcpListener, expected_state: &str) -> Result { + listener.set_nonblocking(false)?; + let (mut stream, _) = listener + .accept() + .map_err(|e| anyhow!("Failed to accept callback: {e}"))?; + let mut reader = std::io::BufReader::new(&stream); + let mut request_line = String::new(); + reader.read_line(&mut request_line)?; + let path = request_line.split_whitespace().nth(1).unwrap_or(""); + let url = url::Url::parse(&format!("http://localhost{path}")) + .map_err(|_| anyhow!("Invalid callback URL"))?; + let code = code_from_redirect(&url, expected_state)?; + let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n

Authentication successful!

You can close this tab.

"; + let _ = stream.write_all(response.as_bytes()); + Ok(code) +} + +/// Anthropic OAuth (Claude Pro/Max). PKCE with the verifier doubling as `state` +/// (Pi's convention) and a JSON token exchange against `platform.claude.com`. +pub async fn login_anthropic_browser_flow(no_browser: bool) -> Result<()> { + let (verifier, challenge) = generate_pkce(); + let state = verifier.clone(); // Pi reuses the verifier as the state value + let auth_url = anthropic_authorize_url(&challenge, &state); + + let code = if no_browser { + println!("Open this URL in your browser:\n\n {auth_url}\n"); + println!("After approving, copy the full redirect URL (or just the code) and paste it here:\n"); + let mut input = String::new(); + std::io::stdin() + .read_line(&mut input) + .map_err(|e| anyhow!("Failed to read input: {e}"))?; + let input = input.trim(); + if input.is_empty() { + return Err(anyhow!("No URL provided")); + } + // Accept either a full redirect URL or a bare `code` / `code#state`. + if let Ok(url) = url::Url::parse(input) { + code_from_redirect(&url, &state)? + } else { + let (code, st) = input.split_once('#').unwrap_or((input, state.as_str())); + if st != state { + return Err(anyhow!("State mismatch")); + } + code.to_string() + } + } else { + let listener = TcpListener::bind(format!("127.0.0.1:{ANTHROPIC_REDIRECT_PORT}")).map_err( + |e| { + anyhow!("Failed to bind port {ANTHROPIC_REDIRECT_PORT}: {e}. Is another instance running?") + }, + )?; + println!("Opening browser for authentication...\n"); + if open::that(&auth_url).is_err() { + println!("Could not open browser. Open this URL manually:\n\n {auth_url}\n"); + } + println!("Waiting for callback..."); + accept_callback_code(&listener, &state)? + }; + + let store = exchange_anthropic_code(&code, &state, &verifier).await?; + save_tokens_for(&store)?; println!( "\n\u{2705} Login successful! Token saved to {:?}", auth_path() @@ -565,6 +724,42 @@ pub async fn login_browser_flow(no_browser: bool) -> Result<()> { Ok(()) } +async fn exchange_anthropic_code(code: &str, state: &str, verifier: &str) -> Result { + let client = reqwest::Client::new(); + let resp = client + .post(ANTHROPIC_TOKEN_URL) + .json(&serde_json::json!({ + "grant_type": "authorization_code", + "client_id": anthropic_client_id(), + "code": code, + "state": state, + "redirect_uri": anthropic_redirect_uri(), + "code_verifier": verifier, + })) + .send() + .await?; + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Token exchange failed: {body}")); + } + let payload: serde_json::Value = resp.json().await?; + let access_token = payload["access_token"] + .as_str() + .ok_or_else(|| anyhow!("No access_token"))?; + let refresh_token_val = payload["refresh_token"] + .as_str() + .ok_or_else(|| anyhow!("No refresh_token"))?; + let expires_in = payload["expires_in"].as_u64().unwrap_or(3600); + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + Ok(TokenStore { + access_token: access_token.to_string(), + refresh_token: refresh_token_val.to_string(), + expires_at: now + expires_in, + token_endpoint: ANTHROPIC_TOKEN_URL.to_string(), + provider: ANTHROPIC_NAMESPACE.to_string(), + }) +} + // Device code flow pub async fn login_codex_device_flow() -> Result<()> { println!("Starting OpenAI Codex device-code login...\n"); @@ -649,7 +844,7 @@ pub async fn login_codex_device_flow() -> Result<()> { token_endpoint: CODEX_TOKEN_URL.to_string(), provider: "codex".to_string(), }; - save_tokens(&store)?; + save_tokens_for(&store)?; println!( "\n\u{2705} Login successful! Token saved to {:?}", auth_path() @@ -681,31 +876,49 @@ pub async fn login_codex_device_flow() -> Result<()> { } pub fn show_status() { - match load_tokens() { - Ok(store) => { - let expired = store.is_expired(); - let masked = if store.access_token.len() > 12 { - format!( - "{}...{}", - &store.access_token[..8], - &store.access_token[store.access_token.len() - 4..] - ) - } else { - "****".to_string() - }; - println!("Provider: {}", store.provider); - println!("Token: {}", masked); - println!( - "Expires: {} ({})", - store.expires_at, - if expired { "EXPIRED" } else { "valid" } - ); - println!("File: {:?}", auth_path()); - } - Err(e) => { - println!("Not authenticated: {e}\nRun: openab-agent auth codex-oauth"); - } + let path = auth_path(); + let tokens: Vec = read_auth_file(&path) + .map(|map| { + let mut v: Vec = map + .into_values() + .filter_map(|e| match e { + AuthEntry::Token(t) => Some(t), + _ => None, + }) + .collect(); + v.sort_by(|a, b| a.provider.cmp(&b.provider)); + v + }) + .unwrap_or_default(); + + if tokens.is_empty() { + println!( + "Not authenticated.\nRun: openab-agent auth codex-oauth | openab-agent auth anthropic-oauth" + ); + return; + } + + for store in tokens { + let expired = store.is_expired(); + let masked = if store.access_token.len() > 12 { + format!( + "{}...{}", + &store.access_token[..8], + &store.access_token[store.access_token.len() - 4..] + ) + } else { + "****".to_string() + }; + println!("Provider: {}", store.provider); + println!("Token: {}", masked); + println!( + "Expires: {} ({})", + store.expires_at, + if expired { "EXPIRED" } else { "valid" } + ); + println!(); } + println!("File: {:?}", path); } #[cfg(test)] @@ -779,6 +992,44 @@ mod tests { assert_eq!(challenge, expected); } + #[test] + fn test_anthropic_authorize_url_carries_required_params() { + temp_env::with_var("OPENAB_AGENT_ANTHROPIC_CLIENT_ID", None::<&str>, || { + let url = anthropic_authorize_url("CHAL", "STATE"); + assert!(url.starts_with("https://claude.ai/oauth/authorize?")); + assert!(url.contains("client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e")); + assert!(url.contains("response_type=code")); + assert!(url.contains("code_challenge=CHAL")); + assert!(url.contains("code_challenge_method=S256")); + assert!(url.contains("state=STATE")); + // scope is url-encoded; spot-check one encoded scope token + assert!(url.contains("user%3Ainference")); + // redirect must be the loopback callback on the Anthropic port + assert!(url.contains("localhost%3A53692%2Fcallback")); + }); + } + + #[test] + fn test_anthropic_save_uses_provider_as_key_disjoint_from_codex() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth.json"); + let mut codex = make_store(1); + codex.provider = "codex".to_string(); + let mut anth = make_store(2); + anth.provider = ANTHROPIC_NAMESPACE.to_string(); + anth.access_token = "sk-ant-oat-xyz".to_string(); + let mut input = HashMap::new(); + input.insert(codex.provider.clone(), AuthEntry::Token(codex)); + input.insert(anth.provider.clone(), AuthEntry::Token(anth)); + write_auth_file(&path, &input).unwrap(); + let map = read_auth_file(&path).unwrap(); + assert_eq!(token_of(map.get("codex")).expires_at, 1); + assert_eq!( + token_of(map.get(ANTHROPIC_NAMESPACE)).access_token, + "sk-ant-oat-xyz" + ); + } + fn token_of(entry: Option<&AuthEntry>) -> &TokenStore { match entry { Some(AuthEntry::Token(t)) => t, diff --git a/openab-agent/src/llm.rs b/openab-agent/src/llm.rs index de5e1eb6c..1a3f7b506 100644 --- a/openab-agent/src/llm.rs +++ b/openab-agent/src/llm.rs @@ -91,20 +91,22 @@ impl std::ops::Deref for SharedLlmProvider { } /// Select an `LlmProvider` from an explicit `choice` (`anthropic` / -/// `openai` / `codex`) or, for any other value, auto-detect (Anthropic API -/// key first, then codex OAuth). Shared by the ACP session path and MCP -/// sampling so both honor the same `OPENAB_AGENT_PROVIDER` selection and -/// credential fallback. +/// `anthropic-oauth` / `openai` / `codex`) or, for any other value, auto-detect +/// (Anthropic API key, then Claude subscription OAuth, then codex OAuth). The +/// `anthropic` choice itself auto-falls-back from API key to OAuth. Shared by +/// the ACP session path and MCP sampling so both honor the same +/// `OPENAB_AGENT_PROVIDER` selection and credential fallback. pub fn select_provider(choice: &str) -> Result, String> { match choice { - "anthropic" => Ok(Box::new(AnthropicProvider::from_env()?)), + "anthropic" => Ok(Box::new(AnthropicProvider::auto()?)), + "anthropic-oauth" | "claude" => Ok(Box::new(AnthropicProvider::from_oauth_store()?)), "openai" | "codex" => Ok(Box::new(OpenAiProvider::from_auth_store()?)), - _ => match AnthropicProvider::from_env() { + _ => 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}" )), }, }, @@ -122,15 +124,62 @@ pub fn default_provider() -> Option { .map(|b| SharedLlmProvider(Arc::from(b))) } +/// How an `AnthropicProvider` authenticates to the Messages API. +enum AnthropicAuth { + /// `ANTHROPIC_API_KEY` → `x-api-key`, plain system prompt. + ApiKey(String), + /// Claude Pro/Max subscription OAuth → `Bearer` + Claude Code identity + /// headers/system block. The live token is fetched (and refreshed) per call + /// from the `anthropic-oauth` tenant in auth.json. + OAuth, +} + /// Anthropic Claude provider. pub struct AnthropicProvider { - api_key: String, + auth: AnthropicAuth, model: String, - #[allow(dead_code)] max_tokens: u32, client: reqwest::Client, } +fn anthropic_model_from_env() -> String { + std::env::var("OPENAB_AGENT_MODEL").unwrap_or_else(|_| "claude-sonnet-4-20250514".to_string()) +} + +fn anthropic_max_tokens() -> u32 { + std::env::var("OPENAB_AGENT_MAX_TOKENS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(8192) +} + +/// openab-agent's built-in tools mapped to Claude Code's canonical casing. The +/// `claude-code-20250219` beta (sent with OAuth tokens) expects these names, so +/// they're rewritten on the way out and restored on the way back. Unknown names +/// (e.g. MCP tools) pass through unchanged, matching Pi's behaviour. +const CC_TOOL_NAMES: &[(&str, &str)] = &[ + ("read", "Read"), + ("write", "Write"), + ("edit", "Edit"), + ("bash", "Bash"), +]; + +fn to_claude_code_name(name: &str) -> String { + CC_TOOL_NAMES + .iter() + .find(|(lc, _)| *lc == name) + .map(|(_, cc)| (*cc).to_string()) + .unwrap_or_else(|| name.to_string()) +} + +fn from_claude_code_name(name: &str) -> String { + CC_TOOL_NAMES + .iter() + .find(|(_, cc)| *cc == name) + .map(|(lc, _)| (*lc).to_string()) + .unwrap_or_else(|| name.to_string()) +} + impl AnthropicProvider { pub fn from_env() -> Result { let api_key = std::env::var("ANTHROPIC_API_KEY") @@ -139,25 +188,51 @@ impl AnthropicProvider { return Err("ANTHROPIC_API_KEY is empty".to_string()); } Ok(Self { - api_key, - model: std::env::var("OPENAB_AGENT_MODEL") - .unwrap_or_else(|_| "claude-sonnet-4-20250514".to_string()), - max_tokens: std::env::var("OPENAB_AGENT_MAX_TOKENS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(8192), + auth: AnthropicAuth::ApiKey(api_key), + model: anthropic_model_from_env(), + max_tokens: anthropic_max_tokens(), client: reqwest::Client::new(), }) } - /// Create provider with a specific model override. - pub fn from_env_with_model(model: &str) -> Result { - let mut p = Self::from_env()?; + /// Claude Pro/Max OAuth. Verifies a stored `anthropic-oauth` token exists; + /// the live token is fetched (and refreshed) at call time. + pub fn from_oauth_store() -> Result { + crate::auth::load_tokens_for(crate::auth::ANTHROPIC_NAMESPACE) + .map_err(|e| e.to_string())?; + Ok(Self { + auth: AnthropicAuth::OAuth, + model: anthropic_model_from_env(), + max_tokens: anthropic_max_tokens(), + client: reqwest::Client::new(), + }) + } + + /// Prefer an explicit API key, else a stored Claude subscription OAuth token. + pub fn auto() -> Result { + Self::from_env().or_else(|_| Self::from_oauth_store()) + } + + /// `auto()` with an explicit model override. + pub fn auto_with_model(model: &str) -> Result { + let mut p = Self::auto()?; p.model = model.to_string(); Ok(p) } + /// `from_oauth_store()` with an explicit model override. + pub fn from_oauth_store_with_model(model: &str) -> Result { + let mut p = Self::from_oauth_store()?; + p.model = model.to_string(); + Ok(p) + } + + fn is_oauth(&self) -> bool { + matches!(self.auth, AnthropicAuth::OAuth) + } + fn build_request_body(&self, system: &str, messages: &[Message], tools: &[ToolDef]) -> Value { + let oauth = self.is_oauth(); let msgs: Vec = messages .iter() .map(|m| { @@ -167,6 +242,7 @@ impl AnthropicProvider { .map(|b| match b { ContentBlock::Text { text } => json!({ "type": "text", "text": text }), ContentBlock::ToolUse { id, name, input } => { + let name = if oauth { to_claude_code_name(name) } else { name.clone() }; json!({ "type": "tool_use", "id": id, "name": name, "input": input }) } ContentBlock::ToolResult { @@ -194,15 +270,27 @@ impl AnthropicProvider { "model": &self.model, "max_tokens": self.max_tokens, "messages": msgs, - "system": system, }); + // OAuth tokens MUST carry the Claude Code identity as the first system + // block, with the real prompt appended. API-key callers send a plain + // string (unchanged behaviour). + if oauth { + body["system"] = json!([ + { "type": "text", "text": "You are Claude Code, Anthropic's official CLI for Claude." }, + { "type": "text", "text": system }, + ]); + } else { + body["system"] = json!(system); + } + if !tools.is_empty() { let tool_defs: Vec = tools .iter() .map(|t| { + let name = if oauth { to_claude_code_name(&t.name) } else { t.name.clone() }; json!({ - "name": &t.name, + "name": name, "description": &t.description, "input_schema": &t.input_schema }) @@ -228,15 +316,32 @@ impl LlmProvider for AnthropicProvider { ) -> Pin>> + Send + 'a>> { Box::pin(async move { let body = self.build_request_body(system, messages, tools); + let oauth = self.is_oauth(); let max_retries = 3u32; for attempt in 0..=max_retries { - let resp = self + let mut req = self .client .post("https://api.anthropic.com/v1/messages") - .header("x-api-key", &self.api_key) .header("anthropic-version", "2023-06-01") - .header("content-type", "application/json") + .header("content-type", "application/json"); + req = match &self.auth { + AnthropicAuth::ApiKey(key) => req.header("x-api-key", key), + AnthropicAuth::OAuth => { + // Claude Pro/Max: Bearer + Claude Code identity headers. + let token = crate::auth::get_valid_token_for( + crate::auth::ANTHROPIC_NAMESPACE, + ) + .await?; + req.header("authorization", format!("Bearer {token}")) + .header("anthropic-beta", "claude-code-20250219,oauth-2025-04-20") + .header("user-agent", "claude-cli/1.0.0") + .header("x-app", "cli") + .header("anthropic-dangerous-direct-browser-access", "true") + } + }; + + let resp = req .json(&body) .send() .await @@ -251,6 +356,14 @@ impl LlmProvider for AnthropicProvider { continue; } + // 401 on OAuth: token may have expired mid-request; force a + // refresh and retry once before surfacing the error. + if oauth && status.as_u16() == 401 && attempt < max_retries { + let _ = + crate::auth::force_refresh_for(crate::auth::ANTHROPIC_NAMESPACE).await; + continue; + } + if !status.is_success() { let text = resp.text().await.unwrap_or_default(); return Err(anyhow!("Anthropic API error {status}: {text}")); @@ -261,7 +374,17 @@ impl LlmProvider for AnthropicProvider { .await .map_err(|e| anyhow!("Failed to parse response: {e}"))?; - return parse_anthropic_response(&response); + let mut events = parse_anthropic_response(&response)?; + // Restore openab-agent's lowercase tool names from the Claude + // Code canonical casing the model echoes back under OAuth. + if oauth { + for ev in &mut events { + if let LlmEvent::ToolUse { name, .. } = ev { + *name = from_claude_code_name(name); + } + } + } + return Ok(events); } Err(anyhow!("Anthropic API: max retries exceeded")) @@ -664,14 +787,18 @@ mod tests { } } - #[test] - fn test_build_request_body() { - let provider = AnthropicProvider { - api_key: "test".to_string(), + fn test_provider(auth: AnthropicAuth) -> AnthropicProvider { + AnthropicProvider { + auth, model: "claude-sonnet-4-20250514".to_string(), max_tokens: 4096, client: reqwest::Client::new(), - }; + } + } + + #[test] + fn test_build_request_body() { + let provider = test_provider(AnthropicAuth::ApiKey("test".to_string())); let messages = vec![Message { role: "user".to_string(), content: vec![ContentBlock::Text { @@ -681,10 +808,48 @@ mod tests { let body = provider.build_request_body("system prompt", &messages, &[]); assert_eq!(body["model"], "claude-sonnet-4-20250514"); assert_eq!(body["max_tokens"], 4096); + // API-key mode keeps the plain-string system prompt. assert_eq!(body["system"], "system prompt"); assert_eq!(body["messages"][0]["role"], "user"); } + #[test] + fn test_build_request_body_oauth_injects_claude_code_identity_and_caps_tools() { + let provider = test_provider(AnthropicAuth::OAuth); + let messages = vec![Message { + role: "assistant".to_string(), + content: vec![ContentBlock::ToolUse { + id: "tu_1".to_string(), + name: "read".to_string(), + input: json!({"path": "/tmp/x"}), + }], + }]; + let tools = vec![ToolDef { + name: "bash".to_string(), + description: "run".to_string(), + input_schema: json!({}), + }]; + let body = provider.build_request_body("real prompt", &messages, &tools); + // system[0] must be the Claude Code identity, real prompt appended. + assert_eq!( + body["system"][0]["text"], + "You are Claude Code, Anthropic's official CLI for Claude." + ); + assert_eq!(body["system"][1]["text"], "real prompt"); + // tool def + assistant tool_use names normalised to CC casing. + assert_eq!(body["tools"][0]["name"], "Bash"); + assert_eq!(body["messages"][0]["content"][0]["name"], "Read"); + } + + #[test] + fn test_claude_code_name_round_trip_and_passthrough() { + assert_eq!(to_claude_code_name("read"), "Read"); + assert_eq!(from_claude_code_name("Read"), "read"); + // unknown (e.g. MCP) names pass through unchanged both ways. + assert_eq!(to_claude_code_name("linear_search"), "linear_search"); + assert_eq!(from_claude_code_name("linear_search"), "linear_search"); + } + #[test] fn test_parse_openai_text_response() { let resp = json!({ diff --git a/openab-agent/src/main.rs b/openab-agent/src/main.rs index 95d059771..068a74fd4 100644 --- a/openab-agent/src/main.rs +++ b/openab-agent/src/main.rs @@ -86,6 +86,12 @@ enum AuthProvider { }, /// OpenAI Codex via device code (headless servers) CodexDevice, + /// Anthropic Claude Pro/Max via browser PKCE flow + AnthropicOauth { + /// Print URL and paste the redirect instead of opening a browser + #[arg(long)] + no_browser: bool, + }, /// Show stored credentials Status, } @@ -118,6 +124,12 @@ async fn main() { std::process::exit(1); } } + AuthProvider::AnthropicOauth { no_browser } => { + if let Err(e) = auth::login_anthropic_browser_flow(no_browser).await { + eprintln!("❌ Authentication failed: {e}"); + std::process::exit(1); + } + } AuthProvider::Status => { auth::show_status(); } From 5b625f450c946814c51a56778f3c104752a22891 Mon Sep 17 00:00:00 2001 From: Can Yu Date: Wed, 24 Jun 2026 14:21:02 +0800 Subject: [PATCH 2/9] fix(native-agent): default Anthropic model to claude-opus-4-8 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) --- openab-agent/src/acp.rs | 4 ++-- openab-agent/src/llm.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openab-agent/src/acp.rs b/openab-agent/src/acp.rs index 0d866812f..cdc2ed6cf 100644 --- a/openab-agent/src/acp.rs +++ b/openab-agent/src/acp.rs @@ -372,7 +372,7 @@ impl AcpServer { .or_else(|| std::env::var("OPENAB_AGENT_MODEL").ok()) .unwrap_or_else(|| { if active_provider == "anthropic" { - "claude-sonnet-4-20250514".to_string() + "claude-opus-4-8".to_string() } else { "gpt-5.4-mini".to_string() } @@ -433,7 +433,7 @@ impl AcpServer { if self.active_provider.as_deref() == Some("openai") { "gpt-5.4-mini".to_string() } else { - "claude-sonnet-4-20250514".to_string() + "claude-opus-4-8".to_string() } }); diff --git a/openab-agent/src/llm.rs b/openab-agent/src/llm.rs index 1a3f7b506..e1abceb6f 100644 --- a/openab-agent/src/llm.rs +++ b/openab-agent/src/llm.rs @@ -143,7 +143,7 @@ pub struct AnthropicProvider { } fn anthropic_model_from_env() -> String { - std::env::var("OPENAB_AGENT_MODEL").unwrap_or_else(|_| "claude-sonnet-4-20250514".to_string()) + std::env::var("OPENAB_AGENT_MODEL").unwrap_or_else(|_| "claude-opus-4-8".to_string()) } fn anthropic_max_tokens() -> u32 { From 73bf9a27ea99addc8c73d2f82964c772c1d98346 Mon Sep 17 00:00:00 2001 From: Can Yu Date: Wed, 24 Jun 2026 20:37:40 +0800 Subject: [PATCH 3/9] fix(native-agent): address PR review (CI workspace, PKCE state, error UX) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- Cargo.toml | 1 + openab-agent/src/acp.rs | 6 ++---- openab-agent/src/auth.rs | 37 +++++++++++++++++++++++++++---------- openab-agent/src/llm.rs | 33 ++++++++++++++++++--------------- 4 files changed, 48 insertions(+), 29 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ef07ba752..f34ad3eec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = ["crates/openab-core", "crates/openab-gateway"] +exclude = ["openab-agent"] [package] name = "openab" diff --git a/openab-agent/src/acp.rs b/openab-agent/src/acp.rs index cdc2ed6cf..f1af0485a 100644 --- a/openab-agent/src/acp.rs +++ b/openab-agent/src/acp.rs @@ -347,7 +347,7 @@ impl AcpServer { return self.error_response( id, -32000, - &format!("No credentials: set ANTHROPIC_API_KEY, or run `openab-agent auth anthropic-oauth` / `auth codex-oauth`. {e}"), + &format!("No credentials: set ANTHROPIC_API_KEY, or run `openab-agent auth anthropic-oauth` / `openab-agent auth codex-oauth`. {e}"), ) } } @@ -603,9 +603,7 @@ impl AcpServer { if !session_id.is_empty() && self.sessions.contains_key(session_id) { let new_provider: Result, String> = match provider_name { - "anthropic" => { - AnthropicProvider::auto_with_model(value).map(|p| Box::new(p) as _) - } + "anthropic" => AnthropicProvider::auto_with_model(value).map(|p| Box::new(p) as _), _ => crate::llm::OpenAiProvider::from_auth_store_with_model(value) .map(|p| Box::new(p) as _), }; diff --git a/openab-agent/src/auth.rs b/openab-agent/src/auth.rs index ff71ad6ee..960372e21 100644 --- a/openab-agent/src/auth.rs +++ b/openab-agent/src/auth.rs @@ -51,8 +51,9 @@ fn anthropic_redirect_uri() -> String { format!("http://localhost:{ANTHROPIC_REDIRECT_PORT}/callback") } -/// Build the Anthropic authorize URL. Pure so it can be unit-tested. Pi reuses -/// the PKCE verifier as the `state` value, so callers pass `state == verifier`. +/// Build the Anthropic authorize URL. Pure so it can be unit-tested. `state` is +/// an independent random CSRF value (kept distinct from the PKCE verifier, which +/// stays back-channel-only) — the AS just echoes it back. fn anthropic_authorize_url(challenge: &str, state: &str) -> String { let client_id = anthropic_client_id(); let redirect = anthropic_redirect_uri(); @@ -249,16 +250,22 @@ fn write_auth_file(path: &Path, map: &HashMap) -> Result<()> /// Load the LLM token stored under `namespace` (`codex` / `anthropic-oauth`). pub fn load_tokens_for(namespace: &str) -> Result { let path = auth_path(); - let map = read_auth_file(&path).map_err(|_| { + let cmd = if namespace == ANTHROPIC_NAMESPACE { + "openab-agent auth anthropic-oauth" + } else { + "openab-agent auth codex-oauth" + }; + // Preserve the underlying read/parse error for debugging. + let map = read_auth_file(&path).map_err(|e| { anyhow!( - "No credentials found at {}. Run `openab-agent auth` first.", + "No credentials at {} ({e}). Run `{cmd}` first.", path.display() ) })?; match map.get(namespace) { Some(AuthEntry::Token(t)) => Ok(t.clone()), _ => Err(anyhow!( - "No {namespace} credentials in {}. Run `openab-agent auth` first.", + "No {namespace} credentials in {}. Run `{cmd}` first.", path.display() )), } @@ -420,7 +427,9 @@ async fn refresh_token(store: &TokenStore) -> Result { if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - return Err(anyhow!("Token refresh failed (HTTP {status}): {body}. Run `openab-agent auth` again.")); + return Err(anyhow!( + "Token refresh failed (HTTP {status}): {body}. Run `openab-agent auth` again." + )); } let payload: serde_json::Value = resp.json().await?; let access_token = payload["access_token"] @@ -654,8 +663,8 @@ fn code_from_redirect(url: &url::Url, expected_state: &str) -> Result { } /// 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. +/// authorization code. Note: the Codex flow above predates this helper and still +/// inlines the same logic; fold it in if that path is ever touched again. fn accept_callback_code(listener: &TcpListener, expected_state: &str) -> Result { listener.set_nonblocking(false)?; let (mut stream, _) = listener @@ -677,12 +686,20 @@ fn accept_callback_code(listener: &TcpListener, expected_state: &str) -> Result< /// (Pi's convention) and a JSON token exchange against `platform.claude.com`. pub async fn login_anthropic_browser_flow(no_browser: bool) -> Result<()> { let (verifier, challenge) = generate_pkce(); - let state = verifier.clone(); // Pi reuses the verifier as the state value + // Independent random CSRF state — keep the PKCE verifier back-channel-only. + // 32 bytes: claude.ai's authorize rejects a short state ("Invalid request + // format"); matching the verifier's length keeps it happy while the value + // stays independent (full PKCE strength). + let mut state_buf = [0u8; 32]; + getrandom::fill(&mut state_buf).expect("getrandom failed"); + let state = URL_SAFE_NO_PAD.encode(state_buf); let auth_url = anthropic_authorize_url(&challenge, &state); let code = if no_browser { println!("Open this URL in your browser:\n\n {auth_url}\n"); - println!("After approving, copy the full redirect URL (or just the code) and paste it here:\n"); + println!( + "After approving, copy the full redirect URL (or just the code) and paste it here:\n" + ); let mut input = String::new(); std::io::stdin() .read_line(&mut input) diff --git a/openab-agent/src/llm.rs b/openab-agent/src/llm.rs index e1abceb6f..dcd5e21ad 100644 --- a/openab-agent/src/llm.rs +++ b/openab-agent/src/llm.rs @@ -106,7 +106,7 @@ pub fn select_provider(choice: &str) -> Result, String> { 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 anthropic-oauth` / `auth codex-oauth`. {e}" + "No credentials: set ANTHROPIC_API_KEY, or run `openab-agent auth anthropic-oauth` / `openab-agent auth codex-oauth`. {e}" )), }, }, @@ -233,10 +233,11 @@ impl AnthropicProvider { fn build_request_body(&self, system: &str, messages: &[Message], tools: &[ToolDef]) -> Value { let oauth = self.is_oauth(); - let msgs: Vec = messages - .iter() - .map(|m| { - let content: Vec = m + let msgs: Vec = + messages + .iter() + .map(|m| { + let content: Vec = m .content .iter() .map(|b| match b { @@ -262,9 +263,9 @@ impl AnthropicProvider { } }) .collect(); - json!({ "role": &m.role, "content": content }) - }) - .collect(); + json!({ "role": &m.role, "content": content }) + }) + .collect(); let mut body = json!({ "model": &self.model, @@ -288,7 +289,11 @@ impl AnthropicProvider { let tool_defs: Vec = tools .iter() .map(|t| { - let name = if oauth { to_claude_code_name(&t.name) } else { t.name.clone() }; + let name = if oauth { + to_claude_code_name(&t.name) + } else { + t.name.clone() + }; json!({ "name": name, "description": &t.description, @@ -329,10 +334,9 @@ impl LlmProvider for AnthropicProvider { AnthropicAuth::ApiKey(key) => req.header("x-api-key", key), AnthropicAuth::OAuth => { // Claude Pro/Max: Bearer + Claude Code identity headers. - let token = crate::auth::get_valid_token_for( - crate::auth::ANTHROPIC_NAMESPACE, - ) - .await?; + let token = + crate::auth::get_valid_token_for(crate::auth::ANTHROPIC_NAMESPACE) + .await?; req.header("authorization", format!("Bearer {token}")) .header("anthropic-beta", "claude-code-20250219,oauth-2025-04-20") .header("user-agent", "claude-cli/1.0.0") @@ -359,8 +363,7 @@ impl LlmProvider for AnthropicProvider { // 401 on OAuth: token may have expired mid-request; force a // refresh and retry once before surfacing the error. if oauth && status.as_u16() == 401 && attempt < max_retries { - let _ = - crate::auth::force_refresh_for(crate::auth::ANTHROPIC_NAMESPACE).await; + let _ = crate::auth::force_refresh_for(crate::auth::ANTHROPIC_NAMESPACE).await; continue; } From 4ef7c494a8da56388968601e368dbed42c5b1ba5 Mon Sep 17 00:00:00 2001 From: Can Yu Date: Wed, 24 Jun 2026 20:56:52 +0800 Subject: [PATCH 4/9] fix(native-agent): flush stdout drain on ACP server shutdown 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) --- openab-agent/src/acp.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/openab-agent/src/acp.rs b/openab-agent/src/acp.rs index f1af0485a..b3be526d6 100644 --- a/openab-agent/src/acp.rs +++ b/openab-agent/src/acp.rs @@ -204,7 +204,7 @@ impl AcpServer { // through `out_tx` into this one drain task, preserving the // one-writer invariant the HostBridge relies on. let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); - tokio::spawn(async move { + let drain = tokio::spawn(async move { let mut stdout = io::stdout(); while let Some(line) = out_rx.recv().await { let _ = writeln!(stdout, "{}", line); @@ -268,6 +268,16 @@ impl AcpServer { let _ = out_tx.send(line); } } + + // Shutdown: stdin hit EOF and the dispatch loop ended. Drop our senders + // so the drain task can flush any queued output and finish before this + // returns — otherwise `#[tokio::main]` aborts the detached drain on + // return and the last response can be lost (the ACP `initialize` smoke + // test depends on this). Bounded await so a lingering sender (e.g. an + // MCP background task holding an `out_tx` clone) can't wedge shutdown. + drop(bridge); + drop(out_tx); + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), drain).await; } fn handle_initialize(&self, id: u64) -> String { From 21df6747f9288cd8783993effa06e7e2777c1469 Mon Sep 17 00:00:00 2001 From: Can Yu Date: Wed, 24 Jun 2026 22:50:11 +0800 Subject: [PATCH 5/9] fix: address review #6/#7/#11 (auth mode, 401 refresh, error UX) 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) Claude-Session: https://claude.ai/code/session_01XHjGw1uBHXoVPB3dXYEy7h --- openab-agent/src/acp.rs | 6 ++++++ openab-agent/src/agent.rs | 6 ++++++ openab-agent/src/auth.rs | 19 +++++++++++++------ openab-agent/src/llm.rs | 28 ++++++++++++++++++++++------ 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/openab-agent/src/acp.rs b/openab-agent/src/acp.rs index b3be526d6..ff3ae6c89 100644 --- a/openab-agent/src/acp.rs +++ b/openab-agent/src/acp.rs @@ -611,8 +611,14 @@ impl AcpServer { // Rebuild the current session's provider so the switch takes effect immediately if !session_id.is_empty() && self.sessions.contains_key(session_id) { + // Preserve the session's auth mode: an OAuth-forced session must not + // silently fall back to ANTHROPIC_API_KEY (which `auto_*` prefers). + let session_is_oauth = self.sessions[session_id].provider_is_oauth(); let new_provider: Result, String> = match provider_name { + "anthropic" if session_is_oauth => { + AnthropicProvider::from_oauth_store_with_model(value).map(|p| Box::new(p) as _) + } "anthropic" => AnthropicProvider::auto_with_model(value).map(|p| Box::new(p) as _), _ => crate::llm::OpenAiProvider::from_auth_store_with_model(value) .map(|p| Box::new(p) as _), diff --git a/openab-agent/src/agent.rs b/openab-agent/src/agent.rs index 7d50c005a..86ba368a1 100644 --- a/openab-agent/src/agent.rs +++ b/openab-agent/src/agent.rs @@ -107,6 +107,12 @@ impl Agent { self.provider = provider; } + /// True if the current provider authenticates via OAuth. Used on model + /// switch to rebuild with the same auth mode. + pub fn provider_is_oauth(&self) -> bool { + self.provider.is_oauth() + } + /// Update working directory and rebuild system prompt. pub fn set_working_dir(&mut self, cwd: String) { self.system_prompt = Self::build_system_prompt(&cwd, self.mcp_manager.as_ref()); diff --git a/openab-agent/src/auth.rs b/openab-agent/src/auth.rs index 960372e21..6083a7ba8 100644 --- a/openab-agent/src/auth.rs +++ b/openab-agent/src/auth.rs @@ -247,14 +247,20 @@ fn write_auth_file(path: &Path, map: &HashMap) -> Result<()> Ok(()) } -/// Load the LLM token stored under `namespace` (`codex` / `anthropic-oauth`). -pub fn load_tokens_for(namespace: &str) -> Result { - let path = auth_path(); - let cmd = if namespace == ANTHROPIC_NAMESPACE { +/// CLI subcommand that (re)authenticates a tenant `namespace`. Used in +/// credential-error messages so the user runs the right login. +fn auth_subcommand(namespace: &str) -> &'static str { + if namespace == ANTHROPIC_NAMESPACE { "openab-agent auth anthropic-oauth" } else { "openab-agent auth codex-oauth" - }; + } +} + +/// Load the LLM token stored under `namespace` (`codex` / `anthropic-oauth`). +pub fn load_tokens_for(namespace: &str) -> Result { + let path = auth_path(); + let cmd = auth_subcommand(namespace); // Preserve the underlying read/parse error for debugging. let map = read_auth_file(&path).map_err(|e| { anyhow!( @@ -428,7 +434,8 @@ async fn refresh_token(store: &TokenStore) -> Result { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(anyhow!( - "Token refresh failed (HTTP {status}): {body}. Run `openab-agent auth` again." + "Token refresh failed (HTTP {status}): {body}. Run `{}` again.", + auth_subcommand(&store.provider) )); } let payload: serde_json::Value = resp.json().await?; diff --git a/openab-agent/src/llm.rs b/openab-agent/src/llm.rs index dcd5e21ad..7a18b4df3 100644 --- a/openab-agent/src/llm.rs +++ b/openab-agent/src/llm.rs @@ -68,6 +68,13 @@ pub trait LlmProvider: Send + Sync { /// `CreateMessageResult.model` when serving MCP sampling so the requesting /// server learns which model produced the response. fn model(&self) -> &str; + + /// True if this provider authenticates via OAuth rather than an API key. + /// Lets a session rebuild (model switch) preserve its auth mode instead of + /// silently falling back to `ANTHROPIC_API_KEY`. + fn is_oauth(&self) -> bool { + false + } } /// Shared, cloneable handle to an `LlmProvider`. A newtype over @@ -227,10 +234,6 @@ impl AnthropicProvider { Ok(p) } - fn is_oauth(&self) -> bool { - matches!(self.auth, AnthropicAuth::OAuth) - } - fn build_request_body(&self, system: &str, messages: &[Message], tools: &[ToolDef]) -> Value { let oauth = self.is_oauth(); let msgs: Vec = @@ -313,6 +316,10 @@ impl LlmProvider for AnthropicProvider { &self.model } + fn is_oauth(&self) -> bool { + matches!(self.auth, AnthropicAuth::OAuth) + } + fn chat<'a>( &'a self, system: &'a str, @@ -361,9 +368,10 @@ impl LlmProvider for AnthropicProvider { } // 401 on OAuth: token may have expired mid-request; force a - // refresh and retry once before surfacing the error. + // refresh and retry once. Surface a failed refresh instead of + // retrying with the stale token. if oauth && status.as_u16() == 401 && attempt < max_retries { - let _ = crate::auth::force_refresh_for(crate::auth::ANTHROPIC_NAMESPACE).await; + crate::auth::force_refresh_for(crate::auth::ANTHROPIC_NAMESPACE).await?; continue; } @@ -799,6 +807,14 @@ mod tests { } } + #[test] + fn test_is_oauth_reflects_auth_mode() { + // Guards the ACP model-switch rebuild: an OAuth session must report + // OAuth so it isn't silently rebuilt against ANTHROPIC_API_KEY. + assert!(test_provider(AnthropicAuth::OAuth).is_oauth()); + assert!(!test_provider(AnthropicAuth::ApiKey("k".to_string())).is_oauth()); + } + #[test] fn test_build_request_body() { let provider = test_provider(AnthropicAuth::ApiKey("test".to_string())); From fd8d86272a202c1d170b533c47210f2d24018682 Mon Sep 17 00:00:00 2001 From: Can Yu Date: Wed, 24 Jun 2026 22:55:24 +0800 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20address=20review=20#8=20=E2=80=94=20?= =?UTF-8?q?always=20verify=20CSRF=20state=20on=20bare-code=20paste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--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) Claude-Session: https://claude.ai/code/session_01XHjGw1uBHXoVPB3dXYEy7h --- openab-agent/src/auth.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openab-agent/src/auth.rs b/openab-agent/src/auth.rs index 6083a7ba8..42412914c 100644 --- a/openab-agent/src/auth.rs +++ b/openab-agent/src/auth.rs @@ -715,11 +715,15 @@ pub async fn login_anthropic_browser_flow(no_browser: bool) -> Result<()> { if input.is_empty() { return Err(anyhow!("No URL provided")); } - // Accept either a full redirect URL or a bare `code` / `code#state`. + // Accept either a full redirect URL or a bare `code#state`. Require the + // `#state` form so CSRF state is always verified — a bare code can't be + // checked and is rejected rather than trusted. if let Ok(url) = url::Url::parse(input) { code_from_redirect(&url, &state)? } else { - let (code, st) = input.split_once('#').unwrap_or((input, state.as_str())); + let (code, st) = input.split_once('#').ok_or_else(|| { + anyhow!("Paste the full `code#state` value (or the redirect URL) so the state can be verified") + })?; if st != state { return Err(anyhow!("State mismatch")); } From b28c312d5e99c6036077983027b5153f9018a060 Mon Sep 17 00:00:00 2001 From: Can Yu Date: Thu, 25 Jun 2026 00:05:10 +0800 Subject: [PATCH 7/9] fix: don't pin a hardcoded Anthropic default model (review F4 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01XHjGw1uBHXoVPB3dXYEy7h --- docs/native-agent.md | 1 + openab-agent/src/acp.rs | 79 +++++++++++++++++++++++++-------------- openab-agent/src/agent.rs | 6 +++ openab-agent/src/llm.rs | 69 +++++++++++++++++++++------------- 4 files changed, 99 insertions(+), 56 deletions(-) diff --git a/docs/native-agent.md b/docs/native-agent.md index be6b00f89..11e98bceb 100644 --- a/docs/native-agent.md +++ b/docs/native-agent.md @@ -31,6 +31,7 @@ env = { OPENAB_AGENT_OPENAI_MODEL = "gpt-5.4-mini" } | Variable | Default | Description | |----------|---------|-------------| +| `OPENAB_AGENT_MODEL` | — (required for Anthropic) | Anthropic model id (e.g. `claude-opus-4-8`). No hardcoded default — dateless 4.6+ IDs are fixed canonical IDs that retire each generation, so the agent fails loud if unset rather than pin a model that will eventually 404. | | `OPENAB_AGENT_OPENAI_MODEL` | `gpt-5.4-mini` | Model to use (must be supported by your ChatGPT plan — see [Supported Models](#supported-models-chatgpt-subscription)) | | `OPENAB_AGENT_OPENAI_BASE_URL` | `https://chatgpt.com/backend-api` | API base URL | | `OPENAB_AGENT_PROVIDER` | auto-detect | Force provider (`anthropic`, `openai`, `codex`) | diff --git a/openab-agent/src/acp.rs b/openab-agent/src/acp.rs index ff3ae6c89..57a444b9c 100644 --- a/openab-agent/src/acp.rs +++ b/openab-agent/src/acp.rs @@ -366,27 +366,13 @@ impl AcpServer { } }; + // The provider already resolved its model (explicit override → + // OPENAB_AGENT_MODEL, validated at construction). Use it as the + // authoritative reported model instead of a separate hardcoded default. + let model_name = provider.model().to_string(); let agent = Agent::new_boxed(provider, self.working_dir.clone(), self.mcp_manager.clone()); self.sessions.insert(session_id.clone(), agent); - let model_name = self - .active_model - .clone() - .or_else(|| { - if active_provider == "openai" { - std::env::var("OPENAB_AGENT_OPENAI_MODEL").ok() - } else { - None - } - }) - .or_else(|| std::env::var("OPENAB_AGENT_MODEL").ok()) - .unwrap_or_else(|| { - if active_provider == "anthropic" { - "claude-opus-4-8".to_string() - } else { - "gpt-5.4-mini".to_string() - } - }); self.active_model = Some(model_name.clone()); self.active_provider = Some(active_provider.to_string()); self.model_options = Self::available_models().await; @@ -439,13 +425,11 @@ impl AcpServer { self.model_options = Self::available_models().await; } - let model_name = self.active_model.clone().unwrap_or_else(|| { - if self.active_provider.as_deref() == Some("openai") { - "gpt-5.4-mini".to_string() - } else { - "claude-opus-4-8".to_string() - } - }); + // Report the loaded session's actual model (no hardcoded default). + let model_name = self + .active_model + .clone() + .unwrap_or_else(|| self.sessions[session_id].provider_model()); self.ok_response( id, @@ -699,10 +683,14 @@ mod tests { #[tokio::test] async fn test_session_new() { let _guard = ENV_LOCK.lock().unwrap(); - // Set a fake key so from_env() succeeds in CI - unsafe { std::env::set_var("ANTHROPIC_API_KEY", "test-key") }; + // Set a fake key + model so provider construction succeeds in CI + unsafe { + std::env::set_var("ANTHROPIC_API_KEY", "test-key"); + std::env::set_var("OPENAB_AGENT_MODEL", "claude-sonnet-4-6"); + } let mut server = AcpServer::new(); let resp_str = server.handle_session_new(2).await; + unsafe { std::env::remove_var("OPENAB_AGENT_MODEL") }; let resp: Value = serde_json::from_str(&resp_str).unwrap(); assert_eq!(resp["jsonrpc"], "2.0"); assert_eq!(resp["id"], 2); @@ -712,6 +700,7 @@ mod tests { assert!(!config_options.is_empty()); assert_eq!(config_options[0]["id"], "model"); assert_eq!(config_options[0]["category"], "model"); + assert_eq!(config_options[0]["currentValue"], "claude-sonnet-4-6"); assert!(!config_options[0]["options"].as_array().unwrap().is_empty()); } @@ -809,6 +798,30 @@ mod tests { .contains("ANTHROPIC_API_KEY")); } + #[tokio::test] + async fn test_session_new_requires_model() { + // No hardcoded default: a forced anthropic provider without + // OPENAB_AGENT_MODEL must fail loud. + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { + std::env::set_var("OPENAB_AGENT_PROVIDER", "anthropic"); + std::env::set_var("ANTHROPIC_API_KEY", "test-key"); + std::env::remove_var("OPENAB_AGENT_MODEL"); + } + let mut server = AcpServer::new(); + let resp_str = server.handle_session_new(7).await; + unsafe { + std::env::remove_var("ANTHROPIC_API_KEY"); + std::env::remove_var("OPENAB_AGENT_PROVIDER"); + } + let resp: Value = serde_json::from_str(&resp_str).unwrap(); + assert!(resp["error"].is_object()); + assert!(resp["error"]["message"] + .as_str() + .unwrap() + .contains("no model configured")); + } + #[test] fn test_set_config_option_accepts_cached_dynamic_model() { let mut server = AcpServer::new(); @@ -867,11 +880,15 @@ mod tests { #[tokio::test] async fn test_model_switch_preserves_session_history() { let _guard = ENV_LOCK.lock().unwrap(); - unsafe { std::env::set_var("ANTHROPIC_API_KEY", "test-key") }; + unsafe { + std::env::set_var("ANTHROPIC_API_KEY", "test-key"); + std::env::set_var("OPENAB_AGENT_MODEL", "claude-sonnet-4-6"); + } let mut server = AcpServer::new(); // Create a session let resp_str = server.handle_session_new(10).await; + unsafe { std::env::remove_var("OPENAB_AGENT_MODEL") }; let resp: Value = serde_json::from_str(&resp_str).unwrap(); let session_id = resp["result"]["sessionId"].as_str().unwrap().to_string(); @@ -974,11 +991,15 @@ mod tests { #[tokio::test] async fn test_session_load_returns_config_options() { let _guard = ENV_LOCK.lock().unwrap(); - unsafe { std::env::set_var("ANTHROPIC_API_KEY", "test-key") }; + unsafe { + std::env::set_var("ANTHROPIC_API_KEY", "test-key"); + std::env::set_var("OPENAB_AGENT_MODEL", "claude-sonnet-4-6"); + } let mut server = AcpServer::new(); // Create a session first let new_resp_str = server.handle_session_new(10).await; + unsafe { std::env::remove_var("OPENAB_AGENT_MODEL") }; let new_resp: Value = serde_json::from_str(&new_resp_str).unwrap(); let session_id = new_resp["result"]["sessionId"].as_str().unwrap(); diff --git a/openab-agent/src/agent.rs b/openab-agent/src/agent.rs index 86ba368a1..cc7c5fb53 100644 --- a/openab-agent/src/agent.rs +++ b/openab-agent/src/agent.rs @@ -113,6 +113,12 @@ impl Agent { self.provider.is_oauth() } + /// The model id the current provider will use. Authoritative source for the + /// session's reported model (avoids a separate hardcoded default). + pub fn provider_model(&self) -> String { + self.provider.model().to_string() + } + /// Update working directory and rebuild system prompt. pub fn set_working_dir(&mut self, cwd: String) { self.system_prompt = Self::build_system_prompt(&cwd, self.mcp_manager.as_ref()); diff --git a/openab-agent/src/llm.rs b/openab-agent/src/llm.rs index 7a18b4df3..859ea071d 100644 --- a/openab-agent/src/llm.rs +++ b/openab-agent/src/llm.rs @@ -149,8 +149,13 @@ pub struct AnthropicProvider { client: reqwest::Client, } -fn anthropic_model_from_env() -> String { - std::env::var("OPENAB_AGENT_MODEL").unwrap_or_else(|_| "claude-opus-4-8".to_string()) +/// Resolve the Anthropic model from `OPENAB_AGENT_MODEL`. No hardcoded default: +/// dateless 4.6+ IDs are fixed canonical IDs (not evergreen pointers), so a +/// pinned default is a per-generation 404 timebomb. Require an explicit choice +/// and fail loud instead. +fn anthropic_model() -> Result { + std::env::var("OPENAB_AGENT_MODEL") + .map_err(|_| "no model configured; set OPENAB_AGENT_MODEL or select a model".to_string()) } fn anthropic_max_tokens() -> u32 { @@ -188,50 +193,60 @@ fn from_claude_code_name(name: &str) -> String { } impl AnthropicProvider { - pub fn from_env() -> Result { + fn build(auth: AnthropicAuth, model: String) -> Self { + Self { + auth, + model, + max_tokens: anthropic_max_tokens(), + client: reqwest::Client::new(), + } + } + + fn api_key_from_env() -> Result { let api_key = std::env::var("ANTHROPIC_API_KEY") .map_err(|_| "ANTHROPIC_API_KEY not set".to_string())?; if api_key.is_empty() { return Err("ANTHROPIC_API_KEY is empty".to_string()); } - Ok(Self { - auth: AnthropicAuth::ApiKey(api_key), - model: anthropic_model_from_env(), - max_tokens: anthropic_max_tokens(), - client: reqwest::Client::new(), - }) + Ok(api_key) } - /// Claude Pro/Max OAuth. Verifies a stored `anthropic-oauth` token exists; - /// the live token is fetched (and refreshed) at call time. - pub fn from_oauth_store() -> Result { + /// Verify the `anthropic-oauth` tenant has a stored token; the live token is + /// fetched (and refreshed) at call time. + fn ensure_oauth_token() -> Result<(), String> { crate::auth::load_tokens_for(crate::auth::ANTHROPIC_NAMESPACE) - .map_err(|e| e.to_string())?; - Ok(Self { - auth: AnthropicAuth::OAuth, - model: anthropic_model_from_env(), - max_tokens: anthropic_max_tokens(), - client: reqwest::Client::new(), - }) + .map(|_| ()) + .map_err(|e| e.to_string()) + } + + /// Claude Pro/Max OAuth. + pub fn from_oauth_store() -> Result { + Self::ensure_oauth_token()?; + Ok(Self::build(AnthropicAuth::OAuth, anthropic_model()?)) } /// Prefer an explicit API key, else a stored Claude subscription OAuth token. + /// When a key is present its own errors (e.g. missing model) surface rather + /// than falling through to an unrelated OAuth-token error. pub fn auto() -> Result { - Self::from_env().or_else(|_| Self::from_oauth_store()) + match Self::api_key_from_env() { + Ok(key) => Ok(Self::build(AnthropicAuth::ApiKey(key), anthropic_model()?)), + Err(_) => Self::from_oauth_store(), + } } - /// `auto()` with an explicit model override. + /// `auto()` with an explicit model override. The override replaces + /// `OPENAB_AGENT_MODEL`, so it does not require that env var to be set. pub fn auto_with_model(model: &str) -> Result { - let mut p = Self::auto()?; - p.model = model.to_string(); - Ok(p) + Self::api_key_from_env() + .map(|key| Self::build(AnthropicAuth::ApiKey(key), model.to_string())) + .or_else(|_| Self::from_oauth_store_with_model(model)) } /// `from_oauth_store()` with an explicit model override. pub fn from_oauth_store_with_model(model: &str) -> Result { - let mut p = Self::from_oauth_store()?; - p.model = model.to_string(); - Ok(p) + Self::ensure_oauth_token()?; + Ok(Self::build(AnthropicAuth::OAuth, model.to_string())) } fn build_request_body(&self, system: &str, messages: &[Message], tools: &[ToolDef]) -> Value { From 5b768a610a2ae2691810365b554398da12e65b89 Mon Sep 17 00:00:00 2001 From: Can Yu Date: Thu, 25 Jun 2026 02:34:03 +0800 Subject: [PATCH 8/9] docs: fix stale PKCE state comment (review F5) 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) Claude-Session: https://claude.ai/code/session_01XHjGw1uBHXoVPB3dXYEy7h --- openab-agent/src/auth.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openab-agent/src/auth.rs b/openab-agent/src/auth.rs index 42412914c..a30292947 100644 --- a/openab-agent/src/auth.rs +++ b/openab-agent/src/auth.rs @@ -689,8 +689,9 @@ fn accept_callback_code(listener: &TcpListener, expected_state: &str) -> Result< Ok(code) } -/// Anthropic OAuth (Claude Pro/Max). PKCE with the verifier doubling as `state` -/// (Pi's convention) and a JSON token exchange against `platform.claude.com`. +/// Anthropic OAuth (Claude Pro/Max). PKCE with an independent random CSRF +/// `state` (verifier stays back-channel-only) and a JSON token exchange against +/// `platform.claude.com`. pub async fn login_anthropic_browser_flow(no_browser: bool) -> Result<()> { let (verifier, challenge) = generate_pkce(); // Independent random CSRF state — keep the PKCE verifier back-channel-only. From c0292fc8e3d66f9a1449e1f29a2d6f8e1c13aded Mon Sep 17 00:00:00 2001 From: Can Yu Date: Thu, 25 Jun 2026 12:19:31 +0800 Subject: [PATCH 9/9] feat(native-agent): accept canonical provider/model in OPENAB_AGENT_MODEL 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) --- openab-agent/src/acp.rs | 3 +- openab-agent/src/llm.rs | 82 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 8 deletions(-) diff --git a/openab-agent/src/acp.rs b/openab-agent/src/acp.rs index 57a444b9c..615602697 100644 --- a/openab-agent/src/acp.rs +++ b/openab-agent/src/acp.rs @@ -307,8 +307,7 @@ impl AcpServer { let provider_choice = self .active_provider .clone() - .or_else(|| std::env::var("OPENAB_AGENT_PROVIDER").ok()) - .unwrap_or_default(); + .unwrap_or_else(crate::llm::resolve_provider_choice); let model_override = self.active_model.as_deref(); let (provider, active_provider): (Box, &str) = match provider_choice.as_str() { diff --git a/openab-agent/src/llm.rs b/openab-agent/src/llm.rs index 859ea071d..a854fd21e 100644 --- a/openab-agent/src/llm.rs +++ b/openab-agent/src/llm.rs @@ -97,6 +97,44 @@ impl std::ops::Deref for SharedLlmProvider { } } +/// A model reference, optionally provider-qualified. Accepts the canonical +/// `provider/model_id` form (e.g. `anthropic/claude-sonnet-4-6`) as well as a +/// bare `model_id` (provider then inferred from credentials). Model IDs never +/// contain `/`, so the first `/` cleanly separates the two. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelRef { + pub provider: Option, + pub model: String, +} + +impl ModelRef { + pub fn parse(input: &str) -> Self { + match input.split_once('/') { + Some((p, m)) if !p.is_empty() && !m.is_empty() => ModelRef { + provider: Some(p.to_string()), + model: m.to_string(), + }, + _ => ModelRef { + provider: None, + model: input.to_string(), + }, + } + } +} + +/// The provider the user asked for: explicit `OPENAB_AGENT_PROVIDER`, else the +/// `provider/` prefix of `OPENAB_AGENT_MODEL` (e.g. `openai/gpt-5.4` selects +/// OpenAI even when an Anthropic key is also present), else empty (auto-detect). +pub fn resolve_provider_choice() -> String { + match std::env::var("OPENAB_AGENT_PROVIDER") { + Ok(p) if !p.is_empty() => p, + _ => std::env::var("OPENAB_AGENT_MODEL") + .ok() + .and_then(|m| ModelRef::parse(&m).provider) + .unwrap_or_default(), + } +} + /// Select an `LlmProvider` from an explicit `choice` (`anthropic` / /// `anthropic-oauth` / `openai` / `codex`) or, for any other value, auto-detect /// (Anthropic API key, then Claude subscription OAuth, then codex OAuth). The @@ -125,7 +163,7 @@ pub fn select_provider(choice: &str) -> Result, String> { /// credentials are available so the caller can simply decline to advertise /// the `sampling` capability rather than fail. pub fn default_provider() -> Option { - let choice = std::env::var("OPENAB_AGENT_PROVIDER").unwrap_or_default(); + let choice = resolve_provider_choice(); select_provider(&choice) .ok() .map(|b| SharedLlmProvider(Arc::from(b))) @@ -196,7 +234,9 @@ impl AnthropicProvider { fn build(auth: AnthropicAuth, model: String) -> Self { Self { auth, - model, + // Accept a provider-qualified ref (`anthropic/claude-…`); the API + // wants the bare model id. + model: ModelRef::parse(&model).model, max_tokens: anthropic_max_tokens(), client: reqwest::Client::new(), } @@ -481,9 +521,12 @@ impl OpenAiProvider { Ok(Self { base_url: std::env::var("OPENAB_AGENT_OPENAI_BASE_URL") .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), - model: std::env::var("OPENAB_AGENT_OPENAI_MODEL") - .or_else(|_| std::env::var("OPENAB_AGENT_MODEL")) - .unwrap_or_else(|_| "gpt-5.4-mini".to_string()), + model: ModelRef::parse( + &std::env::var("OPENAB_AGENT_OPENAI_MODEL") + .or_else(|_| std::env::var("OPENAB_AGENT_MODEL")) + .unwrap_or_else(|_| "gpt-5.4-mini".to_string()), + ) + .model, max_tokens: std::env::var("OPENAB_AGENT_MAX_TOKENS") .ok() .and_then(|v| v.parse().ok()) @@ -495,7 +538,7 @@ impl OpenAiProvider { /// Create provider with a specific model override. pub fn from_auth_store_with_model(model: &str) -> Result { let mut p = Self::from_auth_store()?; - p.model = model.to_string(); + p.model = ModelRef::parse(model).model; Ok(p) } } @@ -778,6 +821,33 @@ fn parse_openai_response(response: &Value) -> Result> { mod tests { use super::*; + #[test] + fn test_model_ref_parse() { + // Provider-qualified form splits on the first slash. + let r = ModelRef::parse("anthropic/claude-sonnet-4-6"); + assert_eq!(r.provider.as_deref(), Some("anthropic")); + assert_eq!(r.model, "claude-sonnet-4-6"); + + // Bare model id → no provider, model unchanged. + let r = ModelRef::parse("claude-sonnet-4-6"); + assert_eq!(r.provider, None); + assert_eq!(r.model, "claude-sonnet-4-6"); + + // Degenerate slashes fall back to bare (no empty provider/model). + assert_eq!(ModelRef::parse("/gpt-5.4").provider, None); + assert_eq!(ModelRef::parse("openai/").model, "openai/"); + } + + #[test] + fn test_provider_build_strips_prefix() { + // A qualified ref reaches the API as the bare model id. + let p = AnthropicProvider::build( + AnthropicAuth::ApiKey("k".to_string()), + "anthropic/claude-opus-4-8".to_string(), + ); + assert_eq!(p.model(), "claude-opus-4-8"); + } + #[test] fn test_parse_text_response() { let resp = json!({