packages feed

langchain-hs-0.0.2.0: src/Langchain/LLM/OpenAI.hs

{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TypeFamilies #-}

{- |
Module      : Langchain.LLM.OpenAI
Description : OpenAI integration for LangChain Haskell
Copyright   : (c) 2025 Tushar Adhatrao
License     : MIT
Maintainer  : Tushar Adhatrao <tusharadhatrao@gmail.com>
Stability   : experimental

This module provides the 'OpenAI' data type and implements the 'LLM' typeclass for interacting with OpenAI's language models.
It supports generating text, handling chat interactions, and streaming responses using OpenAI's API.

The 'OpenAI' type encapsulates the API key, model name, and callbacks for event handling.
The 'LLM' instance methods ('generate', 'chat', 'stream') allow for seamless integration with LangChain's processing pipelines.

For more information on OpenAI's API, see: <https://platform.openai.com/docs/api-reference>

@
import Data.Text (Text)
import qualified Langchain.LLM.Core as LLM
import Langchain.LLM.OpenAI (OpenAI(..))

main :: IO()
main = do
  let openAI = OpenAI
        { apiKey = "your-api-key"
        , openAIModelName = "gpt-3.5-turbo"
        , callbacks = []
        }
  result <- LLM.generate openAI "Tell me a joke" Nothing
  case result of
    Left err -> putStrLn $ "Error: " ++ err
    Right response -> putStrLn response
@
-}
module Langchain.LLM.OpenAI
  ( 
    -- * Types
    OpenAI (..)
  , OpenAIParams (..)
    -- * Default functions
  , defaultOpenAIParams
  ) where

import qualified Data.List.NonEmpty as NE
import Data.Map (Map)
import Data.Maybe (fromMaybe, listToMaybe)
import Data.Text (Text)
import Langchain.Callback (Callback)
import qualified Langchain.LLM.Core as LLM
import qualified Langchain.LLM.Internal.OpenAI as OpenAI
import qualified Langchain.Runnable.Core as Run

{- | Configuration for OpenAI's language models.

This data type holds the necessary information to interact with OpenAI's API,
including the API key, the model name, and a list of callbacks for handling events.
-}
data OpenAI = OpenAI
  { apiKey :: Text
  -- ^ The API key for authenticating with OpenAI's services.
  , openAIModelName :: Text
  -- ^ The name of the OpenAI model to use (e.g., "gpt-3.5-turbo", "gpt-4").
  , callbacks :: [Callback]
  -- ^ A list of callbacks for handling events during LLM operations.
  }

-- | Not including API key to avoid accidental leak
instance Show OpenAI where
  show OpenAI {..} = "OpenAI " ++ show openAIModelName

{- | Implementation of the 'LLM' typeclass for OpenAI models.

This instance provides methods for generating text, handling chat interactions,
and streaming responses using OpenAI's API.
-}
instance LLM.LLM OpenAI where
  type LLMParams OpenAI = OpenAIParams

  generate OpenAI {..} prompt mbOpenAIParams = do
    eRes <-
      OpenAI.createChatCompletion
        apiKey
        ( OpenAI.defaultChatCompletionRequest
            { OpenAI.model = openAIModelName
            , OpenAI.messages =
                [OpenAI.defaultMessage {OpenAI.content = Just (OpenAI.StringContent prompt)}]
            , OpenAI.timeout = maybe Nothing timeout mbOpenAIParams
            , OpenAI.frequencyPenalty = maybe Nothing frequencyPenalty mbOpenAIParams
            , OpenAI.logitBias = maybe Nothing logitBias mbOpenAIParams
            , OpenAI.logprobs = maybe Nothing logprobs mbOpenAIParams
            , OpenAI.maxCompletionTokens = maybe Nothing maxCompletionTokens mbOpenAIParams
            , OpenAI.maxTokens = maybe Nothing maxTokens mbOpenAIParams
            , OpenAI.metadata = maybe Nothing metadata mbOpenAIParams
            , OpenAI.modalities = maybe Nothing modalities mbOpenAIParams
            , OpenAI.n = maybe Nothing n mbOpenAIParams
            , OpenAI.parallelToolCalls = maybe Nothing parallelToolCalls mbOpenAIParams
            , OpenAI.prediction = maybe Nothing prediction mbOpenAIParams
            , OpenAI.presencePenalty = maybe Nothing presencePenalty mbOpenAIParams
            , OpenAI.reasoningEffort = maybe Nothing reasoningEffort mbOpenAIParams
            , OpenAI.responseFormat = maybe Nothing responseFormat mbOpenAIParams
            , OpenAI.seed = maybe Nothing seed mbOpenAIParams
            , OpenAI.serviceTier = maybe Nothing serviceTier mbOpenAIParams
            , OpenAI.stop = maybe Nothing stop mbOpenAIParams
            , OpenAI.store = maybe Nothing store mbOpenAIParams
            , OpenAI.temperature = maybe Nothing temperature mbOpenAIParams
            , OpenAI.toolChoice = maybe Nothing toolChoice mbOpenAIParams
            , OpenAI.tools = maybe Nothing tools mbOpenAIParams
            , OpenAI.topLogprobs = maybe Nothing topLogprobs mbOpenAIParams
            , OpenAI.topP = maybe Nothing topP mbOpenAIParams
            , OpenAI.user = maybe Nothing user mbOpenAIParams
            , OpenAI.webSearchOptions = maybe Nothing webSearchOptions mbOpenAIParams
            , OpenAI.audio = maybe Nothing audio mbOpenAIParams
            }
        )
    case eRes of
      Left err -> return $ Left err
      Right r -> do
        case listToMaybe ((\OpenAI.ChatCompletionResponse {..} -> choices) r) of
          Nothing -> return $ Left "Did not received any response"
          Just resp ->
            let OpenAI.Message {..} = OpenAI.message resp
             in pure $
                  Right $
                    maybe
                      ""
                      ( \c -> case c of
                          OpenAI.StringContent t -> t
                          OpenAI.ContentParts _ -> ""
                      )
                      content
  chat OpenAI {..} msgs mbOpenAIParams = do
    eRes <-
      OpenAI.createChatCompletion
        apiKey
        ( OpenAI.defaultChatCompletionRequest
            { OpenAI.model = openAIModelName
            , OpenAI.messages = toOpenAIMessages msgs
            , OpenAI.timeout = maybe Nothing timeout mbOpenAIParams
            , OpenAI.frequencyPenalty = maybe Nothing frequencyPenalty mbOpenAIParams
            , OpenAI.logitBias = maybe Nothing logitBias mbOpenAIParams
            , OpenAI.logprobs = maybe Nothing logprobs mbOpenAIParams
            , OpenAI.maxCompletionTokens = maybe Nothing maxCompletionTokens mbOpenAIParams
            , OpenAI.maxTokens = maybe Nothing maxTokens mbOpenAIParams
            , OpenAI.metadata = maybe Nothing metadata mbOpenAIParams
            , OpenAI.modalities = maybe Nothing modalities mbOpenAIParams
            , OpenAI.n = maybe Nothing n mbOpenAIParams
            , OpenAI.parallelToolCalls = maybe Nothing parallelToolCalls mbOpenAIParams
            , OpenAI.prediction = maybe Nothing prediction mbOpenAIParams
            , OpenAI.presencePenalty = maybe Nothing presencePenalty mbOpenAIParams
            , OpenAI.reasoningEffort = maybe Nothing reasoningEffort mbOpenAIParams
            , OpenAI.responseFormat = maybe Nothing responseFormat mbOpenAIParams
            , OpenAI.seed = maybe Nothing seed mbOpenAIParams
            , OpenAI.serviceTier = maybe Nothing serviceTier mbOpenAIParams
            , OpenAI.stop = maybe Nothing stop mbOpenAIParams
            , OpenAI.store = maybe Nothing store mbOpenAIParams
            , OpenAI.temperature = maybe Nothing temperature mbOpenAIParams
            , OpenAI.toolChoice = maybe Nothing toolChoice mbOpenAIParams
            , OpenAI.tools = maybe Nothing tools mbOpenAIParams
            , OpenAI.topLogprobs = maybe Nothing topLogprobs mbOpenAIParams
            , OpenAI.topP = maybe Nothing topP mbOpenAIParams
            , OpenAI.user = maybe Nothing user mbOpenAIParams
            , OpenAI.webSearchOptions = maybe Nothing webSearchOptions mbOpenAIParams
            , OpenAI.audio = maybe Nothing audio mbOpenAIParams
            }
        )
    case eRes of
      Left err -> return $ Left err
      Right r -> do
        case listToMaybe ((\OpenAI.ChatCompletionResponse {..} -> choices) r) of
          Nothing -> return $ Left "Did not received any response"
          Just resp ->
            let OpenAI.Message {..} = OpenAI.message resp
             in pure $
                  Right $
                    maybe
                      ""
                      ( \c -> case c of
                          OpenAI.StringContent t -> t
                          OpenAI.ContentParts _ -> ""
                      )
                      content

  stream OpenAI {..} msgs LLM.StreamHandler {onComplete, onToken} mbOpenAIParams = do
    let req =
          OpenAI.defaultChatCompletionRequest
            { OpenAI.model = openAIModelName
            , OpenAI.messages = toOpenAIMessages msgs
            , OpenAI.stream = Just True -- Enable streaming'
            , OpenAI.timeout = maybe Nothing timeout mbOpenAIParams
            , OpenAI.frequencyPenalty = maybe Nothing frequencyPenalty mbOpenAIParams
            , OpenAI.logitBias = maybe Nothing logitBias mbOpenAIParams
            , OpenAI.logprobs = maybe Nothing logprobs mbOpenAIParams
            , OpenAI.maxCompletionTokens = maybe Nothing maxCompletionTokens mbOpenAIParams
            , OpenAI.maxTokens = maybe Nothing maxTokens mbOpenAIParams
            , OpenAI.metadata = maybe Nothing metadata mbOpenAIParams
            , OpenAI.modalities = maybe Nothing modalities mbOpenAIParams
            , OpenAI.n = maybe Nothing n mbOpenAIParams
            , OpenAI.parallelToolCalls = maybe Nothing parallelToolCalls mbOpenAIParams
            , OpenAI.prediction = maybe Nothing prediction mbOpenAIParams
            , OpenAI.presencePenalty = maybe Nothing presencePenalty mbOpenAIParams
            , OpenAI.reasoningEffort = maybe Nothing reasoningEffort mbOpenAIParams
            , OpenAI.responseFormat = maybe Nothing responseFormat mbOpenAIParams
            , OpenAI.seed = maybe Nothing seed mbOpenAIParams
            , OpenAI.serviceTier = maybe Nothing serviceTier mbOpenAIParams
            , OpenAI.stop = maybe Nothing stop mbOpenAIParams
            , OpenAI.store = maybe Nothing store mbOpenAIParams
            , OpenAI.temperature = maybe Nothing temperature mbOpenAIParams
            , OpenAI.toolChoice = maybe Nothing toolChoice mbOpenAIParams
            , OpenAI.tools = maybe Nothing tools mbOpenAIParams
            , OpenAI.topLogprobs = maybe Nothing topLogprobs mbOpenAIParams
            , OpenAI.topP = maybe Nothing topP mbOpenAIParams
            , OpenAI.user = maybe Nothing user mbOpenAIParams
            , OpenAI.webSearchOptions = maybe Nothing webSearchOptions mbOpenAIParams
            , OpenAI.audio = maybe Nothing audio mbOpenAIParams
            }
    OpenAI.createChatCompletionStream
      apiKey
      req
      OpenAI.OpenAIStreamHandler
        { OpenAI.onComplete = onComplete
        , OpenAI.onToken = onToken . chunkToText
        }
    where
      chunkToText :: OpenAI.ChatCompletionChunk -> Text
      chunkToText OpenAI.ChatCompletionChunk {..} = do
        case listToMaybe chunkChoices of
          Nothing -> ""
          Just OpenAI.ChunkChoice {..} ->
            fromMaybe "" ((\OpenAI.Delta {..} -> contentForDelta) delta)

toOpenAIMessages :: LLM.ChatMessage -> [OpenAI.Message]
toOpenAIMessages msgs = map go (NE.toList msgs)
  where
    toRole :: LLM.Role -> OpenAI.Role
    toRole r = case r of
      LLM.System -> OpenAI.System
      LLM.User -> OpenAI.User
      LLM.Assistant -> OpenAI.Assistant
      LLM.Tool -> OpenAI.Tool
      LLM.Developer -> OpenAI.Developer
      LLM.Function -> OpenAI.Function

    go :: LLM.Message -> OpenAI.Message
    go msg =
      OpenAI.defaultMessage
        { OpenAI.role = toRole $ LLM.role msg
        , OpenAI.content = Just $ OpenAI.StringContent (LLM.content msg)
        }

instance Run.Runnable OpenAI where
  type RunnableInput OpenAI = (LLM.ChatMessage, Maybe OpenAIParams)
  type RunnableOutput OpenAI = Text

  invoke = uncurry . LLM.chat

-- | Parameters for customizing OpenAI API calls.
data OpenAIParams = OpenAIParams
  { timeout :: Maybe Int
  , frequencyPenalty :: Maybe Double
  , logitBias :: Maybe (Map Text Double)
  , logprobs :: Maybe Bool
  , maxCompletionTokens :: Maybe Int
  , maxTokens :: Maybe Int
  , metadata :: Maybe (Map Text Text)
  , modalities :: Maybe [OpenAI.Modality]
  , n :: Maybe Int
  , parallelToolCalls :: Maybe Bool
  , prediction :: Maybe OpenAI.PredictionOutput
  , presencePenalty :: Maybe Double
  , reasoningEffort :: Maybe OpenAI.ReasoningEffort
  , responseFormat :: Maybe OpenAI.ResponseFormat
  , seed :: Maybe Int
  , serviceTier :: Maybe Text
  , stop :: Maybe (Either Text [Text])
  , store :: Maybe Bool
  , temperature :: Maybe Double
  , toolChoice :: Maybe OpenAI.ToolChoice
  , tools :: Maybe [OpenAI.Tool_]
  , topLogprobs :: Maybe Int
  , topP :: Maybe Double
  , user :: Maybe Text
  , webSearchOptions :: Maybe OpenAI.WebSearchOptions
  , audio :: Maybe OpenAI.AudioConfig
  }

-- | Default parameters for OpenAI API calls.
defaultOpenAIParams :: OpenAIParams
defaultOpenAIParams =
  OpenAIParams
    { timeout = Just 60
    , frequencyPenalty = Nothing
    , logitBias = Nothing
    , logprobs = Nothing
    , maxCompletionTokens = Nothing
    , maxTokens = Nothing
    , metadata = Nothing
    , modalities = Nothing
    , n = Nothing
    , parallelToolCalls = Nothing
    , prediction = Nothing
    , presencePenalty = Nothing
    , reasoningEffort = Nothing
    , responseFormat = Nothing
    , seed = Nothing
    , serviceTier = Nothing
    , stop = Nothing
    , store = Nothing
    , temperature = Nothing
    , toolChoice = Nothing
    , tools = Nothing
    , topLogprobs = Nothing
    , topP = Nothing
    , user = Nothing
    , webSearchOptions = Nothing
    , audio = Nothing
    }

{-
ghci> :set -XOverloadedStrings
ghci> let o = OpenAI { apiKey = <my api key>
    , openAIModelName = "gpt-4.1-nano"
    , Langchain.LLM.OpenAI.callbacks = []
    }
ghci> eRes <- generate o "What is 2+2" Nothing
ghci> eRes
Right "2 + 2 equals 4."
ghci> import qualified Data.List.NonEmpty as NE
ghci> let msg = Langchain.LLM.Core.Message Langchain.LLM.Core.User "What is 2+2" defaultMessageData
ghci> let chatMsg = NE.fromList [msg]
ghci> eRes <- chat o chatMsg Nothing
ghci> eRes
Right "2 + 2 equals 4."
-}