← Back to task

Commit 5845792d

commit 5845792d346b06801f5a6e002249f5f794ec90ed
Author: Ben Sima <ben@bensima.com>
Date:   Thu Jan 1 23:42:54 2026

    Fix Claude Code OAuth - add beta header and Claude Code identity (t-319)
    
    OAuth tokens require:
    1. anthropic-beta: oauth-2025-04-20 header
    2. System prompt identifying as Claude Code
    3. Bearer auth instead of x-api-key
    4. URL-encoded scopes
    
    Added AnthropicOAuth provider variant that handles these requirements.
    
    Task-Id: t-319

diff --git a/Omni/Agent.hs b/Omni/Agent.hs
index aa76bfcb..aa5d80ad 100755
--- a/Omni/Agent.hs
+++ b/Omni/Agent.hs
@@ -349,7 +349,7 @@ resolveClaudeCodeProvider mModel = do
   case tokenResult of
     Right token -> do
       TextIO.hPutStrLn IO.stderr "[agent] Using Claude Code (OAuth)"
-      pure <| Right <| Provider.anthropicProvider token (fromMaybe "claude-sonnet-4-20250514" mModel)
+      pure <| Right <| Provider.anthropicOAuthProvider token (fromMaybe "claude-sonnet-4-20250514" mModel)
     Left err -> do
       -- Need to login
       TextIO.hPutStrLn IO.stderr <| "[agent] " <> err
@@ -359,4 +359,4 @@ resolveClaudeCodeProvider mModel = do
         Left loginErr -> pure <| Left <| "OAuth login failed: " <> loginErr
         Right creds -> do
           TextIO.hPutStrLn IO.stderr "[agent] Login successful!"
-          pure <| Right <| Provider.anthropicProvider (Auth.credAccessToken creds) (fromMaybe "claude-sonnet-4-20250514" mModel)
+          pure <| Right <| Provider.anthropicOAuthProvider (Auth.credAccessToken creds) (fromMaybe "claude-sonnet-4-20250514" mModel)
diff --git a/Omni/Agent/Auth.hs b/Omni/Agent/Auth.hs
index f164cf5c..978cd6fe 100644
--- a/Omni/Agent/Auth.hs
+++ b/Omni/Agent/Auth.hs
@@ -109,7 +109,7 @@ redirectUri :: Text
 redirectUri = "https://console.anthropic.com/oauth/code/callback"
 
 scopes :: Text
-scopes = "org:create_api_key user:profile user:inference"
+scopes = "org:create_api_key%20user:profile%20user:inference"
 
 -- | Path to credentials file
 credentialsFile :: IO FilePath
diff --git a/Omni/Agent/Engine.hs b/Omni/Agent/Engine.hs
index d4e1ccaa..0cd55c29 100644
--- a/Omni/Agent/Engine.hs
+++ b/Omni/Agent/Engine.hs
@@ -1070,6 +1070,7 @@ runAgentWithProvider engineCfg provider agentCfg userPrompt = do
     getProviderModel (Provider.OpenRouter cfg) = Provider.providerModel cfg
     getProviderModel (Provider.Ollama cfg) = Provider.providerModel cfg
     getProviderModel (Provider.Anthropic cfg) = Provider.providerModel cfg
+    getProviderModel (Provider.AnthropicOAuth cfg) = Provider.providerModel cfg
     getProviderModel (Provider.AmpCLI _) = "amp"
 
     updateProviderToolCallCounts :: Map.Map Text Int -> [Provider.ToolCall] -> Map.Map Text Int
@@ -1278,6 +1279,7 @@ runAgentWithProviderStreaming engineCfg provider agentCfg userPrompt onStreamChu
     getProviderModelStreaming (Provider.OpenRouter cfg) = Provider.providerModel cfg
     getProviderModelStreaming (Provider.Ollama cfg) = Provider.providerModel cfg
     getProviderModelStreaming (Provider.Anthropic cfg) = Provider.providerModel cfg
+    getProviderModelStreaming (Provider.AnthropicOAuth cfg) = Provider.providerModel cfg
     getProviderModelStreaming (Provider.AmpCLI _) = "amp"
 
     updateToolCallCountsStreaming :: Map.Map Text Int -> [Provider.ToolCall] -> Map.Map Text Int
diff --git a/Omni/Agent/Provider.hs b/Omni/Agent/Provider.hs
index 19a0b783..7a1c6364 100644
--- a/Omni/Agent/Provider.hs
+++ b/Omni/Agent/Provider.hs
@@ -30,6 +30,7 @@ module Omni.Agent.Provider
     openRouterProvider,
     ollamaProvider,
     anthropicProvider,
+    anthropicOAuthProvider,
     -- * Legacy defaults
     defaultOpenRouter,
     defaultOllama,
@@ -122,6 +123,7 @@ data Provider
   = OpenRouter ProviderConfig
   | Ollama ProviderConfig
   | Anthropic ProviderConfig
+  | AnthropicOAuth ProviderConfig  -- OAuth token uses Bearer auth instead of x-api-key
   | AmpCLI FilePath
   deriving (Show, Eq, Generic)
 
@@ -158,6 +160,17 @@ anthropicProvider apiKey model =
         providerExtraHeaders = []
       }
 
