langchain-hs-0.0.5.0: src/Langchain/Provider/Ollama.hs
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{- |
Module : Langchain.Provider.Ollama
Description : Ollama provider implementing the effect-polymorphic ChatModel typeclass
Copyright : (c) 2025-2026 Tushar Adhatrao
License : MIT
Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>
Stability : experimental
Ollama provider using 'ollama-haskell' 0.4.0.0. Supports native structured outputs
via JSON Schema grammar sampling, streaming, tool calling, and embeddings.
== Request & Precedence Semantics
Because Ollama's 'ModelConfig' is 'OllamaChat.ChatRequest', which contains both
'chatModel' and 'chatMessages', callers may supply messages and model names either
via the 'ChatModel' interface ('invoke' / 'stream' arguments) or within the 'ChatRequest'.
The provider resolves these with well-defined precedence rules:
1. __Messages Precedence__:
* When @inputMsgs@ is non-empty (@not (null inputMsgs)@), it takes precedence over
'ChatRequest.chatMessages'. This enables reusing request templates across multiple calls
and ensures 'batch' processes each item's messages properly.
* When @inputMsgs@ is empty (@null inputMsgs@), the provider falls back to
'ChatRequest.chatMessages' if a 'ChatRequest' is provided.
* If both are empty, defaults to an empty user message.
2. __Model Name Precedence__:
* When a 'ChatRequest' is provided with a non-empty 'chatModel', it overrides
the provider's default 'ollamaModelName'.
* Otherwise, the provider's 'ollamaModelName' is used as the default.
-}
module Langchain.Provider.Ollama
( Ollama (..)
, newOllama
, newOllamaWithClient
, toOllamaRole
, fromOllamaRole
, toOllamaMessage
, fromOllamaMessage
, withJsonFormat
, withSchemaFormat
, withStructuredOutput
, withOptions
, chatRequestFor
, resolveChatRequest
, withTools
, toOllamaTool
, toOllamaTools
, OllamaWithTools (..)
, bindTools
-- * Re-exports from ollama-haskell format, schema, options & client config
, module Ollama.API.Chat
, module Ollama.Client.Config
, module Ollama.Types.Common
, module Ollama.Types.Options
, OFormat.Format (..)
, OSB.Schema (..)
, OSB.Property (..)
, OSB.JsonType (..)
, OSD.ToSchema (..)
, OSD.ToJsonType (..)
) where
import Control.Monad (when)
import Control.Monad.Except (throwError)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.Aeson (Result (..), decode, fromJSON, toJSON)
import qualified Data.Aeson as Aeson
import qualified Data.ByteString.Lazy.Char8 as LBSC
import Data.Conduit (await, transPipe, yield, (.|))
import qualified Data.List.NonEmpty as NonEmpty
import Data.Maybe (fromMaybe, isJust, mapMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Langchain.Core.Error (llmError)
import Langchain.Core.Model
import Langchain.Core.Stream (StreamEvent (..), TokenUsage (..))
import Langchain.Core.Tool (Tool, toolToValue)
import Langchain.Tool.Binding (ToolBinder (..))
import Ollama.API.Chat
import qualified Ollama.API.Chat as OllamaChat
import Ollama.Client (OllamaClient, newClient)
import Ollama.Client.Config
import Ollama.Types.Common (Base64Image (..), ModelName (..))
import qualified Ollama.Types.Format as OFormat
import qualified Ollama.Types.Format.SchemaBuilder as OSB
import qualified Ollama.Types.Format.SchemaDerive as OSD
import qualified Ollama.Types.Message as O
import Ollama.Types.Options (ModelOptions (..), defaultOptions)
import qualified Ollama.Types.Tool as OTool
-- | Ollama provider data type wrapping OllamaClient and model name
data Ollama = Ollama
{ client :: OllamaClient
, ollamaModelName :: Text
}
instance Show Ollama where
show (Ollama _ m) = "Ollama provider (" ++ show m ++ ")"
-- | Create a new Ollama provider with model name and client config
newOllama :: MonadIO m => Text -> OllamaClientConfig -> m Ollama
newOllama model cfg = do
c <- liftIO $ newClient cfg
pure $ Ollama c model
-- | Create an Ollama provider using an existing OllamaClient handle
newOllamaWithClient :: Text -> OllamaClient -> Ollama
newOllamaWithClient model c = Ollama c model
-- | Helper to convert core Role to Ollama Role
toOllamaRole :: Role -> O.Role
toOllamaRole System = O.System
toOllamaRole User = O.User
toOllamaRole Assistant = O.Assistant
toOllamaRole Tool = O.Tool
toOllamaRole Developer = O.System
toOllamaRole Function = O.Tool
-- | Helper to convert Ollama Role to core Role
fromOllamaRole :: O.Role -> Role
fromOllamaRole O.System = System
fromOllamaRole O.User = User
fromOllamaRole O.Assistant = Assistant
fromOllamaRole O.Tool = Tool
-- | Convert core Message to Ollama Message
toOllamaMessage :: Message -> O.Message
toOllamaMessage msg =
let r = toOllamaRole (messageRole msg)
txt = extractMessageText msg
imgs = case [ b64
| ImageBlock ImageContent {imageSource = ImageBase64 _ b64} <- NonEmpty.toList (messageContents msg)
] of
[] -> Nothing
xs -> Just (map Base64Image xs)
tools = case messageToolCalls msg of
Nothing -> Nothing
Just tcs ->
Just
[ OTool.ToolCall
{ OTool.tcFunction =
OTool.ToolCallFunction
{ OTool.tcfName = toolCallName tc
, OTool.tcfArguments = parseArgs (toolCallArguments tc)
}
}
| tc <- tcs
]
parseArgs v = case v of
Aeson.Object _ -> case fromJSON v of
Success m -> m
_ -> mempty
Aeson.String s -> fromMaybe mempty $ decode (LBSC.fromStrict (TE.encodeUtf8 s))
_ -> case fromJSON v of
Success m -> m
_ -> mempty
in O.Message r txt imgs tools (messageName msg) Nothing
-- | Convert Ollama Message to core Message
fromOllamaMessage :: O.Message -> Message
fromOllamaMessage (O.Message r txt _imgs tools name _think) =
let cRole = fromOllamaRole r
cMsg = (textMessage cRole txt) {messageName = name}
cTools = case tools of
Nothing -> Nothing
Just tcs ->
Just
[ ToolCall
{ toolCallId = ""
, toolCallType = "function"
, toolCallName = OTool.tcfName (OTool.tcFunction tc)
, toolCallArguments = toJSON (OTool.tcfArguments (OTool.tcFunction tc))
}
| tc <- tcs
]
in cMsg {messageToolCalls = cTools}
{- | Construct a 'ChatRequest' for an 'Ollama' instance with the given messages.
Sets 'chatModel' to the provider's 'ollamaModelName'. When passed to 'invoke'
or 'stream', any non-empty message argument passed directly to 'invoke' or
'stream' will take priority over the messages in this 'ChatRequest'.
-}
chatRequestFor :: Ollama -> [Message] -> OllamaChat.ChatRequest
chatRequestFor model inputMsgs =
let oMsgs = case inputMsgs of
[] -> O.userMessage "" NonEmpty.:| []
(m : ms) -> NonEmpty.map toOllamaMessage (m NonEmpty.:| ms)
in OllamaChat.chatRequest (ModelName (ollamaModelName model)) oMsgs
{- | Resolve the effective 'ChatRequest', effective model name, and effective messages
given the provider instance, explicit message arguments, and optional 'ChatRequest'.
= Precedence Rules
* __Messages Precedence__:
1. If @inputMsgs@ is non-empty (@not (null inputMsgs)@), it takes priority and
is used as the request conversation. This enables reusing a configured
'ChatRequest' (tools, formats, options) across invocations and guarantees
that 'batch' processes each item's messages properly.
2. If @inputMsgs@ is empty (@null inputMsgs@) and a 'ChatRequest' is provided,
its 'chatMessages' field is preserved and used.
3. If both are empty (or @inputMsgs@ is empty and 'mbReq' is 'Nothing'),
it defaults to a single empty user message.
* __Model Name Precedence__:
1. If a 'ChatRequest' is provided and its 'chatModel' is non-empty,
it overrides the provider's default 'ollamaModelName'.
2. Otherwise, the provider's 'ollamaModelName' is used as the default.
-}
resolveChatRequest ::
Ollama ->
[Message] ->
Maybe OllamaChat.ChatRequest ->
(OllamaChat.ChatRequest, Text, [Message])
resolveChatRequest model inputMsgs mbReq =
let providerModel = ollamaModelName model
resolvedModelText = case mbReq of
Just r ->
let m = unModelName (OllamaChat.chatModel r)
in if T.null m then providerModel else m
Nothing -> providerModel
resolvedModelName = ModelName resolvedModelText
(resolvedOMsgs, resolvedCoreMsgs) = case inputMsgs of
(m : ms) ->
let oList = NonEmpty.map toOllamaMessage (m NonEmpty.:| ms)
in (oList, inputMsgs)
[] -> case mbReq of
Just r ->
let oList = OllamaChat.chatMessages r
coreList = map fromOllamaMessage (NonEmpty.toList oList)
in (oList, coreList)
Nothing ->
(O.userMessage "" NonEmpty.:| [], [])
resolvedReq = case mbReq of
Nothing ->
OllamaChat.chatRequest resolvedModelName resolvedOMsgs
Just r ->
r
{ OllamaChat.chatModel = resolvedModelName
, OllamaChat.chatMessages = resolvedOMsgs
}
in (resolvedReq, resolvedModelText, resolvedCoreMsgs)
instance ChatModel Ollama where
type ModelConfig Ollama = OllamaChat.ChatRequest
invoke model inputMsgs mbReq = do
let (req, _modelName, _msgs) = resolveChatRequest model inputMsgs mbReq
eRes <- liftIO $ OllamaChat.chat (client model) req
case eRes of
Left err -> throwError $ llmError (T.pack $ show err) Nothing Nothing
Right resp -> case OllamaChat.crMessage resp of
Nothing -> throwError $ llmError "No message in response" Nothing Nothing
Just oMsg -> pure $ fromOllamaMessage oMsg
stream model inputMsgs mbReq = do
let runId_ = "ollama-run"
(req, resolvedModel, resolvedMsgs) = resolveChatRequest model inputMsgs mbReq
yield $ LLMStart runId_ resolvedModel resolvedMsgs
transPipe liftIO (OllamaChat.chatStream (client model) req) .| processChunks runId_
where
processChunks rId = loop [] Nothing Nothing
where
loop accChunks mbLastUsage mbLastTools =
await >>= \case
Nothing -> do
let fullText = T.concat (reverse accChunks)
finalMsg =
(assistantMessage fullText)
{ messageToolCalls = mbLastTools
}
yield $ LLMEnd rId finalMsg mbLastUsage
Just resp -> do
let mbMsg = OllamaChat.crMessage resp
chunkTxt = maybe "" O.messageContent mbMsg
mbTools = mbMsg >>= O.messageToolCalls
toolCalls = case mbTools of
Nothing -> Nothing
Just tcs ->
Just
[ ToolCall
{ toolCallId = ""
, toolCallType = "function"
, toolCallName = OTool.tcfName (OTool.tcFunction tc)
, toolCallArguments = toJSON (OTool.tcfArguments (OTool.tcFunction tc))
}
| tc <- tcs
]
toolDelta = case toolCalls of
Just (tc : _) -> Just tc
_ -> Nothing
newAccChunks = if T.null chunkTxt then accChunks else chunkTxt : accChunks
newTools = case toolCalls of
Just tcs -> Just $ maybe tcs (++ tcs) mbLastTools
Nothing -> mbLastTools
newUsage = case (OllamaChat.crPromptEvalCount resp, OllamaChat.crEvalCount resp) of
(Just p, Just c) -> Just $ TokenUsage p c (p + c)
_ -> mbLastUsage
when (not (T.null chunkTxt) || isJust toolDelta) $
yield $
LLMChunk rId chunkTxt toolDelta
loop newAccChunks newUsage newTools
-- | Set model options on an Ollama ChatRequest
withOptions :: ModelOptions -> OllamaChat.ChatRequest -> OllamaChat.ChatRequest
withOptions opts req = req {OllamaChat.chatOptions = Just opts}
-- | Attach Langchain tools to an Ollama ChatRequest
withTools :: [Tool m] -> OllamaChat.ChatRequest -> OllamaChat.ChatRequest
withTools ts req = req {OllamaChat.chatTools = Just (toOllamaTools ts)}
-- | Convert a Langchain 'Tool' definition to an Ollama 'OTool.Tool'
toOllamaTool :: Tool m -> Maybe OTool.Tool
toOllamaTool t = case fromJSON (toolToValue t) of
Success ot -> Just ot
Aeson.Error _ -> Nothing
-- | Convert a list of Langchain 'Tool' definitions to Ollama 'OTool.Tool's
toOllamaTools :: [Tool m] -> [OTool.Tool]
toOllamaTools = mapMaybe toOllamaTool
-- | Attach generic JSON format constraint to Ollama ChatRequest
withJsonFormat :: OllamaChat.ChatRequest -> OllamaChat.ChatRequest
withJsonFormat req = req {OllamaChat.chatFormat = Just OFormat.JsonFormat}
-- | Attach specific Schema format constraint to Ollama ChatRequest
withSchemaFormat :: OSB.Schema -> OllamaChat.ChatRequest -> OllamaChat.ChatRequest
withSchemaFormat schema req = req {OllamaChat.chatFormat = Just (OFormat.SchemaFormat schema)}
-- | Attach automatic ToSchema derived format constraint to Ollama ChatRequest
withStructuredOutput ::
forall a.
(OSD.ToSchema a) =>
OllamaChat.ChatRequest ->
OllamaChat.ChatRequest
withStructuredOutput req =
req {OllamaChat.chatFormat = Just (OFormat.SchemaFormat (OSD.toSchema @a))}
-- | Ollama model with pre-bound tools
data OllamaWithTools m = OllamaWithTools
{ ollamaBaseModel :: !Ollama
, ollamaBoundTools :: ![Tool m]
}
instance Show (OllamaWithTools m) where
show (OllamaWithTools m _) = "OllamaWithTools (" ++ show m ++ ")"
-- | Bind tools to an Ollama model so any invocation automatically includes tool definitions
bindTools :: [Tool m] -> Ollama -> OllamaWithTools m
bindTools ts model = OllamaWithTools model ts
instance ChatModel (OllamaWithTools m) where
type ModelConfig (OllamaWithTools m) = OllamaChat.ChatRequest
invoke (OllamaWithTools model ts) msgs mbReq =
let req = fromMaybe (chatRequestFor model msgs) mbReq
in invoke model msgs (Just (withTools ts req))
stream (OllamaWithTools model ts) msgs mbReq =
let req = fromMaybe (chatRequestFor model msgs) mbReq
in stream model msgs (Just (withTools ts req))
-- | Bind tools to an Ollama model by creating/merging a ChatRequest with tool definitions
instance ToolBinder Ollama m where
bindToolsConfig tools mbReq =
case tools of
[] -> mbReq
_ ->
let baseReq = fromMaybe (OllamaChat.chatRequest (ModelName "") (O.userMessage "" NonEmpty.:| [])) mbReq
in Just $ withTools tools baseReq
-- | OllamaWithTools already has tools bound, but merges additional tools if provided
instance ToolBinder (OllamaWithTools m) n where
bindToolsConfig tools mbReq =
case tools of
[] -> mbReq
_ ->
let baseReq = fromMaybe (OllamaChat.chatRequest (ModelName "") (O.userMessage "" NonEmpty.:| [])) mbReq
in Just $ withTools tools baseReq