commit e5672cca564a7ff98949fd1396f60459d14cec53
Author: Coder Agent <coder@agents.omni>
Date: Sun Apr 12 10:41:24 2026
ava: harden claude oauth token refresh handling
Investigated Ava logs and found claude-oauth calls failing with 401
"Invalid authentication credentials" and no refresh attempt.
Fixes:
- Treat Anthropic OAuth 401 responses as refreshable once per request
instead of only matching "OAuth token has expired" text.
- Retry request once after refresh with latest token from auth.json.
- Add Claude CLI-style headers on refresh token POST.
- Make Ava token loading use Auth.getValidToken first so expired
tokens refresh proactively before provider calls.
- Add provider unit tests for 401 refresh decision helper.
Task-Id: t-778
diff --git a/Omni/Agent/Provider.hs b/Omni/Agent/Provider.hs
index 7bbb4037..e9935286 100644
--- a/Omni/Agent/Provider.hs
+++ b/Omni/Agent/Provider.hs
@@ -184,6 +184,12 @@ test =
Test.unit "isRetryable detects timeout" <| do
isRetryableError "Request timed out" Test.@=? True
isRetryableError "Connection timeout" Test.@=? True,
+ Test.unit "shouldRefreshAnthropicOAuthToken refreshes on 401" <| do
+ shouldRefreshAnthropicOAuthToken True 401 Test.@=? True
+ shouldRefreshAnthropicOAuthToken False 401 Test.@=? False,
+ Test.unit "shouldRefreshAnthropicOAuthToken ignores non-401" <| do
+ shouldRefreshAnthropicOAuthToken True 400 Test.@=? False
+ shouldRefreshAnthropicOAuthToken True 429 Test.@=? False,
-- Failover tests
Test.unit "failover uses first working provider" <| do
p1 <- mockProvider [MockText "First provider"]
@@ -1403,7 +1409,7 @@ chatAnthropicOAuth cfg tools messages = do
freshToken <- readFreshOAuthToken
let token = fromMaybe (providerApiKey cfg) freshToken
cfgWithFreshToken = cfg {providerApiKey = token}
- chatAnthropicOAuthWithToken cfgWithFreshToken tools messages
+ chatAnthropicOAuthWithToken cfgWithFreshToken tools messages True
-- | Read fresh OAuth token from auth.json
readFreshOAuthToken :: IO (Maybe Text)
@@ -1426,8 +1432,8 @@ readFreshOAuthToken = do
else pure Nothing
-- | Internal: Chat with a specific token
-chatAnthropicOAuthWithToken :: ProviderConfig -> [ToolApi] -> [Message] -> IO (Either Text ChatResult)
-chatAnthropicOAuthWithToken cfg tools messages = do
+chatAnthropicOAuthWithToken :: ProviderConfig -> [ToolApi] -> [Message] -> Bool -> IO (Either Text ChatResult)
+chatAnthropicOAuthWithToken cfg tools messages allowRefresh = do
let url = Text.unpack (providerBaseUrl cfg) <> "/v1/messages"
req0 <- HTTP.parseRequest url
let reqBase =
@@ -1506,19 +1512,26 @@ chatAnthropicOAuthWithToken cfg tools messages = do
then parseAnthropicResponseOAuth respBody -- Use OAuth parser that maps tool names back
else do
let errText = TE.decodeUtf8 (BL.toStrict respBody)
- -- Check for expired token error
- if status == 401 && "OAuth token has expired" `Text.isInfixOf` errText
+ if shouldRefreshAnthropicOAuthToken allowRefresh status
then do
- putText "OAuth token expired, attempting refresh..."
+ putText "Anthropic OAuth request returned 401, attempting refresh..."
refreshResult <- refreshAnthropicOAuthToken
case refreshResult of
Left refreshErr -> pure (Left ("Token refresh failed: " <> refreshErr))
Right _newToken -> do
putText "Token refreshed, retrying request..."
- -- Retry - chatAnthropicOAuth will read fresh token from file
- chatAnthropicOAuth cfg tools messages
+ freshToken <- readFreshOAuthToken
+ let retriedCfg = cfg {providerApiKey = fromMaybe (providerApiKey cfg) freshToken}
+ chatAnthropicOAuthWithToken retriedCfg tools messages False
else pure (Left ("HTTP error: " <> tshow status <> " - " <> errText))
+-- | Refresh once on Anthropic OAuth auth failures.
+-- Anthropic now often returns generic 401 auth errors (for expired/invalid tokens)
+-- instead of the older "OAuth token has expired" message.
+shouldRefreshAnthropicOAuthToken :: Bool -> Int -> Bool
+shouldRefreshAnthropicOAuthToken allowRefresh status =
+ allowRefresh && status == 401
+
-- | Refresh Anthropic OAuth token using refresh_token from auth files
refreshAnthropicOAuthToken :: IO (Either Text Text)
refreshAnthropicOAuthToken = do
@@ -1557,6 +1570,10 @@ doRefresh authPath refreshToken = do
let req =
HTTP.setRequestMethod "POST"
<| HTTP.setRequestHeader "Content-Type" ["application/json"]
+ <| HTTP.setRequestHeader "Accept" ["application/json"]
+ <| HTTP.setRequestHeader "User-Agent" ["claude-cli/2.1.2 (external, cli)"]
+ <| HTTP.setRequestHeader "x-app" ["cli"]
+ <| HTTP.setRequestHeader "anthropic-dangerous-direct-browser-access" ["true"]
<| HTTP.setRequestBodyLBS (Aeson.encode body)
<| req0
result <- try @SomeException (HTTP.httpLBS req)
diff --git a/Omni/Ava/Telegram/Bot.hs b/Omni/Ava/Telegram/Bot.hs
index 37ea1827..b30e58a0 100644
--- a/Omni/Ava/Telegram/Bot.hs
+++ b/Omni/Ava/Telegram/Bot.hs
@@ -2531,7 +2531,7 @@ loadClaudeOAuthToken = do
case envToken of
Just t -> pure (Just (Text.pack t))
Nothing -> do
- -- Try agent's auth file first (~/.local/share/agent/auth.json)
+ -- Prefer Auth.getValidToken so expired Anthropic OAuth creds refresh automatically.
agentToken <- loadFromAgentAuth
case agentToken of
Just t -> pure (Just t)
@@ -2539,6 +2539,14 @@ loadClaudeOAuthToken = do
where
loadFromAgentAuth :: IO (Maybe Text)
loadFromAgentAuth = do
+ tokenResult <- Auth.getValidToken Auth.ProviderAnthropic
+ case tokenResult of
+ Right creds -> pure (Just (Auth.credAccessToken creds))
+ Left _ -> loadFromAgentAuthFile
+
+ -- Fallback: if refresh/check logic fails, still try raw access token from auth.json.
+ loadFromAgentAuthFile :: IO (Maybe Text)
+ loadFromAgentAuthFile = do
dataDir <- Directory.getXdgDirectory Directory.XdgData "agent"
let authPath = dataDir FilePath.</> "auth.json"
exists <- Directory.doesFileExist authPath