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 +6/−0
- LICENSE +20/−0
- README.md +33/−0
- langchain-hs-ollama.cabal +75/−0
- src/Langchain/Embeddings/Ollama.hs +74/−0
- src/Langchain/Provider/Ollama.hs +415/−0
- test/Spec.hs +19/−0
- test/Test/Langchain/Provider/Ollama.hs +74/−0
- test/Test/Langchain/Provider/OllamaConversionSpec.hs +71/−0
+ 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"+ ]+ ]