langchain-hs-0.0.3.0: src/Langchain/LLM/OpenAICompatible.hs
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TypeFamilies #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
{- |
Module : Langchain.LLM.OpenAICompatible
Description : Generic OpenAI-compatible API integration for LangChain Haskell
Copyright : (c) 2025 Tushar Adhatrao
License : MIT
Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>
Stability : experimental
This module provides a generic 'OpenAICompatible' data type and
implements the 'LLM' typeclass for interacting with any service that provides
an OpenAI-compatible API interface.
-}
module Langchain.LLM.OpenAICompatible
( OpenAICompatible (..)
, mkOpenRouter
, module Langchain.LLM.Core
) where
import Control.Exception (SomeException, try)
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString.Lazy.Char8 as BSL
import qualified Data.List.NonEmpty as NE
import Data.Maybe (fromMaybe, listToMaybe)
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Data.Vector as V
import Langchain.Callback
import qualified Langchain.Error as Error
import Langchain.LLM.Core
import qualified Langchain.LLM.Core as LLM
import qualified Langchain.Runnable.Core as LLM
import OpenAI.V1
import OpenAI.V1.Chat.Completions
import qualified OpenAI.V1.Chat.Completions as OpenAIV1
import qualified OpenAI.V1.ToolCall as OpenAIV1
-- | Generic OpenAICompatible implementation for any service with an OpenAI-compatible API
data OpenAICompatible = OpenAICompatible
{ apiKey :: T.Text
-- ^ The API key for authenticating.
, callbacks :: [Callback]
-- ^ A list of callbacks for handling events during LLM operations
, baseUrl :: Maybe String
-- ^ Base URL for the service. Default "https://api.openai.com"
, providerName :: T.Text
-- ^ The provider or service name
}
instance Show OpenAICompatible where
show OpenAICompatible {..} = show providerName
-- | Helper function to extract text from OpenAI Message T.Text
messageToText :: OpenAIV1.Message T.Text -> T.Text
messageToText (OpenAIV1.User {OpenAIV1.content = c}) = c
messageToText (OpenAIV1.System {OpenAIV1.content = c}) = c
messageToText (OpenAIV1.Assistant {OpenAIV1.assistant_content = ac}) = fromMaybe "" ac
messageToText (OpenAIV1.Tool {OpenAIV1.content = c}) = c
-- | Helper function to extract text from Vector Content
extractTextFromContent :: V.Vector OpenAIV1.Content -> T.Text
extractTextFromContent contents =
fromMaybe "" $ listToMaybe $ V.toList $ V.mapMaybe getTextContent contents
where
getTextContent :: OpenAIV1.Content -> Maybe T.Text
getTextContent (OpenAIV1.Text txt) = Just txt
getTextContent _ = Nothing
-- | Helper function to create content list with text
makeContentList :: T.Text -> Maybe [T.Text] -> V.Vector OpenAIV1.Content
makeContentList text mbImageData = do
let res = V.fromList [OpenAIV1.Text text]
res <> case mbImageData of
Just images ->
V.fromList
( map
( OpenAIV1.Image_URL
. (\urlText -> OpenAIV1.ImageURL {url = urlText, detail = Nothing})
)
images
)
Nothing -> V.empty
toOpenAIToolCall :: [ToolCall] -> V.Vector OpenAIV1.ToolCall
toOpenAIToolCall = V.fromList . map go
where
go :: ToolCall -> OpenAIV1.ToolCall
go = \case
ToolCall {toolCallId, toolCallFunction = ToolFunction {toolFunctionName, toolFunctionArguments}} ->
OpenAIV1.ToolCall_Function
{ OpenAIV1.id = toolCallId
, OpenAIV1.function =
OpenAIV1.Function
{ OpenAIV1.name = toolFunctionName
, OpenAIV1.arguments = T.decodeUtf8 $ BSL.toStrict $ Aeson.encode toolFunctionArguments
}
}
fromOpenAIToolCall :: OpenAIV1.ToolCall -> ToolCall
fromOpenAIToolCall = \case
OpenAIV1.ToolCall_Function
{ OpenAIV1.id = tcId
, OpenAIV1.function =
OpenAIV1.Function
{ OpenAIV1.name = fnName
, OpenAIV1.arguments = fnArgs
}
} ->
let argsVal = Aeson.decode (BSL.fromStrict $ T.encodeUtf8 fnArgs) :: Maybe Aeson.Value
argsMap = case argsVal of
Just (Aeson.Object o) -> KM.toMapText o
_ -> mempty
in ToolCall
{ toolCallId = tcId
, toolCallType = "function"
, toolCallFunction =
ToolFunction
{ toolFunctionName = fnName
, toolFunctionArguments = argsMap
}
}
getToolId :: [ToolCall] -> T.Text
getToolId toolCalls = case toolCalls of
(ToolCall {toolCallId} : _) -> toolCallId
[] -> ""
getImageDataIfExists :: V.Vector OpenAIV1.Content -> Maybe [T.Text]
getImageDataIfExists contents =
let images = V.toList $ V.mapMaybe getImageContent contents
in if null images then Nothing else Just images
where
getImageContent :: OpenAIV1.Content -> Maybe T.Text
getImageContent (OpenAIV1.Image_URL imgUrl) = Just $ url imgUrl
getImageContent _ = Nothing
{- | MessageConvertible instance for OpenAIV1.Message (V.Vector OpenAIV1.Content)
This is used for request messages
-}
instance LLM.MessageConvertible (OpenAIV1.Message (V.Vector OpenAIV1.Content)) where
-- \| Convert LLM.Message to OpenAIV1.Message (V.Vector OpenAIV1.Content)
to :: LLM.Message -> OpenAIV1.Message (V.Vector OpenAIV1.Content)
to msg =
let imagesData = LLM.messageImages $ LLM.messageData msg
contentVec = makeContentList (LLM.content msg) imagesData
msgName = LLM.name $ LLM.messageData msg
in case LLM.role msg of
LLM.User ->
OpenAIV1.User
{ OpenAIV1.content = contentVec
, OpenAIV1.name = msgName
}
LLM.System ->
OpenAIV1.System
{ OpenAIV1.content = contentVec
, OpenAIV1.name = msgName
}
LLM.Assistant ->
OpenAIV1.Assistant
{ OpenAIV1.assistant_content = Just contentVec
, OpenAIV1.name = msgName
, OpenAIV1.refusal = Nothing
, OpenAIV1.assistant_audio = Nothing
, OpenAIV1.tool_calls = fmap toOpenAIToolCall <$> toolCalls $ messageData msg
}
LLM.Tool ->
OpenAIV1.Tool
{ OpenAIV1.content = contentVec
, OpenAIV1.tool_call_id = fromMaybe "" $ fmap getToolId <$> toolCalls $ messageData msg
}
-- Fallback to User for unsupported roles (Developer, Function)
_ ->
OpenAIV1.User
{ OpenAIV1.content = contentVec
, OpenAIV1.name = msgName
}
-- \| Convert OpenAIV1.Message (V.Vector OpenAIV1.Content) to LLM.Message
from :: OpenAIV1.Message (V.Vector OpenAIV1.Content) -> LLM.Message
from msg = case msg of
OpenAIV1.User {OpenAIV1.content = c, OpenAIV1.name = n} ->
LLM.Message
{ LLM.role = LLM.User
, LLM.content = extractTextFromContent c
, LLM.messageData =
LLM.MessageData
{ LLM.name = n
, LLM.toolCalls = Nothing
, LLM.messageImages = getImageDataIfExists c
, LLM.thinking = Nothing
}
}
OpenAIV1.System {OpenAIV1.content = c, OpenAIV1.name = n} ->
LLM.Message
{ LLM.role = LLM.System
, LLM.content = extractTextFromContent c
, LLM.messageData =
LLM.MessageData
{ LLM.name = n
, LLM.toolCalls = Nothing
, LLM.messageImages = getImageDataIfExists c
, LLM.thinking = Nothing
}
}
OpenAIV1.Assistant
{ OpenAIV1.assistant_content = ac
, OpenAIV1.name = n
, OpenAIV1.tool_calls = mbToolVector
} ->
LLM.Message
{ LLM.role = LLM.Assistant
, LLM.content = maybe "" extractTextFromContent ac
, LLM.messageData =
LLM.MessageData
{ LLM.name = n
, LLM.toolCalls = fmap (V.toList . V.map fromOpenAIToolCall) mbToolVector
, LLM.messageImages = getImageDataIfExists =<< ac
, LLM.thinking = Nothing
}
}
OpenAIV1.Tool {OpenAIV1.content = c, OpenAIV1.tool_call_id = toolCallid} ->
LLM.Message
{ LLM.role = LLM.Tool
, LLM.content = extractTextFromContent c
, LLM.messageData =
LLM.MessageData
{ LLM.name = Nothing
, LLM.toolCalls =
Just
[ ToolCall
{ toolCallId = toolCallid
, toolCallType = "function"
, toolCallFunction =
ToolFunction
{ toolFunctionName = ""
, toolFunctionArguments = mempty
}
}
]
, LLM.messageImages = getImageDataIfExists c
, LLM.thinking = Nothing
}
}
instance LLM.MessageConvertible (OpenAIV1.Message T.Text) where
to :: LLM.Message -> OpenAIV1.Message T.Text
to _ = error "Conversion to OpenAIV1.Message T.Text not implemented."
-- \| Convert OpenAIV1.Message T.Text to LLM.Message
from :: OpenAIV1.Message T.Text -> LLM.Message
from msg = case msg of
OpenAIV1.User {OpenAIV1.content = c, OpenAIV1.name = n} ->
LLM.Message
{ LLM.role = LLM.User
, LLM.content = c
, LLM.messageData =
LLM.MessageData
{ LLM.name = n
, LLM.toolCalls = Nothing
, LLM.messageImages = Nothing
, LLM.thinking = Nothing
}
}
OpenAIV1.System {OpenAIV1.content = c, OpenAIV1.name = n} ->
LLM.Message
{ LLM.role = LLM.System
, LLM.content = c
, LLM.messageData =
LLM.MessageData
{ LLM.name = n
, LLM.toolCalls = Nothing
, LLM.messageImages = Nothing
, LLM.thinking = Nothing
}
}
OpenAIV1.Assistant
{ OpenAIV1.assistant_content = ac
, OpenAIV1.name = n
, OpenAIV1.tool_calls = mbToolVector
} ->
LLM.Message
{ LLM.role = LLM.Assistant
, LLM.content = fromMaybe "" ac
, LLM.messageData =
LLM.MessageData
{ LLM.name = n
, LLM.toolCalls = fmap (V.toList . V.map fromOpenAIToolCall) mbToolVector
, LLM.messageImages = Nothing
, LLM.thinking = Nothing
}
}
OpenAIV1.Tool {OpenAIV1.content = c, OpenAIV1.tool_call_id = toolCallid} ->
LLM.Message
{ LLM.role = LLM.Tool
, LLM.content = c
, LLM.messageData =
LLM.MessageData
{ LLM.name = Nothing
, LLM.toolCalls =
Just
[ ToolCall
{ toolCallId = toolCallid
, toolCallType = "function"
, toolCallFunction =
ToolFunction
{ toolFunctionName = ""
, toolFunctionArguments = mempty
}
}
]
, LLM.messageImages = Nothing
, LLM.thinking = Nothing
}
}
-- | Helper function to convert LLM.Message to OpenAI Message (using MessageConvertible)
toOpenAIMsg :: LLM.Message -> OpenAIV1.Message (V.Vector OpenAIV1.Content)
toOpenAIMsg = LLM.to
-- | Helper function to convert OpenAI Message to LLM.Message (using MessageConvertible)
fromOpenAIMsg :: OpenAIV1.Message T.Text -> LLM.Message
fromOpenAIMsg = LLM.from
instance LLM.LLM OpenAICompatible where
type LLMParams OpenAICompatible = OpenAIV1.CreateChatCompletion
type LLMStreamTokenType OpenAICompatible = OpenAIV1.ChatCompletionChunk
generate OpenAICompatible {..} prompt mbLLMParams = do
clientEnv <- getClientEnv $ maybe "https://api.openai.com" T.pack baseUrl
let Methods {createChatCompletion} = makeMethods clientEnv apiKey Nothing Nothing
let openaiParams = fromMaybe _CreateChatCompletion mbLLMParams
eRes <-
try $
createChatCompletion
openaiParams
{ OpenAIV1.messages =
V.fromList
[ OpenAIV1.User
{ OpenAIV1.content = V.fromList [OpenAIV1.Text prompt]
, name = Nothing
}
]
}
case eRes of
Left err -> pure $ Left $ Error.fromString $ show (err :: SomeException)
Right (ChatCompletionObject {choices}) -> do
let Choice {message} = V.head choices
pure (Right $ messageToText message)
chat OpenAICompatible {..} chatHistory mbLLMParams = do
clientEnv <- getClientEnv $ maybe "https://api.openai.com" T.pack baseUrl
let Methods {createChatCompletion} = makeMethods clientEnv apiKey Nothing Nothing
let openaiParams = fromMaybe _CreateChatCompletion mbLLMParams
eRes <-
try $
createChatCompletion
openaiParams {OpenAIV1.messages = V.fromList $ map toOpenAIMsg (NE.toList chatHistory)}
case eRes of
Left err -> pure $ Left $ Error.fromString $ show (err :: SomeException)
Right (ChatCompletionObject {choices}) -> do
let Choice {message} = V.head choices
pure (Right $ fromOpenAIMsg message)
stream OpenAICompatible {..} chatHistory streamHandler mbLLMParams = do
let onEvent (Left _) = pure () -- ignore for now
onEvent (Right chunk) = onToken streamHandler chunk
clientEnv <- getClientEnv $ maybe "https://api.openai.com" T.pack baseUrl
let Methods {createChatCompletionStreamTyped} = makeMethods clientEnv apiKey Nothing Nothing
let openaiParams = fromMaybe _CreateChatCompletion mbLLMParams
let req_ = openaiParams {OpenAIV1.messages = V.fromList $ map toOpenAIMsg (NE.toList chatHistory)}
_ <- createChatCompletionStreamTyped req_ onEvent
pure $ Right ()
{- | Create an OpenRouter instance
OpenRouter provides access to multiple model providers through a single API
Model name should be in the format "provider/model" (e.g., "anthropic/claude-3-opus")
-}
mkOpenRouter :: [Callback] -> Maybe String -> T.Text -> OpenAICompatible
mkOpenRouter callbacks' baseUrl' apiKey' =
OpenAICompatible
{ apiKey = apiKey' -- OpenRouter requires an API key
, callbacks = callbacks'
, baseUrl = Just $ fromMaybe "https://openrouter.ai/api" baseUrl'
, providerName = "OpenRouter"
}
instance LLM.Runnable OpenAICompatible where
type RunnableInput OpenAICompatible = (ChatHistory, Maybe OpenAIV1.CreateChatCompletion)
type RunnableOutput OpenAICompatible = LLM.Message
invoke = uncurry . chat