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)