packages feed

praxo-core-0.3.0: src/Praxo/Anthropic.hs

module Praxo.Anthropic
    ( AnthropicTool(..)
    , AnthropicConfig(..)
    , buildAnthropicRequest
    , parseAnthropicResponse
    , generateAnthropic
    ) where

import Data.List (intercalate)
import Praxo.Agent (AgentError(..), Message(..), Response(..), ToolCall(..))
import Praxo.Json (Json(..), asArray, asString, encodeJson, field, parseJson)
import Praxo.OpenAICompatible (HttpRequest(..), HttpResponse(..), Transport(..))

data AnthropicTool = AnthropicTool
    { anthropicToolName :: String
    , anthropicToolDescription :: String
    , anthropicToolParameters :: Json
    } deriving (Eq, Show)

data AnthropicConfig = AnthropicConfig
    { anthropicBaseURL :: String
    , anthropicModel :: String
    , anthropicKey :: Maybe String
    , anthropicVersion :: String
    , anthropicMaxTokens :: Integer
    , anthropicToolChoice :: Maybe String
    , anthropicTools :: [AnthropicTool]
    , anthropicTransport :: Transport
    }

buildAnthropicRequest :: AnthropicConfig -> [Message] -> Either AgentError HttpRequest
buildAnthropicRequest config messages
    | null (anthropicBaseURL config) = Left (AgentError "PROVIDER_HTTP" "provider base URL is required")
    | otherwise = do
        (system, encoded) <- messagesJson messages
        let toolValues = map toolJson (anthropicTools config)
            base =
                [ ("model", JString (anthropicModel config))
                , ("max_tokens", JNumber (fromIntegral (anthropicMaxTokens config)))
                , ("messages", JArray encoded)
                ]
            withSystem = if null system then base else base ++ [("system", JString (intercalate "\n\n" system))]
            withTools = if null toolValues then withSystem else withSystem ++ [("tools", JArray toolValues)]
            withChoice = case anthropicToolChoice config of
                Just "auto" -> withTools ++ [("tool_choice", choice "auto")]
                Just "required" -> withTools ++ [("tool_choice", choice "any")]
                Just "none" -> withTools ++ [("tool_choice", choice "none")]
                _ -> withTools
        case encodeJson (JObject withChoice) of
            Left detail -> Left (AgentError "PROVIDER_REQUEST" detail)
            Right text ->
                Right HttpRequest
                    { httpMethod = "POST"
                    , httpURL = trimSlash (anthropicBaseURL config) ++ "/v1/messages"
                    , httpHeaders =
                        [ ("Content-Type", "application/json")
                        , ("Accept", "application/json")
                        ] ++ case anthropicKey config of
                            Nothing -> []
                            Just key -> [("x-api-key", key), ("anthropic-version", anthropicVersion config)]
                    , httpBody = text
                    }
  where
    choice value = JObject [("type", JString value)]
    trimSlash = reverse . dropWhile (== '/') . reverse

messagesJson :: [Message] -> Either AgentError ([String], [Json])
messagesJson messages = go messages [] [] []
  where
    go [] system out pending = do
        flushed <- flush pending out
        pure (reverse system, reverse flushed)
    go (message : rest) system out pending
        | messageRole message == "system" =
            go rest (messageContent message : system) out pending
        | messageRole message == "tool" =
            go rest system out (pending ++ [toolResult message])
        | otherwise = do
            flushed <- flush pending out
            content <- messageContentJson message
            go rest system (if null content then flushed else JObject
                [("role", JString (messageRole message)), ("content", JArray content)] : flushed) []
    flush [] out = pure out
    flush pending out = pure (JObject [("role", JString "user"), ("content", JArray pending)] : out)

toolResult :: Message -> Json
toolResult message = JObject
    [ ("type", JString "tool_result")
    , ("tool_use_id", JString (messageToolCallId message))
    , ("content", JString (messageContent message))
    ]

messageContentJson :: Message -> Either AgentError [Json]
messageContentJson message = do
    calls <- mapM callJson (messageToolCalls message)
    let text = if null (messageContent message) then [] else [JObject [("type", JString "text"), ("text", JString (messageContent message))]]
    pure (text ++ calls)
  where
    callJson call =
        pure (JObject
            [ ("type", JString "tool_use")
            , ("id", JString (toolCallId call))
            , ("name", JString (toolCallName call))
            , ("input", toolCallArguments call)
            ])

toolJson :: AnthropicTool -> Json
toolJson tool = JObject
    [ ("name", JString (anthropicToolName tool))
    , ("description", JString (anthropicToolDescription tool))
    , ("input_schema", parameters)
    ]
  where
    parameters = case anthropicToolParameters tool of
        JNull -> JObject [("type", JString "object")]
        value -> value

parseAnthropicResponse :: String -> Either AgentError Response
parseAnthropicResponse text = do
    root <- parseJson text `orAgent` "PROVIDER_RESPONSE"
    if field "error" root /= JNull
        then Left (AgentError "PROVIDER_HTTP" "anthropic: provider error")
        else do
            allCalls <- mapM callFromJson (asArray (field "content" root))
            let calls = filter (\call -> not (null (toolCallName call))) allCalls
                content = concatMap blockText (asArray (field "content" root))
                usage = field "usage" root
            pure Response
                { responseContent = content
                , responseToolCalls = calls
                , inputTokens = jsonInteger (field "input_tokens" usage)
                , outputTokens = jsonInteger (field "output_tokens" usage)
                }
  where
    blockText block = if asString (field "type" block) == "text"
        then asString (field "text" block) else ""
    callFromJson block
        | asString (field "type" block) /= "tool_use" = Right emptyCall
        | otherwise = Right ToolCall
            { toolCallId = asString (field "id" block)
            , toolCallName = asString (field "name" block)
            , toolCallArguments = case field "input" block of
                value@(JObject _) -> value
                _ -> JObject []
            }
    emptyCall = ToolCall "" "" (JObject [])

generateAnthropic :: AnthropicConfig -> [Message] -> IO (Either AgentError Response)
generateAnthropic config messages =
    case buildAnthropicRequest config messages of
        Left errorValue -> pure (Left errorValue)
        Right httpRequest -> do
            result <- sendTransport (anthropicTransport config) httpRequest
            pure $ case result of
                Left errorValue -> Left errorValue
                Right response
                    | httpStatus response < 200 || httpStatus response >= 300 ->
                        Left (AgentError "PROVIDER_HTTP"
                            ("provider returned HTTP " ++ show (httpStatus response) ++ ": " ++ bounded (httpResponseBody response)))
                    | otherwise -> parseAnthropicResponse (httpResponseBody response)

orAgent :: Either String Json -> String -> Either AgentError Json
orAgent (Right value) _ = Right value
orAgent (Left detail) code = Left (AgentError code detail)

jsonInteger :: Json -> Integer
jsonInteger (JNumber value) = truncate value
jsonInteger _ = 0

bounded :: String -> String
bounded text = if length text > 512 then take 512 text else text