langchain-hs-anthropic-0.0.6.2: src/Langchain/Provider/Anthropic.hs
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{- |
Module : Langchain.Provider.Anthropic
Description : Native Anthropic Claude Messages API provider for langchain-hs
Copyright : (c) 2025-2026 Tushar Adhatrao
License : MIT
Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>
Stability : experimental
Anthropic Claude Messages API (@/v1/messages@) integration implementing
'ChatModel', 'ToolBinder', and 'CacheableChatModel' with full support for
top-level @system@ prompts, multi-modal images, @tool_use@/@tool_result@ blocks,
prompt caching, extended thinking budgets, and SSE incremental tool streaming.
-}
module Langchain.Provider.Anthropic
( -- * Provider & Construction
Anthropic (..)
, newAnthropic
, newAnthropicWithBaseUrl
, withBaseUrl
, withAnthropicVersion
-- * Configuration & Combinators
, AnthropicTool (..)
, AnthropicConfig (..)
, defaultAnthropicConfig
, withMaxTokens
, withThinkingBudget
, withPromptCaching
, withSystem
, withTemperature
-- * Request / Response Conversion & Streaming Helpers
, messagesToAnthropic
, redactApiKey
, splitSseFrames
) where
import Control.Exception (SomeException, displayException, try)
import Control.Monad.Except (throwError)
import Control.Monad.IO.Class (liftIO)
import Data.Aeson (FromJSON (..), ToJSON (..), Value (..), object, (.:), (.:?), (.=))
import qualified Data.Aeson as Aeson
import Data.Aeson.Types (Parser, parseEither)
import Data.Bifunctor (first)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BSC
import qualified Data.ByteString.Lazy as LBS
import Data.Conduit (bracketP, yield)
import qualified Data.List as List
import qualified Data.List.NonEmpty as NonEmpty
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Maybe (catMaybes, fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import GHC.Generics (Generic)
import Network.HTTP.Client
( BodyReader
, HttpException
, Request
, RequestBody (RequestBodyLBS)
, Response
, brConsume
, brRead
, httpLbs
, method
, newManager
, parseRequest
, requestBody
, requestHeaders
, responseBody
, responseClose
, responseOpen
, responseStatus
)
import Network.HTTP.Client.TLS (tlsManagerSettings)
import Network.HTTP.Types.Status (statusCode, statusIsSuccessful, statusMessage)
import Langchain.Cache.Core (CacheableChatModel (..))
import Langchain.Error (LangchainError, llmError)
import Langchain.Model
import Langchain.Stream (ChatStream, StreamEvent (..), TokenUsage (..))
import qualified Langchain.Tool as CoreTool
import Langchain.Tool.Binding (ToolBinder (..))
-- | Anthropic provider configuration for the @/v1/messages@ API.
data Anthropic = Anthropic
{ anthropicApiKey :: !Text
-- ^ Anthropic API key sent via @x-api-key@ header
, anthropicModel :: !Text
-- ^ Target Claude model identifier (e.g. @"claude-sonnet-4-5"@)
, anthropicBaseUrl :: !Text
-- ^ API base URL (default: @"https://api.anthropic.com"@)
, anthropicVersion :: !Text
-- ^ Value for the @anthropic-version@ header (default: @"2023-06-01"@)
}
deriving (Eq, Show, Generic)
-- | Tool schema formatted for the Anthropic Messages API.
data AnthropicTool = AnthropicTool
{ anthropicToolName :: !Text
, anthropicToolDescription :: !Text
, anthropicToolInputSchema :: !Value
}
deriving (Eq, Show, Generic)
instance ToJSON AnthropicTool where
toJSON AnthropicTool {..} =
object
[ "name" .= anthropicToolName
, "description" .= anthropicToolDescription
, "input_schema" .= anthropicToolInputSchema
]
instance FromJSON AnthropicTool where
parseJSON v = case v of
Object o ->
AnthropicTool
<$> o .: "name"
<*> o .: "description"
<*> o .: "input_schema"
_ -> fail "Expected AnthropicTool JSON object"
-- | Runtime request parameters for Anthropic Messages API calls.
data AnthropicConfig = AnthropicConfig
{ configMaxTokens :: !Int
-- ^ Maximum output tokens (required by Anthropic API, default: 4096)
, configTemperature :: !(Maybe Double)
, configTopP :: !(Maybe Double)
, configTopK :: !(Maybe Int)
, configStopSequences :: !(Maybe [Text])
, configSystem :: !(Maybe Text)
, configTools :: !(Maybe [AnthropicTool])
, configToolChoice :: !(Maybe Value)
, configThinkingBudget :: !(Maybe Int)
-- ^ Optional extended thinking token budget
, configPromptCaching :: !Bool
-- ^ Enable ephemeral @cache_control@ markers on system/tools
}
deriving (Eq, Show, Generic, ToJSON, FromJSON)
-- | Default configuration with @max_tokens = 4096@ and prompt caching disabled.
defaultAnthropicConfig :: AnthropicConfig
defaultAnthropicConfig =
AnthropicConfig
{ configMaxTokens = 4096
, configTemperature = Nothing
, configTopP = Nothing
, configTopK = Nothing
, configStopSequences = Nothing
, configSystem = Nothing
, configTools = Nothing
, configToolChoice = Nothing
, configThinkingBudget = Nothing
, configPromptCaching = False
}
-- | Construct an 'Anthropic' provider using the default Anthropic API base URL.
newAnthropic :: Text -> Text -> Anthropic
newAnthropic key mdl =
Anthropic
{ anthropicApiKey = key
, anthropicModel = mdl
, anthropicBaseUrl = "https://api.anthropic.com"
, anthropicVersion = "2023-06-01"
}
-- | Construct an 'Anthropic' provider targeting a custom base URL (e.g. proxy or local mock server).
newAnthropicWithBaseUrl :: Text -> Text -> Text -> Anthropic
newAnthropicWithBaseUrl key mdl url =
(newAnthropic key mdl) {anthropicBaseUrl = url}
-- | Override the API base URL on an 'Anthropic' instance.
withBaseUrl :: Text -> Anthropic -> Anthropic
withBaseUrl url a = a {anthropicBaseUrl = url}
-- | Override the @anthropic-version@ header on an 'Anthropic' instance.
withAnthropicVersion :: Text -> Anthropic -> Anthropic
withAnthropicVersion ver a = a {anthropicVersion = ver}
-- | Configure @max_tokens@.
withMaxTokens :: Int -> AnthropicConfig -> AnthropicConfig
withMaxTokens n cfg = cfg {configMaxTokens = n}
-- | Configure extended thinking token budget (@thinking.budget_tokens@).
withThinkingBudget :: Int -> AnthropicConfig -> AnthropicConfig
withThinkingBudget n cfg = cfg {configThinkingBudget = Just n}
-- | Enable or disable ephemeral prompt caching (@cache_control@).
withPromptCaching :: Bool -> AnthropicConfig -> AnthropicConfig
withPromptCaching enabled cfg = cfg {configPromptCaching = enabled}
-- | Set explicit system instruction override.
withSystem :: Text -> AnthropicConfig -> AnthropicConfig
withSystem sys cfg = cfg {configSystem = Just sys}
-- | Set sampling temperature.
withTemperature :: Double -> AnthropicConfig -> AnthropicConfig
withTemperature t cfg = cfg {configTemperature = Just t}
-- | Redact every occurrence of the API key from error text.
redactApiKey :: Text -> Text -> Text
redactApiKey key txt
| T.null key = txt
| otherwise = T.replace key "[REDACTED]" txt
encodeContentBlock :: ContentBlock -> Value
encodeContentBlock (TextBlock txt) =
object ["type" .= ("text" :: Text), "text" .= txt]
encodeContentBlock (ImageBlock ImageContent {imageSource = ImageBase64 mMime b64}) =
object
[ "type" .= ("image" :: Text)
, "source"
.= object
[ "type" .= ("base64" :: Text)
, "media_type" .= fromMaybe "image/png" mMime
, "data" .= b64
]
]
encodeContentBlock (ImageBlock ImageContent {imageSource = ImageUrl url}) =
object
[ "type" .= ("image" :: Text)
, "source"
.= object
[ "type" .= ("url" :: Text)
, "url" .= url
]
]
encodeContentBlock (AudioBlock mime b64) =
object
[ "type" .= ("text" :: Text)
, "text" .= ("[audio:" <> mime <> " len=" <> T.pack (show (T.length b64)) <> "]")
]
encodeContentBlock (DataBlock bs) =
object
["type" .= ("text" :: Text), "text" .= ("[data:" <> T.pack (show (BS.length bs)) <> " bytes]")]
toRoleAndBlocks :: Message -> Maybe (Text, [Value])
toRoleAndBlocks msg = case messageRole msg of
System -> Nothing
Developer -> Nothing
User -> Just ("user", map encodeContentBlock (NonEmpty.toList (messageContents msg)))
Assistant ->
let txt = extractMessageText msg
txtBlocks = [object ["type" .= ("text" :: Text), "text" .= txt] | not (T.null txt)]
toolBlocks =
[ object
[ "type" .= ("tool_use" :: Text)
, "id" .= toolCallId tc
, "name" .= toolCallName tc
, "input" .= toolCallArguments tc
]
| tc <- fromMaybe [] (messageToolCalls msg)
]
allBlocks = txtBlocks ++ toolBlocks
nonEmptyBlocks =
if null allBlocks then [object ["type" .= ("text" :: Text), "text" .= ("" :: Text)]] else allBlocks
in Just ("assistant", nonEmptyBlocks)
Tool -> toolResultMsg
Function -> toolResultMsg
where
toolResultMsg =
Just
( "user"
,
[ object
[ "type" .= ("tool_result" :: Text)
, "tool_use_id" .= fromMaybe "" (messageToolId msg)
, "content" .= extractMessageText msg
]
]
)
coalesceRoleBlocks :: [(Text, [Value])] -> [(Text, [Value])]
coalesceRoleBlocks = foldr step []
where
step (r, bs) ((r', bs') : rest)
| r == r' = (r, bs ++ bs') : rest
step item acc = item : acc
-- | Convert Langchain 'Message's into Anthropic top-level @system@ and @messages@ JSON values.
messagesToAnthropic :: Maybe Text -> [Message] -> (Maybe Text, [Value])
messagesToAnthropic explicitSys msgs =
let msgSystems =
[ extractMessageText m
| m <- msgs
, messageRole m == System || messageRole m == Developer
, not (T.null (extractMessageText m))
]
sysParts = maybe [] pure explicitSys ++ msgSystems
combinedSys = if null sysParts then Nothing else Just (T.intercalate "\n\n" sysParts)
bodyMsgs =
[ object ["role" .= r, "content" .= bs]
| (r, bs) <- coalesceRoleBlocks (foldr (\m acc -> maybe acc (: acc) (toRoleAndBlocks m)) [] msgs)
]
in (combinedSys, bodyMsgs)
toolWithOptionalCache :: Bool -> Bool -> AnthropicTool -> Value
toolWithOptionalCache True True AnthropicTool {..} =
object
[ "name" .= anthropicToolName
, "description" .= anthropicToolDescription
, "input_schema" .= anthropicToolInputSchema
, "cache_control" .= object ["type" .= ("ephemeral" :: Text)]
]
toolWithOptionalCache _ _ tool = toJSON tool
encodeToolsWithCaching :: Bool -> [AnthropicTool] -> Value
encodeToolsWithCaching _ [] = toJSON ([] :: [AnthropicTool])
encodeToolsWithCaching enableCache tools =
let len = length tools
encoded = zipWith (\idx t -> toolWithOptionalCache enableCache (idx == len) t) [1 ..] tools
in toJSON encoded
buildAnthropicBody :: Anthropic -> Bool -> Maybe AnthropicConfig -> [Message] -> Value
buildAnthropicBody Anthropic {..} isStream mbCfg msgs =
let AnthropicConfig {..} = fromMaybe defaultAnthropicConfig mbCfg
(mbSys, encodedMsgs) = messagesToAnthropic configSystem msgs
sysVal = case mbSys of
Nothing -> Nothing
Just sTxt
| configPromptCaching ->
Just $
toJSON
[ object
[ "type" .= ("text" :: Text)
, "text" .= sTxt
, "cache_control" .= object ["type" .= ("ephemeral" :: Text)]
]
]
| otherwise -> Just (String sTxt)
basePairs =
[ "model" .= anthropicModel
, "max_tokens" .= configMaxTokens
, "messages" .= encodedMsgs
, "stream" .= isStream
]
optPairs =
catMaybes
[ ("system" .=) <$> sysVal
, ("temperature" .=) <$> configTemperature
, ("top_p" .=) <$> configTopP
, ("top_k" .=) <$> configTopK
, ("stop_sequences" .=) <$> configStopSequences
, (\ts -> "tools" .= encodeToolsWithCaching configPromptCaching ts) <$> configTools
, ("tool_choice" .=) <$> configToolChoice
, (\b -> "thinking" .= object ["type" .= ("enabled" :: Text), "budget_tokens" .= b])
<$> configThinkingBudget
]
in object (basePairs ++ optPairs)
messagesEndpoint :: Text -> Text
messagesEndpoint raw =
let trimmed = T.dropWhileEnd (== '/') raw
in if "/v1/messages" `T.isSuffixOf` trimmed
then trimmed
else
if "/v1" `T.isSuffixOf` trimmed
then trimmed <> "/messages"
else trimmed <> "/v1/messages"
mkHttpRequest :: Anthropic -> Bool -> Value -> IO (Either LangchainError Request)
mkHttpRequest a@Anthropic {..} isStream body = do
let urlStr = T.unpack (messagesEndpoint anthropicBaseUrl)
eBaseReq <- try @SomeException (parseRequest urlStr)
pure $ case eBaseReq of
Left ex ->
Left $
llmError
(redactApiKey anthropicApiKey $ "Invalid Anthropic URL: " <> T.pack (displayException ex))
(Just "Anthropic")
Nothing
Right baseReq ->
let _ = a
in Right
baseReq
{ method = "POST"
, requestHeaders =
[ ("x-api-key", TE.encodeUtf8 anthropicApiKey)
, ("anthropic-version", TE.encodeUtf8 anthropicVersion)
, ("content-type", "application/json")
, ("accept", if isStream then "text/event-stream" else "application/json")
]
, requestBody = RequestBodyLBS (Aeson.encode body)
}
parseResponseBlock :: Value -> Parser (Maybe Text, Maybe ToolCall)
parseResponseBlock = Aeson.withObject "AnthropicContentBlock" $ \o -> do
typ :: Text <- o .: "type"
case typ of
"text" -> do
txt <- o .: "text"
pure (Just txt, Nothing)
"tool_use" -> do
cid <- o .: "id"
cname <- o .: "name"
cinput <- o .:? "input"
let tc = ToolCall cid "function" cname (fromMaybe (object []) cinput)
pure (Nothing, Just tc)
_ -> pure (Nothing, Nothing)
parseAnthropicResponse :: Value -> Either Text Message
parseAnthropicResponse =
first T.pack
. parseEither
( Aeson.withObject "AnthropicResponse" $ \o -> do
blocks :: [Value] <- o .: "content"
parsed <- mapM parseResponseBlock blocks
let txts = [t | (Just t, _) <- parsed]
tcs = [tc | (_, Just tc) <- parsed]
fullText = T.concat txts
mbTools = if null tcs then Nothing else Just tcs
pure $ (assistantMessage fullText) {messageToolCalls = mbTools}
)
httpFailureError :: Anthropic -> Response a -> LBS.ByteString -> LangchainError
httpFailureError Anthropic {..} resp bodyBytes =
let code = statusCode (responseStatus resp)
reason = TE.decodeUtf8 (statusMessage (responseStatus resp))
bodyTxt = TE.decodeUtf8 (LBS.toStrict bodyBytes)
rawMsg =
"Anthropic HTTP "
<> T.pack (show code)
<> " "
<> reason
<> ": "
<> bodyTxt
in llmError (redactApiKey anthropicApiKey rawMsg) (Just "Anthropic") Nothing
-- | Split a buffered byte chunk into complete SSE frame payloads (delimited by blank lines) and trailing remainder.
splitSseFrames :: BS.ByteString -> ([BS.ByteString], BS.ByteString)
splitSseFrames raw =
let normalized = BSC.pack (stripCr (BSC.unpack raw))
in go normalized []
where
stripCr [] = []
stripCr ('\r' : '\n' : xs) = '\n' : stripCr xs
stripCr (x : xs) = x : stripCr xs
go buf acc
| BS.null buf = (reverse acc, BS.empty)
| otherwise =
let (frame, restWithSep) = BS.breakSubstring "\n\n" buf
in if BS.null restWithSep
then (reverse acc, buf)
else
let nextBuf = BS.drop 2 restWithSep
nextAcc = if BS.null frame then acc else frame : acc
in go nextBuf nextAcc
data ActiveBlock
= ActiveText
| ActiveTool !Text !Text ![Text]
data AnthropicSseState = AnthropicSseState
{ sseRunId :: !Text
, sseModel :: !Text
, sseStarted :: !Bool
, sseEnded :: !Bool
, sseAccText :: ![Text]
, sseActiveBlocks :: !(Map Int ActiveBlock)
, sseTools :: ![ToolCall]
, sseInputTokens :: !Int
, sseOutputTokens :: !Int
}
initialSseState :: Text -> AnthropicSseState
initialSseState mdl =
AnthropicSseState
{ sseRunId = "anthropic-stream"
, sseModel = mdl
, sseStarted = False
, sseEnded = False
, sseAccText = []
, sseActiveBlocks = Map.empty
, sseTools = []
, sseInputTokens = 0
, sseOutputTokens = 0
}
parseRawSseFrame :: BS.ByteString -> (Maybe Text, Maybe BS.ByteString)
parseRawSseFrame frame =
let ls = BSC.lines frame
in List.foldl' step (Nothing, Nothing) ls
where
step (mEv, mData) line
| "event:" `BS.isPrefixOf` line =
let val = T.strip (TE.decodeUtf8 (BS.drop 6 line))
in (Just val, mData)
| "data:" `BS.isPrefixOf` line =
let chunk = BS.dropWhile (== 32) (BS.drop 5 line)
nextData = case mData of
Nothing -> Just chunk
Just prev -> Just (prev <> "\n" <> chunk)
in (mEv, nextData)
| otherwise = (mEv, mData)
buildFinalStreamMessage :: AnthropicSseState -> Message
buildFinalStreamMessage AnthropicSseState {..} =
let fullTxt = T.concat (reverse sseAccText)
toolsRev = reverse sseTools
mbTools = if null toolsRev then Nothing else Just toolsRev
in (assistantMessage fullTxt) {messageToolCalls = mbTools}
currentTokenUsage :: AnthropicSseState -> TokenUsage
currentTokenUsage AnthropicSseState {..} =
TokenUsage
{ promptTokens = sseInputTokens
, completionTokens = sseOutputTokens
, totalTokens = sseInputTokens + sseOutputTokens
}
ensureStarted :: AnthropicSseState -> [Message] -> ([StreamEvent], AnthropicSseState)
ensureStarted st@AnthropicSseState {..} inputMsgs
| sseStarted = ([], st)
| otherwise =
( [LLMStart sseRunId sseModel inputMsgs]
, st {sseStarted = True}
)
processSseFrame ::
Text ->
[Message] ->
AnthropicSseState ->
BS.ByteString ->
Either LangchainError ([StreamEvent], AnthropicSseState)
processSseFrame apiKey inputMsgs st frame =
case parseRawSseFrame frame of
(_, Nothing) -> Right ([], st)
(mbEvName, Just rawJson) ->
case Aeson.eitherDecodeStrict rawJson of
Left err ->
Left $
llmError
(redactApiKey apiKey $ "Invalid Anthropic SSE JSON: " <> T.pack err)
(Just "Anthropic")
Nothing
Right (Object obj) ->
let evType =
case parseEither (.: "type") obj of
Right t -> t
Left _ -> fromMaybe "" mbEvName
in handleSseObject apiKey inputMsgs st evType obj
Right _ ->
Left $ llmError "Expected JSON object in Anthropic SSE event" (Just "Anthropic") Nothing
handleSseObject ::
Text ->
[Message] ->
AnthropicSseState ->
Text ->
Aeson.Object ->
Either LangchainError ([StreamEvent], AnthropicSseState)
handleSseObject apiKey inputMsgs st evType obj = case evType of
"ping" -> Right ([], st)
"error" ->
let errTxt = case parseEither (\o -> (o .: "error") >>= (.: "message")) obj of
Right m -> m
Left _ -> TE.decodeUtf8 (LBS.toStrict (Aeson.encode obj))
in Left $ llmError (redactApiKey apiKey errTxt) (Just "Anthropic") Nothing
"message_start" ->
case parseEither parseMsgStart obj of
Left err ->
Left $
llmError ("Failed to parse Anthropic message_start: " <> T.pack err) (Just "Anthropic") Nothing
Right (mbId, mbMdl, mbInTok) ->
let st1 =
st
{ sseRunId = fromMaybe (sseRunId st) mbId
, sseModel = fromMaybe (sseModel st) mbMdl
, sseInputTokens = fromMaybe (sseInputTokens st) mbInTok
}
(startEvs, st2) = ensureStarted st1 inputMsgs
in Right (startEvs, st2)
"content_block_start" ->
let (startEvs, st1) = ensureStarted st inputMsgs
in case parseEither parseBlockStart obj of
Left err ->
Left $
llmError
("Failed to parse Anthropic content_block_start: " <> T.pack err)
(Just "Anthropic")
Nothing
Right (idx, bTyp, mbInitText, mbToolMeta) ->
case bTyp of
"tool_use" ->
let (tid, tname) = fromMaybe ("", "") mbToolMeta
st2 = st1 {sseActiveBlocks = Map.insert idx (ActiveTool tid tname []) (sseActiveBlocks st1)}
in Right (startEvs, st2)
_ ->
let st2 = st1 {sseActiveBlocks = Map.insert idx ActiveText (sseActiveBlocks st1)}
initEvs = case mbInitText of
Just txt | not (T.null txt) -> [LLMChunk (sseRunId st2) txt Nothing]
_ -> []
st3 = case mbInitText of
Just txt | not (T.null txt) -> st2 {sseAccText = txt : sseAccText st2}
_ -> st2
in Right (startEvs ++ initEvs, st3)
"content_block_delta" ->
let (startEvs, st1) = ensureStarted st inputMsgs
in case parseEither parseBlockDelta obj of
Left err ->
Left $
llmError
("Failed to parse Anthropic content_block_delta: " <> T.pack err)
(Just "Anthropic")
Nothing
Right (idx, dTyp, mbTxt, mbJsonFrag) -> case dTyp of
"text_delta" ->
let txt = fromMaybe "" mbTxt
st2 = st1 {sseAccText = txt : sseAccText st1}
in Right (startEvs ++ [LLMChunk (sseRunId st2) txt Nothing], st2)
"input_json_delta" ->
let frag = fromMaybe "" mbJsonFrag
upd (ActiveTool tid tname fs) = ActiveTool tid tname (frag : fs)
upd other = other
st2 = st1 {sseActiveBlocks = Map.adjust upd idx (sseActiveBlocks st1)}
in Right (startEvs, st2)
_ -> Right (startEvs, st1)
"content_block_stop" ->
let (startEvs, st1) = ensureStarted st inputMsgs
in case parseEither (.: "index") obj of
Left err ->
Left $
llmError ("Failed to parse Anthropic content_block_stop: " <> T.pack err) (Just "Anthropic") Nothing
Right idx -> case Map.lookup idx (sseActiveBlocks st1) of
Just (ActiveTool tid tname frags) ->
let rawToolJson = T.concat (reverse frags)
parsedArgs
| T.null (T.strip rawToolJson) = Right (object [])
| otherwise = Aeson.eitherDecodeStrict (TE.encodeUtf8 rawToolJson)
in case parsedArgs of
Left jErr ->
Left $
llmError
( redactApiKey apiKey $
"Invalid JSON in completed tool_use block '" <> tname <> "': " <> T.pack jErr
)
(Just "Anthropic")
Nothing
Right argsVal ->
let tc = ToolCall tid "function" tname argsVal
st2 =
st1
{ sseActiveBlocks = Map.delete idx (sseActiveBlocks st1)
, sseTools = tc : sseTools st1
}
in Right (startEvs ++ [LLMChunk (sseRunId st2) "" (Just tc)], st2)
_ ->
let st2 = st1 {sseActiveBlocks = Map.delete idx (sseActiveBlocks st1)}
in Right (startEvs, st2)
"message_delta" ->
case parseEither parseMsgDelta obj of
Left err ->
Left $
llmError ("Failed to parse Anthropic message_delta: " <> T.pack err) (Just "Anthropic") Nothing
Right (mbInTok, mbOutTok) ->
let st1 =
st
{ sseInputTokens = fromMaybe (sseInputTokens st) mbInTok
, sseOutputTokens = fromMaybe (sseOutputTokens st) mbOutTok
}
in Right ([], st1)
"message_stop" ->
let (startEvs, st1) = ensureStarted st inputMsgs
in if sseEnded st1
then Right (startEvs, st1)
else
let endEv = LLMEnd (sseRunId st1) (buildFinalStreamMessage st1) (Just (currentTokenUsage st1))
in Right (startEvs ++ [endEv], st1 {sseEnded = True})
_ -> Right ([], st)
where
parseMsgStart o = do
msg <- o .: "message"
mbId <- msg .:? "id"
mbMdl <- msg .:? "model"
mbUsage <- msg .:? "usage"
mbInTok <- maybe (pure Nothing) (.:? "input_tokens") mbUsage
pure (mbId, mbMdl, mbInTok)
parseBlockStart o = do
idx <- o .: "index"
cb <- o .: "content_block"
bTyp <- cb .: "type"
mbText <- cb .:? "text"
mbTool <- case (bTyp :: Text) of
"tool_use" -> do
tid <- cb .: "id"
tname <- cb .: "name"
pure (Just (tid, tname))
_ -> pure Nothing
pure (idx, bTyp, mbText, mbTool)
parseBlockDelta o = do
idx <- o .: "index"
d <- o .: "delta"
dTyp :: Text <- d .: "type"
mbTxt <- d .:? "text"
mbJson <- d .:? "partial_json"
pure (idx, dTyp, mbTxt, mbJson)
parseMsgDelta o = do
mbUsage <- o .:? "usage"
case mbUsage of
Nothing -> pure (Nothing, Nothing)
Just u -> (,) <$> u .:? "input_tokens" <*> u .:? "output_tokens"
streamSseBody ::
Anthropic ->
[Message] ->
Response BodyReader ->
ChatStream
streamSseBody provider inputMsgs resp = do
if not (statusIsSuccessful (responseStatus resp))
then do
errBody <- LBS.fromChunks <$> liftIO (brConsume (responseBody resp))
throwError $ httpFailureError provider resp errBody
else loop BS.empty (initialSseState (anthropicModel provider))
where
apiKey = anthropicApiKey provider
reader = responseBody resp
emitFrames _ st [] = pure (Right st)
emitFrames _ st (f : fs) =
case processSseFrame apiKey inputMsgs st f of
Left err -> pure (Left err)
Right (evs, st') -> do
mapM_ yield evs
emitFrames apiKey st' fs
loop buf st = do
eChunk <- liftIO $ try @SomeException (brRead reader)
case eChunk of
Left ex ->
throwError $
llmError
(redactApiKey apiKey $ "Anthropic stream transport error: " <> T.pack (displayException ex))
(Just "Anthropic")
Nothing
Right chunk
| BS.null chunk -> do
let (finalFrames, _) = splitSseFrames (buf <> "\n\n")
eFinalSt <- emitFrames apiKey st finalFrames
case eFinalSt of
Left err -> throwError err
Right st' ->
if sseEnded st'
then pure ()
else do
let (startEvs, stStarted) = ensureStarted st' inputMsgs
mapM_ yield startEvs
yield $
LLMEnd
(sseRunId stStarted)
(buildFinalStreamMessage stStarted)
(Just (currentTokenUsage stStarted))
| otherwise -> do
let (frames, nextBuf) = splitSseFrames (buf <> chunk)
eNextSt <- emitFrames apiKey st frames
case eNextSt of
Left err -> throwError err
Right st' -> loop nextBuf st'
instance ChatModel Anthropic where
type ModelConfig Anthropic = AnthropicConfig
invoke provider msgs mbCfg = do
let reqJson = buildAnthropicBody provider False mbCfg msgs
apiKey = anthropicApiKey provider
eReq <- liftIO $ mkHttpRequest provider False reqJson
req <- case eReq of
Left err -> throwError err
Right r -> pure r
eResp <- liftIO $ try @HttpException $ do
mgr <- newManager tlsManagerSettings
httpLbs req mgr
case eResp of
Left ex ->
throwError $
llmError
(redactApiKey apiKey $ "Anthropic request error: " <> T.pack (displayException ex))
(Just "Anthropic")
Nothing
Right resp ->
if not (statusIsSuccessful (responseStatus resp))
then throwError $ httpFailureError provider resp (responseBody resp)
else case Aeson.eitherDecode (responseBody resp) of
Left err ->
throwError $
llmError
(redactApiKey apiKey $ "Invalid Anthropic response JSON: " <> T.pack err)
(Just "Anthropic")
Nothing
Right val -> case parseAnthropicResponse val of
Left err ->
throwError $
llmError
(redactApiKey apiKey $ "Failed to decode Anthropic message: " <> err)
(Just "Anthropic")
Nothing
Right msg -> pure msg
stream provider msgs mbCfg = do
let reqJson = buildAnthropicBody provider True mbCfg msgs
eReq <- liftIO $ mkHttpRequest provider True reqJson
req <- case eReq of
Left err -> throwError err
Right r -> pure r
mgr <- liftIO $ newManager tlsManagerSettings
bracketP (responseOpen req mgr) responseClose (streamSseBody provider msgs)
instance ToolBinder Anthropic m where
bindToolsConfig tools mbCfg =
let cfg = fromMaybe defaultAnthropicConfig mbCfg
converted =
[ AnthropicTool
{ anthropicToolName = CoreTool.toolName t
, anthropicToolDescription = CoreTool.toolDescription t
, anthropicToolInputSchema = CoreTool.toolSchema t
}
| t <- tools
]
existing = fromMaybe [] (configTools cfg)
existingNames = map anthropicToolName existing
merged = existing ++ filter (\t -> anthropicToolName t `notElem` existingNames) converted
in Just $
if null merged
then cfg
else cfg {configTools = Just merged}
instance CacheableChatModel Anthropic where
cacheModelIdentity Anthropic {..} mbCfg =
let cfg = fromMaybe defaultAnthropicConfig mbCfg
in object
[ "provider" .= ("anthropic" :: Text)
, "model" .= anthropicModel
, "baseUrl" .= anthropicBaseUrl
, "version" .= anthropicVersion
, "maxTokens" .= configMaxTokens cfg
, "system" .= configSystem cfg
, "temperature" .= configTemperature cfg
, "thinkingBudget" .= configThinkingBudget cfg
, "promptCaching" .= configPromptCaching cfg
, "tools" .= configTools cfg
]