packages feed

genai-lib-2.0.1: src/lib/GenAILib.hs

module GenAILib
  (
  -- * A library for interacting with LLM back-ends like Ollama and OpenAI
  --
  -- | An example
  --
  -- > import Control.Exception (Handler (..), catches)
  -- > import Data.Text.Lazy.IO qualified as TL
  -- > import GenAILib (ClientError, Request (..), jsonToText, mkRequest, numopt,
  -- >   stringopt, systemmsg, usermsg)
  -- > import GenAILib.HTTP (GenAIException, openaiV1Chat, openaiV1ChatJ,
  -- >   tokenFromFile)
  -- > import GenAILib.OpenAI (OpenAIRequest, getMessage)
  -- >
  -- >
  -- > main :: IO ()
  -- > main = do
  -- >   openaiJSON
  -- >   openaiData
  -- >
  -- > -- A simple example with no error handling that expects an Aeson Value
  -- > -- (OpenAIRequest is an instance of ToJSON) and displays the encoded JSON
  -- > -- response
  -- > openaiJSON :: IO ()
  -- > openaiJSON = do
  -- >   let req :: OpenAIRequest = mkRequest "gpt-3.5-turbo" $ usermsg "Why is the sky blue?"
  -- >   token <- tokenFromFile "path/to/openai/key"
  -- >   TL.putStrLn . jsonToText =<< openaiV1ChatJ token Nothing req
  -- >
  -- > -- Another example with exception handling that expects an OpenAIResponse
  -- > -- data structure, displaying that and also just the response text
  -- > openaiData :: IO ()
  -- > openaiData = do
  -- >   let req :: OpenAIRequest = mkRequest "gpt-3.5-turbo"
  -- >           (  systemmsg "Answer in the style of Bugs Bunny. Try to work the phrase \"What's up Doc?\" in somewhere."
  -- >           <> usermsg "Why is the sky blue?"
  -- >           <> numopt "temperature" 0.8
  -- >           <> stringopt "service_tier" "default"
  -- >           )
  -- >   token <- tokenFromFile "path/to/openai/key"
  -- >   res <- openaiV1Chat token Nothing req `catches`
  -- >     [ Handler (\(e :: GenAIException) -> error . show $ e)
  -- >     , Handler (\(e :: ClientError) -> error . show $ e)
  -- >     ]
  -- >   print res  -- The entire OpenAIResponse
  -- >   TL.putStrLn . getMessage $ res  -- Just the response Message Content

    Request (..)
  , RequestMod (..)
  , usermsg
  , numopt
  , boolopt
  , stringopt
  , systemmsg
  , optional
  , mkMessage
  , mkMessageWithRole
  , Model (..)
  , Message (..)
  , Role (..)
  , Content (..)
  , ImageData (..)
  , Stream (..)

  -- re-exporting
  , ClientError
  , module GenAILib.JSON
  )
  where

import Data.Aeson (FromJSON, ToJSON, Value (Number, String))
import Data.Aeson qualified as A
import Data.List.NonEmpty (NonEmpty)
import Data.Scientific (fromFloatDigits)
import Data.Text qualified as TS
import Data.Text.Lazy qualified as TL
import GHC.Generics (Generic)
import Servant.Client.Core (ClientError)

import GenAILib.JSON


-- | The name of an LLM model
--
--   This lets the back-end know which model we want to use by name.
newtype Model = Model TL.Text
  deriving (Generic, Show)

instance FromJSON Model
instance ToJSON Model


-- | Text values sent to and from LLMs like prompts and responses.
newtype Content = Content TL.Text
  deriving (Generic, Show)

instance FromJSON Content
instance ToJSON Content


-- | Which role is responsible for this content
--
--   Valid values are 'system', 'user', 'assistant' or 'tool'
newtype Role = Role TL.Text
  deriving (Generic, Show)

instance FromJSON Role
instance ToJSON Role


-- | Base64 encoded image data for working with models
newtype ImageData = ImageData TL.Text
  deriving (Generic, Show)

instance FromJSON ImageData
instance ToJSON ImageData


-- | Requests and responses to and from LLMs contain one or more @Message@s
--
--   These data structures always contain a @Role@ and some @Content@ and may
--   also optionally have an array of image data.
--
--   NOTE: The images as base64 encoded @Text@ are specific to Ollama at this
--   time and work is ongoing to support OpenAI's APIs
data Message = Message
  { role :: Role
  , content :: Content
  , images :: Maybe (NonEmpty ImageData)
  }
  deriving (Generic, Show)

instance FromJSON Message
instance ToJSON Message


-- | Convenience function to create a simple @Message@ with the role of 'user',
--   some @Content@ (prompt text) and no image data.
mkMessage :: TL.Text -> Message
mkMessage = mkMessageWithRole $ Role "user"


-- | Convenience function to create a simple @Message@ with the specified role,
--   some @Content@ (prompt text) and no image data.
mkMessageWithRole :: Role -> TL.Text -> Message
mkMessageWithRole role' content = Message role' (Content content) Nothing


-- | Whether the back-end should use streaming to send response data in "chunks"
--
--   NOTE: This is mostly unused at this time and the behavior is untested
newtype Stream = Stream Bool
  deriving (Generic, Show)

instance ToJSON Stream


class Request a where
  -- | Append a @Message@ to an existing request
  appendMessage :: Message -> a -> a

  -- | Construct a new @Request@
  --   Note: This request has no prompt data yet so at a minimum it must be used like this
  --
  --   > mkRequest "somefabulousmodel" $ usermsg "some fabulous prompt"
  mkRequest :: TL.Text -> RequestMod a -> a

  -- | Set an arbitrary LLM option given a name and JSON @Value@
  --   This is used internally by functions like boolopt, stringopt, ...
  setOpt :: TS.Text -> Value -> a -> a


newtype RequestMod r = RequestMod (r -> r)

instance Request r => Semigroup (RequestMod r) where
  RequestMod f1 <> RequestMod f2 = RequestMod (f2 . f1)


-- | Append a system prompt
systemmsg :: Request r => TL.Text -> RequestMod r
systemmsg systemPrompt = RequestMod . appendMessage
  $ mkMessageWithRole (Role "system") systemPrompt


-- | Append a user message (prompt)
usermsg :: Request r => TL.Text -> RequestMod r
usermsg prompt = RequestMod . appendMessage $ mkMessage prompt


-- | Apply a request modifier to a Maybe value
--
--   > stringopt `optional` (Just "foo")
optional :: (a -> RequestMod r) -> Maybe a -> RequestMod r
optional = maybe $ RequestMod id


-- | Set an option with a @Bool@ value
boolopt :: Request r => TS.Text -> Bool -> RequestMod r
boolopt keystr = RequestMod . setOpt keystr . A.Bool


-- | Set an option with a @Double@ value
numopt :: Request r => TS.Text -> Double -> RequestMod r
numopt keystr = RequestMod . setOpt keystr . Number . fromFloatDigits


-- | Set an option with a @Text@ value
stringopt :: Request r => TS.Text -> TS.Text -> RequestMod r
stringopt keystr = RequestMod . setOpt keystr . String