packages feed

langchain-hs-ollama (empty) → 0.0.6.0

raw patch · 9 files changed

+787/−0 lines, 9 filesdep +aesondep +basedep +bytestring

Dependencies added: aeson, base, bytestring, conduit, containers, langchain-hs-core, langchain-hs-ollama, mtl, ollama-haskell, tasty, tasty-hunit, text

Files

+ CHANGELOG.md view
@@ -0,0 +1,6 @@+# Changelog for langchain-hs-ollama++## 0.0.6.0 - 2026-10-02++- Initial release. Split from the monolithic `langchain-hs` package.+- Provides `Langchain.Provider.Ollama` and `Langchain.Embeddings.Ollama`.
+ LICENSE view
@@ -0,0 +1,20 @@+Copyright (c) 2025 Tushar Adhatrao++Permission is hereby granted, free of charge, to any person obtaining+a copy of this software and associated documentation files (the+"Software"), to deal in the Software without restriction, including+without limitation the rights to use, copy, modify, merge, publish,+distribute, sublicense, and/or sell copies of the Software, and to+permit persons to whom the Software is furnished to do so, subject to+the following conditions:++The above copyright notice and this permission notice shall be included+in all copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ README.md view
@@ -0,0 +1,33 @@+# langchain-hs-ollama++Ollama ChatModel provider and embeddings for [langchain-hs](https://hackage.haskell.org/package/langchain-hs).++Provides `Langchain.Provider.Ollama` and `Langchain.Embeddings.Ollama` without+pulling in OpenAI or Gemini dependencies.++## Installation++```yaml+# stack.yaml extra-deps+- langchain-hs-ollama-0.0.6.0+```++```yaml+# package.yaml / .cabal+dependencies:+  - langchain-hs-ollama+```++## Usage++```haskell+import Langchain.Provider.Ollama+import Control.Monad.Except (runExceptT)++main :: IO ()+main = do+  model <- newOllama "llama3" defaultClientConfig+  let cfg = withOptions (defaultOptions {optTemperature = Just 0.7}) defaultOllamaConfig+  res   <- runExceptT $ invoke model [userMessage "Hello"] (Just cfg)+  print res+```
+ langchain-hs-ollama.cabal view
@@ -0,0 +1,75 @@+cabal-version: 1.12++-- This file has been generated from package.yaml by hpack version 0.39.6.+--+-- see: https://github.com/sol/hpack++name:           langchain-hs-ollama+version:        0.0.6.0+synopsis:       Ollama provider and embeddings for langchain-hs+description:    Ollama ChatModel provider and text embeddings integration for langchain-hs. Depends only on ollama-haskell; does not pull in OpenAI or Gemini dependencies.+category:       AI+homepage:       https://github.com/tusharad/langchain-hs#readme+bug-reports:    https://github.com/tusharad/langchain-hs/issues+author:         Tushar Adhatrao+maintainer:     tusharadhatrao@gmail.com+copyright:      2025-2026 Tushar Adhatrao+license:        MIT+license-file:   LICENSE+build-type:     Simple+tested-with:+    GHC == 9.12.4+  , GHC == 9.10.3+  , GHC == 9.8.4+extra-source-files:+    README.md+    CHANGELOG.md++source-repository head+  type: git+  location: https://github.com/tusharad/langchain-hs++library+  exposed-modules:+      Langchain.Provider.Ollama+      Langchain.Embeddings.Ollama+  other-modules:+      Paths_langchain_hs_ollama+  hs-source-dirs:+      src+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints+  build-depends:+      aeson >=2.0 && <3+    , base >=4.17 && <5+    , bytestring >=0.10 && <0.13+    , conduit ==1.3.*+    , langchain-hs-core ==0.0.6.*+    , mtl >=2.2 && <2.4+    , ollama-haskell >=0.4.0.0 && <0.5+    , text >=1.2 && <3+  default-language: Haskell2010++test-suite langchain-hs-ollama-test+  type: exitcode-stdio-1.0+  main-is: Spec.hs+  other-modules:+      Test.Langchain.Provider.Ollama+      Test.Langchain.Provider.OllamaConversionSpec+      Paths_langchain_hs_ollama+  hs-source-dirs:+      test+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts -with-rtsopts=-N+  build-depends:+      aeson+    , base >=4.17 && <5+    , bytestring+    , conduit ==1.3.*+    , containers+    , langchain-hs-core+    , langchain-hs-ollama+    , mtl >=2.2 && <2.4+    , ollama-haskell+    , tasty+    , tasty-hunit+    , text+  default-language: Haskell2010
+ src/Langchain/Embeddings/Ollama.hs view
@@ -0,0 +1,74 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}++{- |+Module      : Langchain.Embeddings.Ollama+Description : Ollama integration for text embeddings in LangChain Haskell+Copyright   : (c) 2025-2026 Tushar Adhatrao+License     : MIT+Maintainer  : Tushar Adhatrao <tusharadhatrao@gmail.com>+Stability   : experimental++Ollama implementation of LangChain's embedding interface using ollama-haskell 0.3.0.0.+-}+module Langchain.Embeddings.Ollama+  ( OllamaEmbeddings (..)+  ) where++import Control.Monad.Except (throwError)+import Control.Monad.IO.Class (liftIO)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Lazy as TL+import Langchain.DocumentLoader.Core (Document (..))+import Langchain.Embeddings.Core+import Langchain.Error (llmError)++import Ollama.API.Embed (EmbedRequest (..), EmbedResponse (..), embed)+import Ollama.Client (defaultClient)+import Ollama.Types.Common (ModelName (..))+import Ollama.Types.Options (ModelOptions)++data OllamaEmbeddings = OllamaEmbeddings+  { model :: Text+  , defaultTruncate :: Maybe Bool+  , defaultKeepAlive :: Maybe Text+  , modelOptions :: Maybe ModelOptions+  }+  deriving (Show, Eq)++instance Embeddings OllamaEmbeddings where+  embedDocuments (OllamaEmbeddings {..}) docs = do+    client <- liftIO defaultClient+    let inputs = map (TL.toStrict . pageContent) docs+        req =+          EmbedRequest+            { embModel = ModelName model+            , embInput = Right inputs+            , embTruncate = defaultTruncate+            , embOptions = modelOptions+            , embKeepAlive = defaultKeepAlive+            , embDimensions = Nothing+            }+    eRes <- liftIO $ embed client req+    case eRes of+      Left ollamaErr -> throwError $ llmError (T.pack (show ollamaErr)) (Just "OllamaEmbeddings") Nothing+      Right resp -> pure $ map (map realToFrac) (erEmbeddings resp)++  embedQuery (OllamaEmbeddings {..}) query = do+    client <- liftIO defaultClient+    let req =+          EmbedRequest+            { embModel = ModelName model+            , embInput = Left query+            , embTruncate = defaultTruncate+            , embOptions = modelOptions+            , embKeepAlive = defaultKeepAlive+            , embDimensions = Nothing+            }+    eRes <- liftIO $ embed client req+    case eRes of+      Left err -> throwError $ llmError (T.pack (show err)) (Just "OllamaEmbeddings") Nothing+      Right resp -> case erEmbeddings resp of+        (vec : _) -> pure $ map realToFrac vec+        [] -> throwError $ llmError "Embeddings are empty" (Just "OllamaEmbeddings") Nothing
+ src/Langchain/Provider/Ollama.hs view
@@ -0,0 +1,415 @@+{-# LANGUAGE DeriveGeneric #-}+{-# 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'. Supports native structured outputs+via JSON Schema grammar sampling, token streaming, tool calling, and model options.+-}+module Langchain.Provider.Ollama+  ( -- * Provider+    Ollama (..)+  , newOllama+  , newOllamaWithClient++    -- * Configuration+  , OllamaConfig (..)+  , OllamaOptions+  , defaultOllamaConfig+  , defaultClientConfig+  , withOptions+  , withTools+  , withJsonFormat+  , withSchemaFormat+  , withStructuredOutput+  , withKeepAlive+  , withThink++    -- * Proxy+  , Proxy (..)++    -- * Tool Binding+  , OllamaWithTools (..)+  , bindTools+  , toOllamaTool+  , toOllamaTools++    -- * Conversions+  , toOllamaRole+  , fromOllamaRole+  , toOllamaMessage+  , fromOllamaMessage++    -- * Re-exports from ollama-haskell+  , 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.Applicative ((<|>))+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.Proxy (Proxy (..))+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import GHC.Generics (Generic)++import Langchain.Cache.Core (CacheableChatModel (..))+import Langchain.Error (llmError)+import Langchain.Model+import Langchain.Stream (StreamEvent (..), TokenUsage (..))+import Langchain.Tool (Tool, toolToValue)+import Langchain.Tool.Binding (ToolBinder (..))++import qualified Ollama.API.Chat as OllamaChat+import Ollama.Client (OllamaClient (..), newClient)+import Ollama.Client.Config (OllamaClientConfig (..), defaultConfig)+import Ollama.Types.Common (Base64Image (..), ModelName (..), Think)+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 wrapping an 'OllamaClient' and model name.+data Ollama = Ollama+  { client :: !OllamaClient+  -- ^ The underlying HTTP client connection to Ollama+  , ollamaModelName :: !Text+  -- ^ The model name (e.g. @"llama3"@, @"gemma3"@)+  }++instance Show Ollama where+  show (Ollama _ m) = "Ollama provider (" ++ show m ++ ")"++-- | Create a new Ollama provider with a model name and client configuration.+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++{- | Configuration options for an Ollama invocation.++Contains request-level settings (tools, format constraints, model options,+keep-alive duration, thinking parameter) without redundant model names or message history.+-}+data OllamaConfig = OllamaConfig+  { ollamaTools :: !(Maybe [OTool.Tool])+  -- ^ Tool definitions available for the model to invoke+  , ollamaFormat :: !(Maybe OFormat.Format)+  -- ^ Output format constraint (e.g. JSON format or JSON schema)+  , ollamaOptions :: !(Maybe ModelOptions)+  -- ^ Runtime model parameters (temperature, num_ctx, top_k, top_p, etc.)+  , ollamaKeepAlive :: !(Maybe Text)+  -- ^ Time to keep model in memory (e.g. @"5m"@)+  , ollamaThink :: !(Maybe Think)+  -- ^ Thinking parameter for reasoning models+  }+  deriving (Eq, Show, Generic)++-- | Backward-compatible alias for 'OllamaConfig'.+type OllamaOptions = OllamaConfig++-- | Default empty 'OllamaConfig' with all options unset.+defaultOllamaConfig :: OllamaConfig+defaultOllamaConfig = OllamaConfig Nothing Nothing Nothing Nothing Nothing++-- | Default Ollama client configuration targeting local @http://localhost:11434@.+defaultClientConfig :: OllamaClientConfig+defaultClientConfig = defaultConfig++instance Semigroup OllamaConfig where+  a <> b =+    OllamaConfig+      { ollamaTools = case (ollamaTools a, ollamaTools b) of+          (Just ts1, Just ts2) -> Just (ts1 ++ ts2)+          (t1, t2) -> t2 <|> t1+      , ollamaFormat = ollamaFormat b <|> ollamaFormat a+      , ollamaOptions = ollamaOptions b <|> ollamaOptions a+      , ollamaKeepAlive = ollamaKeepAlive b <|> ollamaKeepAlive a+      , ollamaThink = ollamaThink b <|> ollamaThink a+      }++instance Monoid OllamaConfig where+  mempty = defaultOllamaConfig++-- | Attach runtime 'ModelOptions' (temperature, context size, etc.) to an 'OllamaConfig'.+withOptions :: ModelOptions -> OllamaConfig -> OllamaConfig+withOptions opts cfg = cfg {ollamaOptions = Just opts}++-- | Attach Langchain tools to an 'OllamaConfig'.+withTools :: [Tool m] -> OllamaConfig -> OllamaConfig+withTools ts cfg =+  let newTs = toOllamaTools ts+      combined = case ollamaTools cfg of+        Nothing -> newTs+        Just existing -> existing ++ newTs+   in cfg {ollamaTools = Just combined}++-- | Attach generic JSON format constraint to an 'OllamaConfig'.+withJsonFormat :: OllamaConfig -> OllamaConfig+withJsonFormat cfg = cfg {ollamaFormat = Just OFormat.JsonFormat}++-- | Attach specific Schema format constraint to an 'OllamaConfig'.+withSchemaFormat :: OSB.Schema -> OllamaConfig -> OllamaConfig+withSchemaFormat schema cfg = cfg {ollamaFormat = Just (OFormat.SchemaFormat schema)}++-- | Attach automatic 'OSD.ToSchema' derived format constraint to an 'OllamaConfig'.+withStructuredOutput ::+  forall a.+  (OSD.ToSchema a) =>+  Proxy a ->+  OllamaConfig ->+  OllamaConfig+withStructuredOutput _ cfg =+  cfg {ollamaFormat = Just (OFormat.SchemaFormat (OSD.toSchema @a))}++-- | Set keep-alive duration (e.g. @"5m"@) on an 'OllamaConfig'.+withKeepAlive :: Text -> OllamaConfig -> OllamaConfig+withKeepAlive duration cfg = cfg {ollamaKeepAlive = Just duration}++-- | Set thinking parameter on an 'OllamaConfig'.+withThink :: Think -> OllamaConfig -> OllamaConfig+withThink th cfg = cfg {ollamaThink = Just th}++-- | Convert core 'Role' to Ollama 'O.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++-- | Convert Ollama 'O.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 'O.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 'O.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}++-- | 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++-- | Build an 'OllamaChat.ChatRequest' from an 'Ollama' model, messages, and optional 'OllamaConfig'.+toChatRequest :: Ollama -> [Message] -> Maybe OllamaConfig -> OllamaChat.ChatRequest+toChatRequest (Ollama _ mName) inputMsgs mbConfig =+  let oMsgs = case inputMsgs of+        (m : ms) -> NonEmpty.map toOllamaMessage (m NonEmpty.:| ms)+        [] -> O.userMessage "" NonEmpty.:| []+      cfg = fromMaybe defaultOllamaConfig mbConfig+   in OllamaChat.ChatRequest+        { OllamaChat.chatModel = ModelName mName+        , OllamaChat.chatMessages = oMsgs+        , OllamaChat.chatTools = ollamaTools cfg+        , OllamaChat.chatFormat = ollamaFormat cfg+        , OllamaChat.chatOptions = ollamaOptions cfg+        , OllamaChat.chStream = Just False+        , OllamaChat.chatKeepAlive = ollamaKeepAlive cfg+        , OllamaChat.chatThink = ollamaThink cfg+        }++instance ChatModel Ollama where+  type ModelConfig Ollama = OllamaConfig++  invoke model inputMsgs mbConfig = do+    let req = toChatRequest model inputMsgs mbConfig+    eRes <- liftIO $ OllamaChat.chat (client model) req+    case eRes of+      Left err -> throwError $ llmError (T.pack $ show err) (Just "Ollama") Nothing+      Right resp -> case OllamaChat.crMessage resp of+        Nothing -> throwError $ llmError "No message in response" (Just "Ollama") Nothing+        Just oMsg -> pure $ fromOllamaMessage oMsg++  stream model inputMsgs mbConfig = do+    let runId_ = "ollama-run"+        req = toChatRequest model inputMsgs mbConfig+        resolvedModel = ollamaModelName model++    yield $ LLMStart runId_ resolvedModel inputMsgs+    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++instance CacheableChatModel Ollama where+  cacheModelIdentity (Ollama ollamaClient mName) mbCfg =+    Aeson.object+      [ "provider" .= ("ollama" :: Text)+      , "baseUrl" .= configBaseUrl (clientConfig ollamaClient)+      , "model" .= mName+      , "config"+          .= Aeson.object+            [ "tools" .= (ollamaTools =<< mbCfg)+            , "format" .= (ollamaFormat =<< mbCfg)+            , "options" .= (ollamaOptions =<< mbCfg)+            , "keep_alive" .= (ollamaKeepAlive =<< mbCfg)+            , "think" .= (ollamaThink =<< mbCfg)+            ]+      ]++-- | Attach tool definitions to an 'Ollama' invocation config.+instance ToolBinder Ollama m where+  bindToolsConfig tools mbConfig =+    case tools of+      [] -> mbConfig+      _ ->+        let cfg = fromMaybe defaultOllamaConfig mbConfig+         in Just (withTools tools cfg)++-- | Ollama model with pre-bound tools.+data OllamaWithTools m = OllamaWithTools+  { ollamaBaseModel :: !Ollama+  -- ^ The underlying Ollama model+  , ollamaBoundTools :: ![Tool m]+  -- ^ Pre-bound tools attached to this model+  }++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) = OllamaConfig+  invoke (OllamaWithTools model ts) msgs mbCfg =+    invoke model msgs (bindToolsConfig @Ollama ts mbCfg)+  stream (OllamaWithTools model ts) msgs mbCfg =+    stream model msgs (bindToolsConfig @Ollama ts mbCfg)++instance ToolBinder (OllamaWithTools m) n where+  bindToolsConfig tools mbCfg =+    case tools of+      [] -> mbCfg+      _ -> Just (withTools tools (fromMaybe defaultOllamaConfig mbCfg))
+ test/Spec.hs view
@@ -0,0 +1,19 @@+{-# LANGUAGE OverloadedStrings #-}++module Main (main) where++import qualified Test.Langchain.Provider.Ollama as OllamaProviderTest+import qualified Test.Langchain.Provider.OllamaConversionSpec as OllamaConversionTest+import Test.Tasty++main :: IO ()+main =+  defaultMain $+    testGroup+      "langchain-hs-ollama Test Suite"+      [ testGroup+          "Provider Tests"+          [ OllamaProviderTest.tests+          , OllamaConversionTest.tests+          ]+      ]
+ test/Test/Langchain/Provider/Ollama.hs view
@@ -0,0 +1,74 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeApplications #-}++module Test.Langchain.Provider.Ollama (tests) where++import Test.Tasty+import Test.Tasty.HUnit++import Data.Text (Text)+import GHC.Generics (Generic)++import Langchain.Provider.Ollama+import qualified Ollama.Client as OC+import qualified Ollama.Types.Format as OFormat++testModelName :: Text+testModelName = "gemma3:latest"++data TestPerson = TestPerson+  { personName :: !Text+  , personAge :: !Int+  }+  deriving (Show, Eq, Generic, ToSchema)++tests :: TestTree+tests =+  testGroup+    "Langchain.Provider.Ollama"+    [ testCase "newOllama initializes provider with defaultConfig" $ do+        p <- newOllama testModelName defaultClientConfig+        ollamaModelName p @?= testModelName+    , testCase "newOllama accepts custom OllamaClientConfig" $ do+        let cfg =+              defaultClientConfig+                { configBaseUrl = "http://custom-host:11434"+                , configTimeout = 120+                }+        p <- newOllama "qwen3.5:2b" cfg+        ollamaModelName p @?= "qwen3.5:2b"+        configBaseUrl (OC.clientConfig (client p)) @?= "http://custom-host:11434"+        configTimeout (OC.clientConfig (client p)) @?= 120+    , testCase "newOllamaWithClient wraps existing OllamaClient" $ do+        c <- OC.defaultClient+        let p = newOllamaWithClient testModelName c+        ollamaModelName p @?= testModelName+    , testCase "defaultOllamaConfig is empty" $ do+        let cfg = defaultOllamaConfig+        ollamaTools cfg @?= Nothing+        ollamaFormat cfg @?= Nothing+        ollamaOptions cfg @?= Nothing+        ollamaKeepAlive cfg @?= Nothing+        ollamaThink cfg @?= Nothing+    , testCase "withOptions sets ModelOptions on OllamaConfig" $ do+        let opts = defaultOptions {optTemperature = Just 0.3, optNumCtx = Just 4096}+            cfg = withOptions opts defaultOllamaConfig+        case ollamaOptions cfg of+          Nothing -> assertFailure "Expected ollamaOptions in OllamaConfig"+          Just o -> do+            optTemperature o @?= Just 0.3+            optNumCtx o @?= Just 4096+    , testCase "withJsonFormat sets JsonFormat" $ do+        let cfg = withJsonFormat defaultOllamaConfig+        ollamaFormat cfg @?= Just OFormat.JsonFormat+    , testCase "withStructuredOutput sets SchemaFormat" $ do+        let cfg = withStructuredOutput (Proxy @TestPerson) defaultOllamaConfig+        case ollamaFormat cfg of+          Just (OFormat.SchemaFormat _) -> pure ()+          _ -> assertFailure "Expected SchemaFormat in OllamaConfig"+    , testCase "withKeepAlive sets keep-alive duration" $ do+        let cfg = withKeepAlive "10m" defaultOllamaConfig+        ollamaKeepAlive cfg @?= Just "10m"+    ]
+ test/Test/Langchain/Provider/OllamaConversionSpec.hs view
@@ -0,0 +1,71 @@+{-# LANGUAGE OverloadedStrings #-}++module Test.Langchain.Provider.OllamaConversionSpec (tests) where++import qualified Data.List.NonEmpty as NonEmpty+import qualified Data.Map.Strict as Map+import Test.Tasty+import Test.Tasty.HUnit++import Langchain.Model+import Langchain.Provider.Ollama+  ( fromOllamaMessage+  , fromOllamaRole+  , toOllamaMessage+  , toOllamaRole+  )+import Ollama.Types.Common (Base64Image (..))+import qualified Ollama.Types.Message as O++tests :: TestTree+tests =+  testGroup+    "Langchain.Provider.OllamaConversionSpec"+    [ testGroup+        "Role Mapping Tests"+        [ testCase "Standard roles map to Ollama equivalents" $ do+            toOllamaRole System @?= O.System+            toOllamaRole User @?= O.User+            toOllamaRole Assistant @?= O.Assistant+            toOllamaRole Tool @?= O.Tool+        , testCase "Developer and Function roles map to System/Tool fallbacks" $ do+            toOllamaRole Developer @?= O.System+            toOllamaRole Function @?= O.Tool+        , testCase "fromOllamaRole parses Ollama roles to Langchain roles" $ do+            fromOllamaRole O.System @?= System+            fromOllamaRole O.User @?= User+            fromOllamaRole O.Assistant @?= Assistant+            fromOllamaRole O.Tool @?= Tool+        ]+    , testGroup+        "Message Conversion Tests"+        [ testCase "toOllamaMessage extracts base64 image data" $ do+            let msg = imageMessage User "image/png" "iVBORw0KGgoAAAANSUhEUg=="+                (O.Message r _ imgs _ _ _) = toOllamaMessage msg+            r @?= O.User+            case imgs of+              Just [Base64Image b64] -> b64 @?= "iVBORw0KGgoAAAANSUhEUg=="+              _ -> assertFailure "Expected single base64 image in Ollama message"+        , testCase "fromOllamaMessage parses role and text content" $ do+            let oMsg = O.Message O.Assistant "Response content" Nothing Nothing Nothing Nothing+                msg = fromOllamaMessage oMsg+            messageRole msg @?= Assistant+            extractMessageText msg @?= "Response content"+        , testCase "Multi-modal message with text and image converts correctly" $ do+            let msg =+                  Message+                    User+                    ( TextBlock "Analyze this:"+                        NonEmpty.:| [ImageBlock $ ImageContent (ImageBase64 (Just "image/jpeg") "dGVzdA==") Nothing Nothing]+                    )+                    Nothing+                    Nothing+                    Nothing+                    Map.empty+                (O.Message _ txt imgs _ _ _) = toOllamaMessage msg+            txt @?= "Analyze this:"+            case imgs of+              Just [Base64Image b64] -> b64 @?= "dGVzdA=="+              _ -> assertFailure "Expected image block conversion"+        ]+    ]