packages feed

genai-lib-2.0: src/lib/GenAILib/OpenAI.hs

{-# LANGUAGE DuplicateRecordFields, OverloadedRecordDot #-}

module GenAILib.OpenAI
  ( OpenAIResponse (..)
  , OpenAIRequest (..)
  , defaultUrl
  , getMessage
  )
  where

import Data.Aeson (FromJSON, ToJSON, Value (Object, String), (.:),
  genericToEncoding, genericToJSON, parseJSON, toEncoding, toJSON)
import Data.Aeson.Key (fromText)
import Data.Aeson.KeyMap (singleton, union)
import Data.Aeson.Types (prependFailure, typeMismatch)
import Data.Maybe (fromMaybe)
import Data.Sequence (Seq, (|>), empty)
import Data.Text qualified as TS
import Data.Text.Lazy qualified as TL
import Data.Time (UTCTime)
import Data.Time.Clock.POSIX (posixSecondsToUTCTime)
import GHC.Generics (Generic)
import Servant.Client (BaseUrl, parseBaseUrl)

import GenAILib (Content (..), Message (..), Model (..), Request (..),
  RequestMod (..), Stream (..))
import GenAILib.JSON (customJSONOptions, flattenOptions)


-- | Data for making OpenAI requests
data OpenAIRequest = OpenAIRequest
  { model :: Model
  , messages :: Seq Message
  , options :: Maybe Value
  , stream :: Maybe Stream
  }
  deriving (Generic, Show)

instance ToJSON OpenAIRequest where
  toJSON = flattenOptions . genericToJSON customJSONOptions
  toEncoding = genericToEncoding customJSONOptions

instance Request OpenAIRequest where
  appendMessage :: Message -> OpenAIRequest -> OpenAIRequest
  appendMessage newMessage oldReq = oldReq { messages = oldReq.messages |> newMessage }

  mkRequest :: TL.Text -> RequestMod OpenAIRequest -> OpenAIRequest
  mkRequest modelName (RequestMod optSetter) = optSetter
    $ OpenAIRequest (Model modelName) empty Nothing Nothing

  setOpt :: TS.Text -> Value -> OpenAIRequest -> OpenAIRequest
  setOpt keystr val req = req { options = Just . Object $ combinedKm }
    where
      newKm = singleton (fromText keystr) val
      combinedKm = case options req of
        (Just (Object existingKm)) -> existingKm `union` newKm
        _ -> newKm


-- | Data returned for OpenAI requests
data OpenAIResponse = OpenAIResponse
  { model :: Model
  , created :: UTCTime
  , message :: Message
  , done :: Bool
  }
  deriving (Generic, Show)

-- Unfortunately, the OpenAI response JSON is a weird enough shape I decided to
-- take it apart it by-hand
{- HLINT ignore "Functor law" -}
instance FromJSON OpenAIResponse where
  parseJSON (Object o) = do
    choices <- o .: "choices"
    choice <- if null choices
      then fail "No elements in choices array, unable to decode this JSON"
      else pure $ head choices
    OpenAIResponse
      <$> (Model <$> o .: "model")
      <*> (posixSecondsToUTCTime <$> o .: "created")
      <*> (parseJSON =<< choice .: "message")
      <*> (isDone <$> choice .: "finish_reason")
  parseJSON invalid    = prependFailure "Parsing OpenAI chat completion response"
    $ typeMismatch "Object" invalid

isDone :: Value -> Bool
isDone (String s) = s == "stop"
isDone _ = False


-- | The default URL for OpenAI REST calls
defaultUrl :: BaseUrl
defaultUrl = fromMaybe (error "Unable to construct the default url for OpenAI!")
  $ parseBaseUrl "https://api.openai.com"


-- | Convenience function to extract the response text
getMessage :: OpenAIResponse -> TL.Text
getMessage res = assistantResponse
  where (Content assistantResponse) = content . message $ res