ollama-haskell 0.1.0.3 → 0.1.1.3
raw patch · 7 files changed
+338/−73 lines, 7 filesdep +base64-bytestringdep +directorydep +filepathPVP ok
version bump matches the API change (PVP)
Dependencies added: base64-bytestring, directory, filepath
API changes (from Hackage documentation)
+ Data.Ollama.Chat: chatJson :: (FromJSON jsonResult, ToJSON jsonResult) => ChatOps -> jsonResult -> Maybe Int -> IO (Either String jsonResult)
+ Data.Ollama.Common.Utils: encodeImage :: FilePath -> IO (Maybe Text)
+ Data.Ollama.Generate: generateJson :: (ToJSON jsonResult, FromJSON jsonResult) => GenerateOps -> jsonResult -> Maybe Int -> IO (Either String jsonResult)
+ Ollama: chatJson :: (FromJSON jsonResult, ToJSON jsonResult) => ChatOps -> jsonResult -> Maybe Int -> IO (Either String jsonResult)
+ Ollama: generateJson :: (ToJSON jsonResult, FromJSON jsonResult) => GenerateOps -> jsonResult -> Maybe Int -> IO (Either String jsonResult)
+ OllamaExamples: instance Data.Aeson.Types.FromJSON.FromJSON OllamaExamples.Example
+ OllamaExamples: instance Data.Aeson.Types.ToJSON.ToJSON OllamaExamples.Example
+ OllamaExamples: instance GHC.Classes.Eq OllamaExamples.Example
+ OllamaExamples: instance GHC.Generics.Generic OllamaExamples.Example
+ OllamaExamples: instance GHC.Show.Show OllamaExamples.Example
Files
- CHANGELOG.md +6/−0
- ollama-haskell.cabal +7/−1
- src/Data/Ollama/Chat.hs +77/−2
- src/Data/Ollama/Common/Utils.hs +38/−1
- src/Data/Ollama/Generate.hs +136/−64
- src/Ollama.hs +5/−0
- src/OllamaExamples.hs +69/−5
CHANGELOG.md view
@@ -1,5 +1,11 @@ # Revision history for ollama-haskell +## 0.1.1.3 -- 2024-11-08++* Increase response timeout to 15 minutes+* Added encodeImage utility function that converts image filePath to base64 image data.+* Added generateJson and chatJson. High level function to return response in Haskell type.+ ## 0.1.0.3 -- 2024-11-05 * Moving to stack instead of cabal.
ollama-haskell.cabal view
@@ -5,7 +5,7 @@ -- see: https://github.com/sol/hpack name: ollama-haskell-version: 0.1.0.3+version: 0.1.1.3 synopsis: Haskell bindings for ollama. description: Please see the README on GitHub at <https://github.com/tusharad/ollama-haskell#readme> category: Web@@ -52,7 +52,10 @@ build-depends: aeson , base >=4.7 && <5+ , base64-bytestring , bytestring+ , directory+ , filepath , http-client , http-types , text@@ -72,7 +75,10 @@ build-depends: aeson , base >=4.7 && <5+ , base64-bytestring , bytestring+ , directory+ , filepath , http-client , http-types , ollama-haskell
src/Data/Ollama/Chat.hs view
@@ -1,10 +1,12 @@ {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-} module Data.Ollama.Chat ( -- * Chat APIs chat+ , chatJson , Message (..) , Role (..) , defaultChatOps@@ -15,11 +17,12 @@ import Data.Aeson import Data.ByteString.Char8 qualified as BS import Data.ByteString.Lazy.Char8 qualified as BSL-import Data.List.NonEmpty+import Data.List.NonEmpty as NonEmpty import Data.Maybe (isNothing) import Data.Ollama.Common.Utils as CU import Data.Text (Text) import Data.Text qualified as T+import Data.Text.Encoding qualified as T import Data.Time (UTCTime) import GHC.Generics import GHC.Int (Int64)@@ -184,7 +187,7 @@ manager <- newManager defaultManagerSettings -- Setting response timeout to 5 minutes, since llm takes time- { managerResponseTimeout = responseTimeoutMicro (5 * 60 * 1000000)+ { managerResponseTimeout = responseTimeoutMicro (15 * 60 * 1000000) } initialRequest <- parseRequest $ T.unpack (url <> "/api/chat") let reqBody = cOps@@ -218,3 +221,75 @@ case stream cOps of Nothing -> genResponse "" Just (sendChunk, flush) -> streamResponse sendChunk flush++{- |+ chatJson is a higher level function that takes ChatOps (similar to chat) and also takes+ a Haskell type (that has To and From JSON instance) and returns the response in provided type.++ This function simply calls chat with extra prompt appended to it, telling LLM to return the+ response in certain JSON format and serializes the response. This function will be helpful when you+ want to use the LLM to do something programmatic.++ For Example:+ > let expectedJsonStrucutre = Example {+ > sortedList = ["sorted List here"]+ > , wasListAlreadSorted = False+ > }+ > let msg0 = Ollama.Message User "Sort given list: [4, 2 , 3, 67]. Also tell whether list was already sorted or not." Nothing+ > eRes3 <-+ > chatJson+ > defaultChatOps+ > { Chat.chatModelName = "llama3.2"+ > , Chat.messages = msg0 :| []+ > }+ > expectedJsonStrucutre+ > (Just 2)+ > print eRes3+ Output:+ > Example {sortedList = ["1","2","3","4"], wasListAlreadSorted = False}++Note: While Passing the type, construct the type that will help LLM understand the field better.+ For example, in the above example, the sortedList's value is written as "Sorted List here". This+ will help LLM understand context better.++ You can also provide number of retries in case the LLM field to return the response in correct JSON+ in first attempt.+-}+chatJson ::+ (FromJSON jsonResult, ToJSON jsonResult) =>+ ChatOps ->+ jsonResult -> -- ^ Haskell type that you want your result in+ Maybe Int -> -- ^ Max retries+ IO (Either String jsonResult)+chatJson cOps@ChatOps {..} jsonStructure mMaxRetries = do+ let lastMessage = NonEmpty.last messages+ jsonHelperPrompt =+ "You are an AI that returns only JSON object. \n"+ <> "* Your output should be a JSON object that matches the following schema: \n"+ <> T.decodeUtf8 (BSL.toStrict $ encode jsonStructure)+ <> content lastMessage+ <> "\n"+ <> "# How to treat the task:\n"+ <> "* Stricly follow the schema for the output.\n"+ <> "* Never return anything other than a JSON object.\n"+ <> "* Do not talk to the user.\n"+ chatResponse <-+ chat+ cOps+ { messages =+ NonEmpty.fromList $+ lastMessage {content = jsonHelperPrompt} : NonEmpty.init messages+ }+ case chatResponse of+ Left err -> return $ Left err+ Right r -> do+ let mMessage = message r+ case mMessage of+ Nothing -> return $ Left "Something went wrong"+ Just res -> do+ case decode (BSL.fromStrict . T.encodeUtf8 $ content res) of+ Nothing -> do + case mMaxRetries of+ Nothing -> return $ Left "Decoding Failed :("+ Just n -> if n < 1 then return $ Left "Decoding Failed :(" else chatJson cOps jsonStructure (Just (n - 1))+ Just resultInType -> return $ Right resultInType
src/Data/Ollama/Common/Utils.hs view
@@ -1,8 +1,45 @@ {-# LANGUAGE OverloadedStrings #-} -module Data.Ollama.Common.Utils (defaultOllama, OllamaClient (..)) where+module Data.Ollama.Common.Utils (defaultOllama, OllamaClient (..), encodeImage) where +import Control.Exception (IOException, try)+import Data.ByteString qualified as BS+import Data.ByteString.Base64 qualified as Base64+import Data.Char (toLower) import Data.Ollama.Common.Types+import Data.Text (Text)+import Data.Text.Encoding qualified as TE+import System.Directory+import System.FilePath defaultOllama :: OllamaClient defaultOllama = OllamaClient "http://127.0.0.1:11434"++supportedExtensions :: [String]+supportedExtensions = [".jpg", ".jpeg", ".png"]++safeReadFile :: FilePath -> IO (Either IOException BS.ByteString)+safeReadFile = try . BS.readFile++asPath :: FilePath -> IO (Maybe BS.ByteString)+asPath filePath = do+ exists <- doesFileExist filePath+ if exists+ then either (const Nothing) Just <$> safeReadFile filePath+ else return Nothing++isSupportedExtension :: FilePath -> Bool+isSupportedExtension path = map toLower (takeExtension path) `elem` supportedExtensions++{- |+ encodeImage is a utility function that takes an image file path (jpg, jpeg, png) and+ returns the image data in Base64 encoded format. Since GenerateOps' images field + expects image data in base64. It is helper function that we are providing out of the box.+-}+encodeImage :: FilePath -> IO (Maybe Text)+encodeImage filePath = do+ if not (isSupportedExtension filePath)+ then return Nothing+ else do+ maybeContent <- asPath filePath+ return $ fmap (TE.decodeUtf8 . Base64.encode) maybeContent
src/Data/Ollama/Generate.hs view
@@ -6,6 +6,7 @@ ( -- * Generate Texts generate , defaultGenerateOps+ , generateJson , GenerateOps (..) , GenerateResponse (..) ) where@@ -17,6 +18,7 @@ import Data.Ollama.Common.Utils as CU import Data.Text (Text) import Data.Text qualified as T+import Data.Text.Encoding qualified as T import Data.Time (UTCTime) import GHC.Int (Int64) import Network.HTTP.Client@@ -24,13 +26,13 @@ -- TODO: Add Options parameter -- TODO: Add Context parameter -{- | - Input type for generate functions. This data type represents all possible configurations +{- |+ Input type for generate functions. This data type represents all possible configurations that you can pass to the Ollama generate API.- + Example:- - > let ops = GenerateOps ++ > let ops = GenerateOps > { modelName = "llama3.2" > , prompt = "What is the meaning of life?" > , suffix = Nothing@@ -91,21 +93,22 @@ <> show keepAlive instance Eq GenerateOps where- (==) a b = - modelName a == modelName b &&- prompt a == prompt b &&- suffix a == suffix b &&- images a == images b &&- format a == format b &&- system a == system b &&- template a == template b &&- raw a == raw b &&- keepAlive a == keepAlive b+ (==) a b =+ modelName a == modelName b+ && prompt a == prompt b+ && suffix a == suffix b+ && images a == images b+ && format a == format b+ && system a == system b+ && template a == template b+ && raw a == raw b+ && keepAlive a == keepAlive b -- TODO: Add Context Param --- | --- Result type for generate function containing the model's response and meta-information.+{- |+Result type for generate function containing the model's response and meta-information.+-} data GenerateResponse = GenerateResponse { model :: Text -- ^ The name of the model that generated the response.@@ -171,15 +174,16 @@ <*> v .:? "eval_count" <*> v .:? "eval_duration" --- | --- A function to create a default 'GenerateOps' type with preset values.--- --- Example:------ > let ops = defaultGenerateOps--- > generate ops--- --- This will generate a response using the default configuration.+{- |+A function to create a default 'GenerateOps' type with preset values.++Example:++> let ops = defaultGenerateOps+> generate ops++This will generate a response using the default configuration.+-} defaultGenerateOps :: GenerateOps defaultGenerateOps = GenerateOps@@ -195,49 +199,50 @@ , keepAlive = Nothing } --- | --- Generate function that returns either a 'GenerateResponse' type or an error message.--- It takes a 'GenerateOps' configuration and performs a request to the Ollama generate API.--- --- Examples:------ Basic usage without streaming:------ > let ops = GenerateOps --- > { modelName = "llama3.2"--- > , prompt = "Tell me a joke."--- > , suffix = Nothing--- > , images = Nothing--- > , format = Nothing--- > , system = Nothing--- > , template = Nothing--- > , stream = Nothing--- > , raw = Nothing--- > , keepAlive = Nothing--- > }--- > result <- generate ops--- > case result of--- > Left errorMsg -> putStrLn ("Error: " ++ errorMsg)--- > Right response -> print response------ Usage with streaming to print responses to the console:------ > void $--- > generate--- > defaultGenerateOps--- > { modelName = "llama3.2"--- > , prompt = "what is functional programming?"--- > , stream = Just (T.putStr . response_, pure ())--- > }------ In this example, the first function in the 'stream' tuple processes each chunk of response by printing it,--- and the second function is a simple no-op flush.generate :: GenerateOps -> IO (Either String GenerateResponse)+{- |+Generate function that returns either a 'GenerateResponse' type or an error message.+It takes a 'GenerateOps' configuration and performs a request to the Ollama generate API.++Examples:++Basic usage without streaming:++> let ops = GenerateOps+> { modelName = "llama3.2"+> , prompt = "Tell me a joke."+> , suffix = Nothing+> , images = Nothing+> , format = Nothing+> , system = Nothing+> , template = Nothing+> , stream = Nothing+> , raw = Nothing+> , keepAlive = Nothing+> }+> result <- generate ops+> case result of+> Left errorMsg -> putStrLn ("Error: " ++ errorMsg)+> Right response -> print response++Usage with streaming to print responses to the console:++> void $+> generate+> defaultGenerateOps+> { modelName = "llama3.2"+> , prompt = "what is functional programming?"+> , stream = Just (T.putStr . response_, pure ())+> }++In this example, the first function in the 'stream' tuple processes each chunk of response by printing it,+and the second function is a simple no-op flush.generate :: GenerateOps -> IO (Either String GenerateResponse)+-} generate :: GenerateOps -> IO (Either String GenerateResponse) generate genOps = do let url = CU.host defaultOllama manager <- newManager -- Setting response timeout to 5 minutes, since llm takes time- defaultManagerSettings {managerResponseTimeout = responseTimeoutMicro (5 * 60 * 1000000)}+ defaultManagerSettings {managerResponseTimeout = responseTimeoutMicro (15 * 60 * 1000000)} initialRequest <- parseRequest $ T.unpack (url <> "/api/generate") let reqBody = genOps request =@@ -270,3 +275,70 @@ case stream genOps of Nothing -> genResponse "" Just (sendChunk, flush) -> streamResponse sendChunk flush++{- |+ generateJson is a higher level function that takes generateOps (similar to generate) and also takes+ a Haskell type (that has To and From JSON instance) and returns the response in provided type.++ This function simply calls generate with extra prompt appended to it, telling LLM to return the+ response in certain JSON format and serializes the response. This function will be helpful when you+ want to use the LLM to do something programmatic.++ For Example:+ > let expectedJsonStrucutre = Example {+ > sortedList = ["sorted List here"]+ > , wasListAlreadSorted = False+ > }+ > eRes2 <- generateJson+ > defaultGenerateOps+ > { modelName = "llama3.2"+ > , prompt = "Sort given list: [4, 2 , 3, 67]. Also tell whether list was already sorted or not."+ > }+ > expectedJsonStrucutre+ > Nothing+ > case eRes2 of+ > Left e -> putStrLn e+ > Right r -> print ("JSON response: " :: String, r)++Output:+ > ("JSON response: ",Example {sortedList = ["1","2","3","4"], wasListAlreadSorted = False})++Note: While Passing the type, construct the type that will help LLM understand the field better.+ For example, in the above example, the sortedList's value is written as "Sorted List here". This+ will help LLM understand context better.++ You can also provide number of retries in case the LLM field to return the response in correct JSON+ in first attempt.+-}+generateJson ::+ (ToJSON jsonResult, FromJSON jsonResult) =>+ GenerateOps ->+ -- | Haskell type that you want your result in+ jsonResult ->+ -- | Max retries+ Maybe Int ->+ IO (Either String jsonResult)+generateJson genOps@GenerateOps {..} jsonStructure mMaxRetries = do+ let jsonHelperPrompt =+ "You are an AI that returns only JSON object. \n"+ <> "* Your output should be a JSON object that matches the following schema: \n"+ <> T.decodeUtf8 (BSL.toStrict $ encode jsonStructure)+ <> prompt+ <> "\n"+ <> "# How to treat the task:\n"+ <> "* Stricly follow the schema for the output.\n"+ <> "* Never return anything other than a JSON object.\n"+ <> "* Do not talk to the user.\n"+ generatedResponse <- generate genOps {prompt = jsonHelperPrompt}+ case generatedResponse of+ Left err -> return $ Left err+ Right r -> do+ case decode (BSL.fromStrict . T.encodeUtf8 $ response_ r) of+ Nothing -> do+ case mMaxRetries of+ Nothing -> return $ Left "Decoding Failed :("+ Just n ->+ if n < 1+ then return $ Left "Decoding failed :("+ else generateJson genOps jsonStructure (Just (n - 1))+ Just resultInType -> return $ Right resultInType
src/Ollama.hs view
@@ -1,3 +1,4 @@+{-# LANGUAGE DuplicateRecordFields #-} {- | #Ollama-Haskell# This library lets you run LlMs from within Haskell projects. Inspired by `ollama-python`.@@ -7,12 +8,14 @@ -- ** Generate Texts generate+ , generateJson , defaultGenerateOps , GenerateOps (..) , GenerateResponse (..) -- ** Chat with LLMs , chat+ , chatJson , Role (..) , defaultChatOps , ChatResponse (..)@@ -66,6 +69,7 @@ , Message (..) , Role (..) , chat+ , chatJson , defaultChatOps ) import Data.Ollama.Copy (copyModel)@@ -77,6 +81,7 @@ , GenerateResponse (..) , defaultGenerateOps , generate+ , generateJson ) import Data.Ollama.List (ModelInfo (..), Models (..), list) import Data.Ollama.Ps (RunningModel (..), RunningModels (..), ps)
src/OllamaExamples.hs view
@@ -1,16 +1,29 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-} -module OllamaExamples where+module OllamaExamples (main) where import Control.Monad (void)-import Data.List.NonEmpty (NonEmpty((:|)))+import Data.Aeson+import Data.List.NonEmpty (NonEmpty ((:|))) import Data.Maybe (fromMaybe)+import Data.Ollama.Chat (chatJson) import Data.Ollama.Chat qualified as Chat+import Data.Ollama.Common.Utils (encodeImage)+import Data.Ollama.Generate (generateJson) import Data.Text.IO qualified as T-import Ollama (GenerateOps(..), Role(..), chat, defaultChatOps, defaultGenerateOps, generate)+import GHC.Generics+import Ollama (GenerateOps (..), Role (..), chat, defaultChatOps, defaultGenerateOps, generate) import Ollama qualified +data Example = Example+ { sortedList :: [String]+ , wasListAlreadSorted :: Bool+ }+ deriving (Show, Eq, Generic, FromJSON, ToJSON)+ main :: IO () main = do -- Example 1: Streamed Text Generation@@ -81,6 +94,57 @@ -- This example uses the `embeddingOps` function, allowing for additional configuration like options and streaming. void $ Ollama.embeddingOps "llama3.1" "What is 5+2?" Nothing Nothing + -- Example 8: Stream Text Generation with JSON Body+ -- It is a higher level version of generate, here with genOps, you can also provide a Haskell type.+ -- You will get the response from LLM in this Haskell type.+ let expectedJsonStrucutre =+ Example+ { sortedList = ["sorted List here"]+ , wasListAlreadSorted = False+ }+ eRes2 <-+ generateJson+ defaultGenerateOps+ { modelName = "llama3.2"+ , prompt = "Sort given list: [14, 12 , 13, 67]. Also tell whether list was already sorted or not."+ }+ expectedJsonStrucutre+ (Just 2)+ case eRes2 of+ Left e -> putStrLn e+ Right r -> print ("JSON response: " :: String, r)+ -- ("JSON response: ",Example {sortedList = ["1","2","3","4"], wasListAlreadSorted = False})++ -- Example 9: Chat with JSON Body+ -- This example demonstrates setting up a chat session but you receive the response in+ -- given haskell type.+ let msg0 =+ Ollama.Message+ User+ "Sort given list: [4, 2 , 3, 67]. Also tell whether list was already sorted or not."+ Nothing+ eRes3 <-+ chatJson+ defaultChatOps+ { Chat.chatModelName = "llama3.2"+ , Chat.messages = msg0 :| []+ }+ expectedJsonStrucutre+ (Just 2)+ print eRes3++ -- Example 10: Chat with Image+ -- This example demonstrates chatting with example using an image.+ mImg <- encodeImage "/home/user/sample.png"+ void $+ generate+ defaultGenerateOps+ { modelName = "llama3.2-vision"+ , prompt = "Describe the given image"+ , images = (\x -> Just [x]) =<< mImg+ , stream = Just (T.putStr . Ollama.response_, pure ())+ }+ {- Scotty example: {-# LANGUAGE OverloadedStrings #-}@@ -109,7 +173,7 @@ conn <- open "chat.db" execute_ conn "CREATE TABLE IF NOT EXISTS conversation (convo_id INTEGER PRIMARY KEY, convo_title TEXT)" execute_ conn "CREATE TABLE IF NOT EXISTS chats (chat_id INTEGER PRIMARY KEY, convo_id INTEGER, role TEXT, message TEXT, FOREIGN KEY(convo_id) REFERENCES conversation(convo_id))"- + scotty 3000 $ do post "/chat" $ do p <- jsonData :: ActionM PromptInput@@ -123,7 +187,7 @@ _ -> pure cId liftIO $ execute conn "INSERT INTO chats (convo_id, role, message) VALUES (?, 'user', ?)" (newConvoId, trimmedP)- + stream $ \sendChunk flush -> do eRes <- generate defaultGenerateOps { modelName = "llama3.2"