+-- | Create an Anthropic OAuth provider (for Claude Max/Pro subscriptions)
+anthropicOAuthProvider :: Text -> Text -> Provider
+anthropicOAuthProvider token model =
+  AnthropicOAuth
+    ProviderConfig
+      { providerBaseUrl = "https://api.anthropic.com",
+        providerApiKey = token,  -- This is actually a Bearer token
+        providerModel = model,
+        providerExtraHeaders = []
+      }
+
 data ProviderConfig = ProviderConfig
   { providerBaseUrl :: Text,
     providerApiKey :: Text,
@@ -387,6 +400,7 @@ chatWithUsage :: Provider -> [ToolApi] -> [Message] -> IO (Either Text ChatResul
 chatWithUsage (OpenRouter cfg) tools messages = chatOpenAI cfg tools messages
 chatWithUsage (Ollama cfg) tools messages = chatOllama cfg tools messages
 chatWithUsage (Anthropic cfg) tools messages = chatAnthropic cfg tools messages
+chatWithUsage (AnthropicOAuth cfg) tools messages = chatAnthropicOAuth cfg tools messages
 chatWithUsage (AmpCLI _promptFile) _tools _messages = do
   pure (Left "Amp CLI provider not yet implemented")
 
@@ -486,6 +500,51 @@ chatAnthropic cfg tools messages = do
       then parseAnthropicResponse respBody
       else pure (Left ("HTTP error: " <> tshow status <> " - " <> TE.decodeUtf8 (BL.toStrict respBody)))
 
+-- | Chat via Anthropic API with OAuth token (Bearer auth)
+chatAnthropicOAuth :: ProviderConfig -> [ToolApi] -> [Message] -> IO (Either Text ChatResult)
+chatAnthropicOAuth cfg tools messages = do
+  let url = Text.unpack (providerBaseUrl cfg) <> "/v1/messages"
+  req0 <- HTTP.parseRequest url
+  
+  -- Anthropic wants system message separate, not in messages array
+  let (userSystemMsg, userMsgs) = extractSystemMessage messages
+      -- OAuth requires Claude Code identity as first system block
+      claudeCodeIdentity :: Text
+      claudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude."
+      systemBlocks :: [Aeson.Value]
+      systemBlocks = 
+        [ Aeson.object [("type" :: Aeson.Key) .= ("text" :: Text), ("text" :: Aeson.Key) .= claudeCodeIdentity]
+        ] ++ if Text.null userSystemMsg 
+             then []
+             else [Aeson.object [("type" :: Aeson.Key) .= ("text" :: Text), ("text" :: Aeson.Key) .= userSystemMsg]]
+      -- Convert tool API to Anthropic format
+      anthropicTools = map convertToolToAnthropic tools
+      body =
+        Aeson.object <|
+          [ "model" .= providerModel cfg,
+            "max_tokens" .= (8192 :: Int),
+            "messages" .= map convertMessageToAnthropic userMsgs,
+            "system" .= systemBlocks
+          ]
+          ++ ["tools" .= anthropicTools | not (null tools)]
+      -- OAuth requires anthropic-beta header with oauth feature flag
+      req =
+        HTTP.setRequestMethod "POST"
+          <| HTTP.setRequestHeader "Content-Type" ["application/json"]
+          <| HTTP.setRequestHeader "Authorization" ["Bearer " <> TE.encodeUtf8 (providerApiKey cfg)]
+          <| HTTP.setRequestHeader "anthropic-version" ["2023-06-01"]
+          <| HTTP.setRequestHeader "anthropic-beta" ["oauth-2025-04-20"]
+          <| HTTP.setRequestBodyLBS (Aeson.encode body)
+          <| req0
+
+  retryWithBackoff maxRetries initialBackoffMicros <| do
+    response <- HTTP.httpLBS req
+    let status = HTTP.getResponseStatusCode response
+        respBody = HTTP.getResponseBody response
+    if status >= 200 && status < 300
+      then parseAnthropicResponse respBody
+      else pure (Left ("HTTP error: " <> tshow status <> " - " <> TE.decodeUtf8 (BL.toStrict respBody)))
+
 -- | Extract system message from message list
 extractSystemMessage :: [Message] -> (Text, [Message])
 extractSystemMessage msgs =
@@ -635,6 +694,7 @@ chatStream :: Provider -> [ToolApi] -> [Message] -> (StreamChunk -> IO ()) -> IO
 chatStream (OpenRouter cfg) tools messages onChunk = chatStreamOpenAI cfg tools messages onChunk
 chatStream (Ollama _cfg) _tools _messages _onChunk = pure (Left "Streaming not implemented for Ollama")
 chatStream (Anthropic _cfg) _tools _messages _onChunk = pure (Left "Streaming not implemented for Anthropic")
+chatStream (AnthropicOAuth _cfg) _tools _messages _onChunk = pure (Left "Streaming not implemented for AnthropicOAuth")
 chatStream (AmpCLI _) _tools _messages _onChunk = pure (Left "Streaming not implemented for AmpCLI")
 
 chatStreamOpenAI :: ProviderConfig -> [ToolApi] -> [Message] -> (StreamChunk -> IO ()) -> IO (Either Text ChatResult)