packages feed

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

module Praxo.OpenAICompatible
    ( HttpRequest(..)
    , HttpResponse(..)
    , Transport(..)
    , OpenAITool(..)
    , OpenAIConfig(..)
    , buildRequest
    , parseResponse
    , generate
    ) where

import Praxo.Agent (AgentError(..), Message(..), Response(..), ToolCall(..))
import Praxo.Json (Json(..), asArray, asString, encodeJson, field, parseJson)

data HttpRequest = HttpRequest
    { httpMethod :: String
    , httpURL :: String
    , httpHeaders :: [(String, String)]
    , httpBody :: String
    } deriving (Eq, Show)

data HttpResponse = HttpResponse
    { httpStatus :: Int
    , httpResponseBody :: String
    } deriving (Eq, Show)

newtype Transport = Transport
    { sendTransport :: HttpRequest -> IO (Either AgentError HttpResponse)
    }

data OpenAITool = OpenAITool
    { openAIToolName :: String
    , openAIToolDescription :: String
    , openAIToolParameters :: Json
    } deriving (Eq, Show)

data OpenAIConfig = OpenAIConfig
    { openAIBaseURL :: String
    , openAIModel :: String
    , openAIKey :: Maybe String
    , openAIToolChoice :: Maybe String
    , openAITools :: [OpenAITool]
    , openAITransport :: Transport
    }

buildRequest :: OpenAIConfig -> [Message] -> Either AgentError HttpRequest
buildRequest config messages
    | null (openAIBaseURL config) = Left (AgentError "PROVIDER_HTTP" "provider base URL is required")
    | otherwise = do
        encoded <- mapM messageJson messages
        let toolValues = map toolJson (openAITools config)
            base =
                [ ("model", JString (openAIModel config))
                , ("messages", JArray encoded)
                ]
            withTools = if null toolValues then base else base ++ [("tools", JArray toolValues)]
            withChoice = case openAIToolChoice config of
                Nothing -> withTools
                Just choice -> withTools ++ [("tool_choice", JString choice)]
        case encodeJson (JObject withChoice) of
            Left detail -> Left (AgentError "PROVIDER_REQUEST" detail)
            Right text ->
                Right HttpRequest
                    { httpMethod = "POST"
                    , httpURL = trimSlash (openAIBaseURL config) ++ "/chat/completions"
                    , httpHeaders =
                        [ ("Content-Type", "application/json")
                        , ("Accept", "application/json")
                        ] ++ case openAIKey config of
                            Nothing -> []
                            Just key -> [("Authorization", "Bearer " ++ key)]
                    , httpBody = text
                    }
  where
    trimSlash = reverse . dropWhile (== '/') . reverse

parseResponse :: String -> Either AgentError Response
parseResponse text = do
    root <- case parseJson text of
        Left detail -> Left (AgentError "PROVIDER_RESPONSE" ("provider returned invalid JSON: " ++ detail))
        Right value -> Right value
    case asArray (field "choices" root) of
        [] -> Left (AgentError "PROVIDER_RESPONSE" "provider response has no choices")
        choice : _ -> do
            let message = field "message" choice
            calls <- mapM callFromJson (asArray (field "tool_calls" message))
            let usage = field "usage" root
            pure Response
                { responseContent = asString (field "content" message)
                , responseToolCalls = calls
                , inputTokens = jsonInteger (field "prompt_tokens" usage)
                , outputTokens = jsonInteger (field "completion_tokens" usage)
                }
  where
    callFromJson item = do
        let function = field "function" item
        arguments <- case field "arguments" function of
            JString raw | not (null raw) -> case parseJson raw of
                Left detail -> Left (AgentError "PROVIDER_RESPONSE" ("tool arguments are not valid JSON: " ++ detail))
                Right value@(JObject _) -> Right value
                Right _ -> Left (AgentError "PROVIDER_RESPONSE" "tool arguments must be an object")
            value@(JObject _) -> Right value
            _ -> Right (JObject [])
        pure ToolCall
            { toolCallId = asString (field "id" item)
            , toolCallName = asString (field "name" function)
            , toolCallArguments = arguments
            }

generate :: OpenAIConfig -> [Message] -> IO (Either AgentError Response)
generate config messages =
    case buildRequest config messages of
        Left errorValue -> pure (Left errorValue)
        Right httpRequest -> do
            result <- sendTransport (openAITransport 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 -> parseResponse (httpResponseBody response)

messageJson :: Message -> Either AgentError Json
messageJson message = do
    calls <- mapM callJson (messageToolCalls message)
    let base =
            [ ("role", JString (messageRole message))
            , ("content", JString (messageContent message))
            ]
        withCalls = if null calls then base else base ++ [("tool_calls", JArray calls)]
        withTool =
            if messageRole message == "tool" && messageToolCallId message /= ""
                then withCalls ++ [("tool_call_id", JString (messageToolCallId message))]
                else withCalls
    pure (JObject withTool)
  where
    callJson call = do
        arguments <- case encodeJson (toolCallArguments call) of
            Left detail -> Left (AgentError "PROVIDER_REQUEST" ("tool arguments: " ++ detail))
            Right text -> Right text
        pure (JObject
            [ ("id", JString (toolCallId call))
            , ("type", JString "function")
            , ("function", JObject
                [ ("name", JString (toolCallName call))
                , ("arguments", JString arguments)
                ])
            ])

toolJson :: OpenAITool -> Json
toolJson tool =
    JObject
        [ ("type", JString "function")
        , ("function", JObject
            [ ("name", JString (openAIToolName tool))
            , ("description", JString (openAIToolDescription tool))
            , ("parameters", parameters)
            ])
        ]
  where
    parameters = case openAIToolParameters tool of
        JNull -> JObject [("type", JString "object")]
        value -> value

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