ollama-haskell 0.1.3.0 → 0.4.0.0
raw patch · 67 files changed
Files
- CHANGELOG.md +47/−24
- README.md +153/−45
- bench/Main.hs +66/−0
- examples/AllFeatures.hs +187/−0
- examples/BasicChat.hs +21/−0
- examples/Embeddings.hs +12/−0
- examples/ModelManagement.hs +13/−0
- examples/StreamingChat.hs +18/−0
- examples/StructuredOutput.hs +37/−0
- examples/ToolCalling.hs +41/−0
- ollama-haskell.cabal +223/−69
- src/Data/Ollama/Chat.hs +0/−401
- src/Data/Ollama/Common/Types.hs +0/−64
- src/Data/Ollama/Common/Utils.hs +0/−45
- src/Data/Ollama/Copy.hs +0/−55
- src/Data/Ollama/Create.hs +0/−120
- src/Data/Ollama/Delete.hs +0/−44
- src/Data/Ollama/Embeddings.hs +0/−102
- src/Data/Ollama/Generate.hs +0/−410
- src/Data/Ollama/List.hs +0/−59
- src/Data/Ollama/Ps.hs +0/−61
- src/Data/Ollama/Pull.hs +0/−114
- src/Data/Ollama/Push.hs +0/−85
- src/Data/Ollama/Show.hs +0/−175
- src/Ollama.hs +182/−79
- src/Ollama/API/Blobs.hs +45/−0
- src/Ollama/API/Chat.hs +170/−0
- src/Ollama/API/Embed.hs +175/−0
- src/Ollama/API/Generate.hs +199/−0
- src/Ollama/API/Models.hs +171/−0
- src/Ollama/API/Models/Create.hs +165/−0
- src/Ollama/API/Models/Pull.hs +100/−0
- src/Ollama/API/Models/Push.hs +100/−0
- src/Ollama/API/Ps.hs +31/−0
- src/Ollama/API/Version.hs +30/−0
- src/Ollama/Client.hs +110/−0
- src/Ollama/Client/Config.hs +103/−0
- src/Ollama/Client/Internal.hs +276/−0
- src/Ollama/Conversation.hs +110/−0
- src/Ollama/Error.hs +66/−0
- src/Ollama/MCP.hs +260/−0
- src/Ollama/Streaming.hs +41/−0
- src/Ollama/Testing.hs +162/−0
- src/Ollama/Types.hs +38/−0
- src/Ollama/Types/Common.hs +152/−0
- src/Ollama/Types/Format.hs +56/−0
- src/Ollama/Types/Format/SchemaBuilder.hs +223/−0
- src/Ollama/Types/Format/SchemaDerive.hs +267/−0
- src/Ollama/Types/Message.hs +129/−0
- src/Ollama/Types/Model.hs +161/−0
- src/Ollama/Types/Options.hs +135/−0
- src/Ollama/Types/Tool.hs +148/−0
- test-integration/Main.hs +176/−0
- test/Main.hs +26/−202
- test/Test/Ollama/Golden/Chat.hs +25/−0
- test/Test/Ollama/Golden/Embed.hs +16/−0
- test/Test/Ollama/Golden/Generate.hs +16/−0
- test/Test/Ollama/Golden/Models.hs +16/−0
- test/Test/Ollama/Property/Arbitrary.hs +51/−0
- test/Test/Ollama/Property/Roundtrip.hs +34/−0
- test/Test/Ollama/Unit/Config.hs +19/−0
- test/Test/Ollama/Unit/Error.hs +22/−0
- test/Test/Ollama/Unit/MCP.hs +100/−0
- test/Test/Ollama/Unit/SchemaBuilder.hs +33/−0
- test/Test/Ollama/Unit/SchemaDerive.hs +170/−0
- test/Test/Ollama/Unit/Testing.hs +87/−0
- test/Test/Ollama/Unit/Types.hs +72/−0
CHANGELOG.md view
@@ -1,34 +1,57 @@-# Revision history for ollama-haskell--## 0.1.3.0 -- 2025-03-25--* Added options, tools and tool_calls fields in chat and generate.-* Exported EmbeddingResponse.-* Added Format argument in chat and generate function for structured output.--## 0.1.2.0 -- 2024-11-20--* Added hostUrl and responseTimeOut options in generate function.-* Added hostUrl and responseTimeOut options in chat function.+# Changelog -## 0.1.1.3 -- 2024-11-08+All notable changes to `ollama-haskell` will be documented in this file.+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),+and this project adheres to [PVP (Haskell Package Versioning Policy)](https://pvp.haskell.org/). -* 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+## [0.4.0.0] - 2026-08-20 -* Moving to stack instead of cabal.+### Added+- **Model Context Protocol (MCP) Integration (`Ollama.MCP`)**:+ - Full bidirectional integration with the Hackage `mcp-server` package (`mcp-server >= 0.2 && < 0.3`).+ - Seamless conversion between Ollama function calling definitions (`Tool`, `ToolCall`) and MCP definitions (`ToolDefinition`, `ArgumentDefinition`, `Content`, `McpSchema`).+ - Bridge functions: `toolToMcpDefinition`, `mcpDefinitionToTool`, `toolCallToMcpArgs`, `mcpContentToToolOutput`.+ - Re-exported MCP server runners (`runMcpServerStdio`, `runMcpServerHttp`, `runMcpServerHttpWithConfig`).+ - Dedicated unit test suite in `Test.Ollama.Unit.MCP`.+- **Automatic JSON Schema Derivation (`Ollama.Types.Format.SchemaDerive`)**:+ - Typeclasses `ToSchema` and `ToJsonType` enabling generic derivation of JSON schemas directly from Haskell record types via `GHC.Generics`.+ - Smart handling of optional fields (`Maybe a` omitted from `required`), nested records (`JObject`), lists (`JArray`), and simple sum enums (`string` enum).+ - Helper functions `schemaFor` and `formatFor` for effortless integration with `chat` / `generate` structured outputs.+ - Dedicated unit test suite in `Test.Ollama.Unit.SchemaDerive`.+- **Configurable Client Timeout**:+ - Support for custom request timeout intervals in `OllamaClientConfig` (`configTimeout`). -## 0.1.0.2 -- 2024-10-18+### Changed+- **PVP Compliance & Upper Bounds**:+ - Added strict upper bounds for `network-uri` (`>= 2.6 && < 2.8`) and `mcp-server` (`>= 0.2 && < 0.3`).+ - Upgraded Stack resolvers and snapshot dependencies (`lts-21.25`, `lts-22.44`, `lts-23.28`, `lts-24.52`, `nightly`). -* Increased response timeout time for chat function. +--- -## 0.1.0.1 -- 2024-10-18+## [0.3.0.0] - 2026-08-04 -* Renaming Lib.hs to OllamaExamples.hs as it was conflicting `Lib.hs` name+### Added+- **`OllamaClient` Core**: Thread-safe client handle with automatic connection manager lifecycle management (`newClient`, `defaultClient`, `clientFromEnv`, `withClient`).+- **First-Class Streaming Pipeline**: `conduit`-based response streaming (`chatStream`, `generateStream`, `pullStream`, `pushStream`, `createModelStream`).+- **Stream Combinators**: `collectStream` and `foldStream` in `Ollama.Streaming`.+- **Structured Output DSL**: Type-safe `SchemaBuilder` DSL in `Ollama.Types.Format.SchemaBuilder` (`|+`, `|++`, `|!`, `|!!`) for constructing JSON Schemas.+- **Thinking / Reasoning Models Support**: `Think` ADT (`ThinkEnabled`, `ThinkDisabled`, `ThinkLevel`) and `ThinkingLevel` (`ThinkLow`, `ThinkMedium`, `ThinkHigh`, `ThinkMax`) supporting models such as `qwen3.5` and `deepseek-r1`.+- **Function / Tool Calling**: `Tool`, `FunctionDef`, `FunctionParameters`, `ToolCall`, and `toolResultMessage` helper.+- **Environment Resolution**: Automatic `OLLAMA_HOST` parsing and normalization in `clientFromEnv` supporting `host:port`, `http://host:port`, and bare `host`.+- **Authorization & Headers**: Support for `OLLAMA_API_KEY` bearer tokens and custom `configHeaders`.+- **Configurable Resilience**: `RetryPolicy` ADT (`NoRetry`, `ConstantRetry`, `ExponentialRetry`), lifecycle callbacks (`configOnStart`, `configOnSuccess`, `configOnError`), and structured logger `configLogger`.+- **Token Throughput Metrics**: Metrics helpers `chatEvalTokensPerSecond`, `chatPromptEvalTokensPerSecond`, `evalTokensPerSecond`, `promptEvalTokensPerSecond`, `tokensPerSecond`.+- **Testing Infrastructure**: Built-in mock testing module `Ollama.Testing` (`newMockClient`, `withMockClient`, `mockGenerateResponse`, `mockChatResponse`, `mockEmbedResponse`, `mockListModelsResponse`).+- **Conversation Store**: Transactional STM-backed `InMemoryStore` and `ConversationStore` typeclass.+- **New API Endpoints**: `Ollama.API.Embed` (`/api/embed`), `Ollama.API.Blobs` (`/api/blobs`), `Ollama.API.Ps` (`/api/ps`), `Ollama.API.Version` (`/api/version`).+- **Benchmark Suite**: Criterion/tasty-bench suite in `bench/Main.hs` measuring serialization and throughput. -## 0.1.0.0 -- YYYY-mm-dd+### Changed+- **MonadIO / MonadUnliftIO Polymorphism**: All API functions use `MonadIO m =>` / `MonadUnliftIO m =>` signatures instead of dual `*M` variants.+- **Typed Newtypes**: `ModelName`, `Digest`, `Base64Image`, `Duration`, `Version` replace primitive string types.+- **Unified Error Type**: `OllamaError` sum type with structured constructors and `Exception` instance. -* First version. Released on an unsuspecting world.+### Deprecated+- `embeddings` endpoint (`/api/embeddings`) marked deprecated in favor of `/api/embed`.
README.md view
@@ -1,74 +1,182 @@-# Ollama-haskell+# ollama-haskell -**ollama-haskell** is an unofficial Haskell binding for [Ollama](https://ollama.com), similar to [`ollama-python`](https://github.com/ollama/ollama-python). +[](https://hackage.haskell.org/package/ollama-haskell)+[](LICENSE) -This library allows you to interact with Ollama, a tool that lets you run large language models (LLMs) locally, from within your Haskell projects. +Industry-grade, feature-complete, modern Haskell client library for the [Ollama](https://ollama.com) local LLM engine. -## Examples+## Features +- **Client-Centric Architecture**: Thread-safe `OllamaClient` handle with connection pooling and resource management (`newClient`, `defaultClient`, `clientFromEnv`, `withClient`).+- **First-Class Streaming**: `conduit`-based response streaming (`chatStream`, `generateStream`, `pullStream`, `pushStream`, `createModelStream`).+- **Model Context Protocol (MCP) Bridge**: Bidirectional integration with `mcp-server` for converting between Ollama tools and MCP tools, running MCP servers via stdio or HTTP (`Ollama.MCP`).+- **Generic JSON Schema Derivation**: Automatically derive JSON schemas from Haskell data types via `GHC.Generics` with `ToSchema` and `formatFor`.+- **Complete API Surface**: Text generation, chat completions, vector embeddings, model management (list, show, copy, delete, pull, push, create), and system endpoints.+- **Structured Outputs DSL**: Powerful `SchemaBuilder` DSL (`|+`, `|++`, `|!`, `|!!`) for type-safe JSON Schema structured responses.+- **Function / Tool Calling**: Full support for tool definitions (`Tool`), tool calls (`ToolCall`), and execution results (`toolResultMessage`).+- **Thinking Models Support**: Native support for reasoning models (`qwen3.5`, `deepseek-r1`) with `Think` / `ThinkingLevel` types.+- **Environment & Auth Integration**: Robust URL normalization for `OLLAMA_HOST` and bearer token support for `OLLAMA_API_KEY`.+- **Configurable Resilience**: Flexible retry policies (`NoRetry`, `ConstantRetry`, `ExponentialRetry`), custom timeouts, lifecycle callbacks, and structured logging.+- **Conversation Store**: Transactional STM-backed `InMemoryStore` and `ConversationStore` typeclass for managing multi-turn chat sessions.+- **SDK Comparison Matrix**: Detailed feature comparison against Python, JS/TS, and Go SDKs in [doc/COMPARISON.md](doc/COMPARISON.md).++---++## Installation++Add `ollama-haskell` to your `.cabal` file:++```cabal+build-depends:+ base >= 4.17 && < 5+ , ollama-haskell >= 0.4.0.0+```++Or using Stack in `package.yaml`:++```yaml+dependencies:+ - ollama-haskell >= 0.4.0.0+```++---++## Quick Start (5 Lines)+ ```haskell-{-# LANGUAGE OverloadedStrings #-}-module Lib where+import Data.List.NonEmpty (NonEmpty ((:|)))+import Data.Text.IO qualified as TIO+import Ollama -import Ollama (GenerateOps(..), defaultGenerateOps, generate)+main :: IO ()+main = do+ client <- defaultClient+ res <- chat client $ chatRequest "qwen3.5:2b" (userMessage "Why is the sky blue?" :| [])+ case res of+ Left err -> print err+ Right resp -> mapM_ (TIO.putStrLn . messageContent) (crMessage resp)+``` +---++## Streaming Responses with Conduit++Stream LLM responses token-by-token as they generate:++```haskell+import Data.List.NonEmpty (NonEmpty ((:|)))+import Data.Text.IO qualified as TIO+import Ollama+ main :: IO () main = do- void $- generate- defaultGenerateOps- { modelName = "llama3.2"- , prompt = "what is functional programming?"- , stream = Just (T.putStr . Ollama.response_, pure ())- }+ client <- defaultClient+ let req = chatRequest "qwen3.5:2b" (userMessage "Count from 1 to 5." :| [])+ + -- Stream chunks directly into stdout or collect them+ chunks <- collectStream (chatStream client req)+ mapM_ (TIO.putStr . maybe "" messageContent . crMessage) chunks+ putStrLn "" ``` -### Output+--- -```bash-ghci> import Lib-ghci> main+## Function & Tool Calling -Whether Haskell is a "good" language depends on what you're looking for in a programming language and your personal preferences. Here are some points to consider:+Define function signatures and let the LLM execute structured tool calls: -**Pros:**+```haskell+import Data.List.NonEmpty (NonEmpty ((:|)))+import Ollama -1. **Strongly typed**: Haskell's type system ensures that you catch errors early, which leads to fewer bugs and easier maintenance.-2. **Functional programming paradigm**: Haskell encourages declarative coding, making it easier to reason about code and write correct programs.-3. **Garbage collection**: Haskell handles memory management automatically, freeing you from worries about manual memory deallocation.+calculatorTool :: Tool+calculatorTool = Tool "function" $ FunctionDef+ { fnName = "add"+ , fnDescription = Just "Add two numbers"+ , fnParameters = Just (FunctionParameters "object" Nothing (Just ["a", "b"]) Nothing Nothing Nothing)+ , fnStrict = Just True+ }++main :: IO ()+main = do+ client <- defaultClient+ let req = (chatRequest "qwen3.5:2b" (userMessage "What is 40 + 2?" :| []))+ { chatTools = Just [calculatorTool] }+ res <- chat client req+ case res of+ Left err -> print err+ Right resp -> print (crMessage resp) ``` -You can find practical examples demonstrating how to use the library in the `examples/OllamaExamples.hs` file. +--- -## Prerequisite+## Structured Outputs (JSON Schema DSL) -Make sure you have [Ollama](https://ollama.com) installed and running on your local machine. You can download it from [here](https://ollama.com/download).+Enforce structured JSON output formats using `SchemaBuilder`: -## How to Use It+```haskell+import Data.Text.IO qualified as TIO+import Ollama+import Ollama.Types.Format.SchemaBuilder -1. Include the `ollama-haskell` package in your `.cabal` file:- ```cabal- build-depends:- base >= 4.7 && < 5,- ollama-haskell- ```+personSchema :: Schema+personSchema = buildSchema $ emptyObject+ |+ ("name", JString)+ |+ ("age", JInteger)+ |! "name" -3. Import the `Ollama` module and start integrating with your local LLM.+main :: IO ()+main = do+ client <- defaultClient+ let req = (generateRequest "qwen3.5:2b" "Generate a person profile.")+ { genFormat = Just (SchemaFormat personSchema) }+ res <- generate client req+ case res of+ Left err -> print err+ Right resp -> TIO.putStrLn (grResponse resp)+``` -## Future Updates+--- -- [x] Improve documentation-- [x] Add tests.-- [x] Add examples.-- [x] Add CI/CD pipeline.-- [ ] `options` parameter in `generate`.+## Environment Variables & Configuration -Stay tuned for future updates and improvements!+Construct a client using environment variables (`OLLAMA_HOST`, `OLLAMA_API_KEY`): -## Author+```haskell+main :: IO ()+main = do+ client <- clientFromEnv+ -- Automatically connects to OLLAMA_HOST with optional Authorization: Bearer header+ ...+``` -This library is developed and maintained by [Tushar](https://github.com/tusharad). Feel free to reach out for any questions or suggestions!+Or configure custom retry policies and loggers: -## Contributions+```haskell+customConfig :: OllamaClientConfig+customConfig = defaultConfig+ { configBaseUrl = "http://my-ollama-server:11434"+ , configTimeout = 120+ , configRetry = ExponentialRetry 3 1 -- 3 retries with exponential backoff+ , configLogger = Just (\level msg -> putStrLn $ "[" <> show level <> "] " <> show msg)+ } -Contributions are welcome! If you'd like to improve the library, please submit a pull request or open an issue. Whether it's fixing bugs, adding new features, or improving documentation, all contributions are greatly appreciated.+main :: IO ()+main = withClient customConfig $ \client -> do+ ...+```++---++## Documentation & SDK Comparison++- [doc/COMPARISON.md](doc/COMPARISON.md) — SDK Feature Matrix comparing `ollama-haskell` with Python, JS/TS, and Go SDKs.+- [CONTRIBUTING.md](CONTRIBUTING.md) — Development setup, testing guidelines, and code style.+- [CHANGELOG.md](CHANGELOG.md) — Release notes and changelog.+- [Hackage Documentation](https://hackage.haskell.org/package/ollama-haskell) — Full Haddock reference.++---++## License++MIT © 2024–2026 Tushar Adhatrao
+ bench/Main.hs view
@@ -0,0 +1,66 @@+{-# LANGUAGE OverloadedStrings #-}++{- |+Module : Main+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Benchmark suite for Ollama client JSON serialization and URL normalization operations.++@since 1.0.0.0+-}+module Main (main) where++import Data.Aeson (decode, encode)+import Data.ByteString.Lazy (ByteString)+import Data.List.NonEmpty (NonEmpty ((:|)))+import Ollama.API.Chat (ChatRequest, ChatResponse, chatRequest)+import Ollama.API.Embed (EmbedRequest, EmbedResponse, embedRequest)+import Ollama.API.Generate (GenerateRequest, GenerateResponse, generateRequest)+import Ollama.API.Models.Create (CreateRequest, defaultCreateRequest)+import Ollama.Testing (mockChatResponse, mockEmbedResponse, mockGenerateResponse)+import Ollama.Types.Common (ModelName (..))+import Ollama.Types.Message (userMessage)+import Test.Tasty.Bench (bench, bgroup, defaultMain, whnf)++main :: IO ()+main =+ defaultMain+ [ bgroup+ "JSON Encoding"+ [ bench "GenerateRequest" $ whnf encode sampleGenerateRequest+ , bench "ChatRequest" $ whnf encode sampleChatRequest+ , bench "EmbedRequest" $ whnf encode sampleEmbedRequest+ , bench "CreateRequest" $ whnf encode sampleCreateRequest+ ]+ , bgroup+ "JSON Decoding"+ [ bench "GenerateResponse" $ whnf (decode @GenerateResponse) sampleGenerateBytes+ , bench "ChatResponse" $ whnf (decode @ChatResponse) sampleChatBytes+ , bench "EmbedResponse" $ whnf (decode @EmbedResponse) sampleEmbedBytes+ ]+ ]++sampleGenerateRequest :: GenerateRequest+sampleGenerateRequest = generateRequest (ModelName "llama3.2") "Why is the sky blue?"++sampleChatRequest :: ChatRequest+sampleChatRequest = chatRequest (ModelName "llama3.2") (userMessage "Hello, world!" :| [])++sampleEmbedRequest :: EmbedRequest+sampleEmbedRequest = embedRequest (ModelName "nomic-embed-text") ["Hello", "World", "Vector"]++sampleCreateRequest :: CreateRequest+sampleCreateRequest = defaultCreateRequest (ModelName "custom-llama")++sampleGenerateBytes :: ByteString+sampleGenerateBytes = encode $ mockGenerateResponse (ModelName "llama3.2") "Sky is blue"++sampleChatBytes :: ByteString+sampleChatBytes = encode $ mockChatResponse (ModelName "llama3.2") "Hello there"++sampleEmbedBytes :: ByteString+sampleEmbedBytes = encode $ mockEmbedResponse (ModelName "nomic") [[0.1, 0.2]]
+ examples/AllFeatures.hs view
@@ -0,0 +1,187 @@+{-# LANGUAGE OverloadedStrings #-}++{- |+Module : Main+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Comprehensive example demonstrating all v1.0 features of ollama-haskell:+ * Client builder with env resolution, custom headers, & retry policies+ * Conduit-based response streaming+ * Thinking / reasoning model integration+ * Function / tool calling+ * Structured JSON schema output+ * Vector embeddings & token throughput metrics+ * Transactional STM conversation storage++@since 1.0.0.0+-}+module Main (main) where++import Data.List.NonEmpty (NonEmpty ((:|)))+import Data.Text qualified as T+import Data.Time (getCurrentTime)+import Ollama++fastOptions :: Maybe ModelOptions+fastOptions = Just (defaultOptions {optNumPredict = Just 20})++main :: IO ()+main = do+ putStrLn "=========================================================="+ putStrLn " ollama-haskell v1.0 — All Features Showcase Example"+ putStrLn "=========================================================="++ -- 1. Client Builder with env vars, custom headers, & Exponential Retry Policy+ putStrLn "\n[1] Initializing client..."+ let customConfig =+ defaultConfig+ { configRetry = ExponentialRetry 3 1+ , configHeaders = [("X-Custom-Client", "ollama-haskell-v1.0")]+ }+ client <- newClient customConfig+ putStrLn "Client initialized with ExponentialRetry policy."++ -- 2. System Inspection+ putStrLn "\n[2] System Engine Version..."+ verRes <- getVersion client+ case verRes of+ Left err -> putStrLn $ "Error fetching version: " <> show err+ Right ver -> putStrLn $ "Ollama Engine Version: " <> T.unpack (unVersion ver)++ putStrLn "\n[3] Listing Local Models..."+ modelsRes <- listModels client+ case modelsRes of+ Left err -> putStrLn $ "Error listing models: " <> show err+ Right (ListResponse ms) -> do+ putStrLn $ "Found " <> show (length ms) <> " installed models:"+ mapM_ (\m -> putStrLn $ " - " <> T.unpack (unModelName (miName m))) ms++ let model = "qwen3.5:2b"++ -- 4. Conduit-Based Streaming Chat+ putStrLn $ "\n[4] Streaming Chat with Conduit (" <> T.unpack (unModelName model) <> ")..."+ let streamReq =+ (chatRequest model (userMessage "Count from 1 to 5." :| []))+ { chatOptions = fastOptions+ , chatThink = Just ThinkDisabled+ }+ putStr "Response stream: "+ chunks <- collectStream (chatStream client streamReq)+ mapM_+ ( \c -> case crMessage c of+ Just msg -> putStr (T.unpack (messageContent msg))+ Nothing -> pure ()+ )+ chunks+ putStrLn ""++ -- 5. Non-Streaming Chat with Token Throughput Metrics+ putStrLn "\n[5] Non-Streaming Chat & Token Metrics..."+ let chatReq =+ ( chatRequest+ model+ (systemMessage "You are a concise assistant." :| [userMessage "Explain gravity in one sentence."])+ )+ { chatOptions = fastOptions+ , chatThink = Just ThinkDisabled+ }+ chatRes <- chat client chatReq+ case chatRes of+ Left err -> putStrLn $ "Chat error: " <> show err+ Right resp -> do+ case crMessage resp of+ Just msg -> putStrLn $ "Answer: " <> T.unpack (messageContent msg)+ Nothing -> putStrLn "No message returned."+ case chatEvalTokensPerSecond resp of+ Just tps -> putStrLn $ "Generation Speed: " <> show tps <> " tokens/sec"+ Nothing -> pure ()++ -- 6. Thinking / Reasoning Model Integration+ putStrLn "\n[6] Generation with Thinking Mode..."+ let thinkReq =+ (generateRequest model "What is 15 * 14?")+ { genThink = Just (ThinkLevel ThinkMedium)+ , genOptions = fastOptions+ }+ thinkRes <- generate client thinkReq+ case thinkRes of+ Left err -> putStrLn $ "Generate error: " <> show err+ Right resp -> putStrLn $ "Result: " <> T.unpack (grResponse resp)++ -- 7. Tool / Function Calling+ putStrLn "\n[7] Tool / Function Calling..."+ let calcTool =+ Tool+ { toolType = "function"+ , toolFunction =+ FunctionDef+ { fnName = "calculator"+ , fnDescription = Just "Perform basic math calculations"+ , fnParameters =+ Just+ FunctionParameters+ { fpType = "object"+ , fpProperties = Nothing+ , fpRequired = Just ["expression"]+ , fpAdditionalProperties = Nothing+ , fpDescription = Nothing+ , fpEnum = Nothing+ }+ , fnStrict = Just True+ }+ }+ toolReq =+ (chatRequest model (userMessage "Calculate 42 * 8" :| []))+ { chatTools = Just [calcTool]+ , chatOptions = fastOptions+ , chatThink = Just ThinkDisabled+ }+ toolRes <- chat client toolReq+ case toolRes of+ Left err -> putStrLn $ "Tool chat error: " <> show err+ Right resp -> case crMessage resp of+ Just msg -> case messageToolCalls msg of+ Just calls -> putStrLn $ "Model requested tool execution: " <> show calls+ Nothing -> putStrLn $ "Response: " <> T.unpack (messageContent msg)+ Nothing -> putStrLn "No message returned."++ -- 8. Vector Embeddings+ putStrLn "\n[8] Vector Embeddings..."+ let embReq = embedRequest model ["Haskell AI development", "Ollama LLM client"]+ embRes <- embed client embReq+ case embRes of+ Left err -> putStrLn $ "Embed error: " <> show err+ Right resp -> putStrLn $ "Generated " <> show (length (erEmbeddings resp)) <> " embedding vectors."++ -- 9. Transactional Conversation Store+ putStrLn "\n[9] STM Conversation Store..."+ store <- initInMemoryStore+ now <- getCurrentTime+ let convId = "demo-session-42"+ session =+ Conversation+ convId+ [systemMessage "Context saved.", userMessage "Favorite language is Haskell."]+ model+ now+ now+ saveConversationInMemory store session+ retrieved <- loadConversationInMemory store convId+ case retrieved of+ Just c ->+ putStrLn $+ "Successfully loaded conversation ["+ <> T.unpack (conversationId c)+ <> "] with "+ <> show (length (messages c))+ <> " messages."+ Nothing -> putStrLn "Failed to load conversation."++ closeClient client+ putStrLn "\n=========================================================="+ putStrLn " Showcase Completed Successfully!"+ putStrLn "=========================================================="
+ examples/BasicChat.hs view
@@ -0,0 +1,21 @@+module Main (main) where++import Data.List.NonEmpty (NonEmpty ((:|)))+import Data.Text.IO qualified as TIO+import Ollama++main :: IO ()+main = do+ client <- defaultClient+ let opts = Just (defaultOptions {optNumPredict = Just 20})+ req =+ (chatRequest "qwen3.5:2b" (userMessage "Why is the sky blue?" :| []))+ { chatOptions = opts+ , chatThink = Just ThinkDisabled+ }+ res <- chat client req+ case res of+ Left err -> putStrLn $ "Error: " <> show err+ Right resp -> case crMessage resp of+ Just msg -> TIO.putStrLn $ "Response: " <> messageContent msg+ Nothing -> putStrLn "No content returned"
+ examples/Embeddings.hs view
@@ -0,0 +1,12 @@+module Main (main) where++import Ollama++main :: IO ()+main = do+ client <- defaultClient+ let req = embedRequest "qwen3.5:2b" ["Hello world", "Haskell LLM integration"]+ res <- embed client req+ case res of+ Left err -> putStrLn $ "Error: " <> show err+ Right resp -> putStrLn $ "Generated " <> show (length $ erEmbeddings resp) <> " embeddings."
+ examples/ModelManagement.hs view
@@ -0,0 +1,13 @@+module Main (main) where++import Ollama++main :: IO ()+main = do+ client <- defaultClient+ res <- listModels client+ case res of+ Left err -> putStrLn $ "Error listing models: " <> show err+ Right (ListResponse ms) -> do+ putStrLn "Installed Models:"+ mapM_ (print . miName) ms
+ examples/StreamingChat.hs view
@@ -0,0 +1,18 @@+module Main (main) where++import Data.List.NonEmpty (NonEmpty ((:|)))+import Data.Text.IO qualified as TIO+import Ollama++main :: IO ()+main = do+ client <- defaultClient+ let opts = Just (defaultOptions {optNumPredict = Just 20})+ req =+ (chatRequest "qwen3.5:2b" (userMessage "Count from 1 to 5." :| []))+ { chatOptions = opts+ , chatThink = Just ThinkDisabled+ }+ chunks <- collectStream (chatStream client req)+ mapM_ (TIO.putStr . maybe "" messageContent . crMessage) chunks+ putStrLn ""
+ examples/StructuredOutput.hs view
@@ -0,0 +1,37 @@+module Main (main) where++import Data.Aeson (FromJSON, eitherDecode)+import Data.Text (Text)+import Data.Text.IO qualified as TIO+import Data.Text.Lazy qualified as TL+import Data.Text.Lazy.Encoding qualified as TLE+import GHC.Generics (Generic)+import Ollama++-- | Define your type and derive 'ToSchema'. That's it — no manual schema needed.+data Person = Person+ { name :: Text+ , age :: Int+ }+ deriving stock (Generic, Show)+ deriving anyclass (FromJSON, ToSchema)++main :: IO ()+main = do+ client <- defaultClient+ let opts = Just (defaultOptions {optNumPredict = Just 20})+ req =+ (generateRequest "qwen3.5:2b" "Generate a person profile.")+ { genFormat = Just (formatFor @Person)+ , genOptions = opts+ , genThink = Just ThinkDisabled+ }+ res <- generate client req+ case res of+ Left err -> putStrLn $ "Error: " <> show err+ Right resp -> do+ TIO.putStrLn $ "Raw response:\n" <> grResponse resp+ -- Decode into our typed Person+ case eitherDecode (TLE.encodeUtf8 . TL.fromStrict $ grResponse resp) of+ Left decErr -> putStrLn $ "Decode error: " <> decErr+ Right person -> putStrLn $ "Parsed: " <> show (person :: Person)
+ examples/ToolCalling.hs view
@@ -0,0 +1,41 @@+module Main (main) where++import Data.List.NonEmpty (NonEmpty ((:|)))+import Ollama++calculatorTool :: Tool+calculatorTool =+ Tool+ { toolType = "function"+ , toolFunction =+ FunctionDef+ { fnName = "add"+ , fnDescription = Just "Add two numbers"+ , fnParameters =+ Just+ FunctionParameters+ { fpType = "object"+ , fpProperties = Nothing+ , fpRequired = Just ["a", "b"]+ , fpAdditionalProperties = Nothing+ , fpDescription = Nothing+ , fpEnum = Nothing+ }+ , fnStrict = Just True+ }+ }++main :: IO ()+main = do+ client <- defaultClient+ let opts = Just (defaultOptions {optNumPredict = Just 20})+ req =+ (chatRequest "qwen3.5:2b" (userMessage "What is 25 + 17?" :| []))+ { chatTools = Just [calculatorTool]+ , chatOptions = opts+ , chatThink = Just ThinkDisabled+ }+ res <- chat client req+ case res of+ Left err -> putStrLn $ "Error: " <> show err+ Right resp -> print (crMessage resp)
ollama-haskell.cabal view
@@ -1,91 +1,245 @@-cabal-version: 1.12---- This file has been generated from package.yaml by hpack version 0.37.0.------ see: https://github.com/sol/hpack--name: ollama-haskell-version: 0.1.3.0-synopsis: Haskell bindings for ollama.-description: Ollama client for Haskell-category: Web-homepage: https://github.com/tusharad/ollama-haskell#readme-bug-reports: https://github.com/tusharad/ollama-haskell/issues-author: tushar-maintainer: tusharadhatrao@gmail.com-copyright: 2024 tushar-license: MIT-license-file: LICENSE-build-type: Simple-extra-source-files:- README.md- CHANGELOG.md+cabal-version: 3.0+name: ollama-haskell+version: 0.4.0.0+synopsis: Industry-grade Haskell client for Ollama local LLMs+description:+ A type-safe Haskell client for interacting with+ locally-running LLMs via the Ollama HTTP API. Supports chat,+ text generation, embeddings, model management, streaming via+ conduit, structured outputs, tool calling, Model Context Protocol (MCP),+ and generic JSON schema derivation.+category: Web, AI, Network+license: MIT+license-file: LICENSE+author: Tushar Adhatrao+maintainer: tusharadhatrao@gmail.com+copyright: 2024-2026 Tushar Adhatrao+homepage: https://github.com/tusharad/ollama-haskell+bug-reports: https://github.com/tusharad/ollama-haskell/issues+tested-with: GHC == 9.4.8, GHC == 9.6.7, GHC == 9.8.4, GHC == 9.10.3, GHC == 9.12.4+stability: stable+extra-doc-files:+ README.md+ CHANGELOG.md source-repository head type: git location: https://github.com/tusharad/ollama-haskell +flag integration-tests+ description: Build and run integration tests (requires running Ollama server)+ default: False+ manual: True++common warnings+ ghc-options:+ -Wall+ -Wcompat+ -Widentities+ -Wincomplete-record-updates+ -Wincomplete-uni-patterns+ -Wmissing-export-lists+ -Wmissing-home-modules+ -Wpartial-fields+ -Wredundant-constraints+ -Wunused-packages++common lang+ default-language: GHC2021+ default-extensions:+ StrictData+ DerivingStrategies+ DeriveGeneric+ DeriveAnyClass+ GeneralizedNewtypeDeriving+ OverloadedStrings+ ImportQualifiedPost+ TypeApplications+ RecordWildCards+ NamedFieldPuns+ LambdaCase+ MultiWayIf+ ScopedTypeVariables+ BangPatterns+ library+ import: warnings, lang+ hs-source-dirs: src exposed-modules:- Data.Ollama.Chat- Data.Ollama.Common.Types- Data.Ollama.Common.Utils- Data.Ollama.Copy- Data.Ollama.Create- Data.Ollama.Delete- Data.Ollama.Embeddings- Data.Ollama.Generate- Data.Ollama.List- Data.Ollama.Ps- Data.Ollama.Pull- Data.Ollama.Push- Data.Ollama.Show- Ollama+ Ollama+ Ollama.Client+ Ollama.Client.Config+ Ollama.API.Generate+ Ollama.API.Chat+ Ollama.API.Embed+ Ollama.API.Models+ Ollama.API.Models.Create+ Ollama.API.Models.Pull+ Ollama.API.Models.Push+ Ollama.API.Blobs+ Ollama.API.Ps+ Ollama.API.Version+ Ollama.Types+ Ollama.Types.Message+ Ollama.Types.Tool+ Ollama.Types.Model+ Ollama.Types.Options+ Ollama.Types.Format+ Ollama.Types.Format.SchemaBuilder+ Ollama.Types.Format.SchemaDerive+ Ollama.Types.Common+ Ollama.Error+ Ollama.Streaming+ Ollama.Testing+ Ollama.Conversation+ Ollama.MCP other-modules:- Paths_ollama_haskell- hs-source-dirs:- src- default-extensions:- ImportQualifiedPost- ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints+ Ollama.Client.Internal build-depends:- aeson- , base >=4.7 && <5- , base64-bytestring- , bytestring- , containers- , directory- , filepath- , http-client- , http-types- , text- , time- default-language: Haskell2010+ aeson >= 2.0 && < 3+ , base >= 4.17 && < 5+ , bytestring >= 0.10 && < 0.13+ , case-insensitive >= 1.2 && < 1.3+ , conduit >= 1.3 && < 1.4+ , conduit-extra >= 1.3 && < 1.4+ , containers >= 0.6 && < 0.9+ , hashable >= 1.4 && < 1.6+ , http-client >= 0.6 && < 0.8+ , http-client-tls >= 0.2 && < 0.5+ , http-types >= 0.7 && < 0.13+ , mtl >= 2.2 && < 3+ , retry >= 0.9 && < 0.10+ , resourcet >= 1.3 && < 1.4+ , stm >= 2.5 && < 3+ , text >= 2.0 && < 3+ , time >= 1.11 && < 2+ , unliftio-core >= 0.2 && < 0.3+ , mcp-server >= 0.2 && < 0.3 test-suite ollama-haskell-test+ import: warnings, lang type: exitcode-stdio-1.0+ hs-source-dirs: test main-is: Main.hs other-modules:- Paths_ollama_haskell- hs-source-dirs:- test- default-extensions:- ImportQualifiedPost- 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+ Test.Ollama.Unit.Types+ Test.Ollama.Unit.Error+ Test.Ollama.Unit.Config+ Test.Ollama.Unit.SchemaBuilder+ Test.Ollama.Unit.SchemaDerive+ Test.Ollama.Unit.Testing+ Test.Ollama.Property.Arbitrary+ Test.Ollama.Property.Roundtrip+ Test.Ollama.Golden.Chat+ Test.Ollama.Golden.Generate+ Test.Ollama.Golden.Embed+ Test.Ollama.Golden.Models+ Test.Ollama.Unit.MCP build-depends:- aeson- , base >=4.7 && <5- , base64-bytestring+ base+ , ollama-haskell+ , aeson , bytestring , containers- , directory- , filepath- , http-client- , http-types+ , mcp-server+ , QuickCheck >= 2.14+ , tasty >= 1.5+ , tasty-golden >= 2.3+ , tasty-hunit+ , tasty-quickcheck >= 0.10+ , text+ , time+ , conduit++test-suite ollama-haskell-integration+ import: warnings, lang+ type: exitcode-stdio-1.0+ hs-source-dirs: test-integration+ main-is: Main.hs+ if !flag(integration-tests)+ buildable: False+ build-depends:+ base , ollama-haskell- , silently+ , aeson , tasty , tasty-hunit , text , time- default-language: Haskell2010++benchmark ollama-haskell-bench+ import: warnings, lang+ type: exitcode-stdio-1.0+ hs-source-dirs: bench+ main-is: Main.hs+ build-depends:+ base+ , ollama-haskell+ , aeson+ , bytestring+ , tasty >= 1.5+ , tasty-bench >= 0.3+ , text++executable ollama-example-basic-chat+ import: warnings, lang+ main-is: BasicChat.hs+ hs-source-dirs: examples+ build-depends:+ base+ , ollama-haskell+ , text++executable ollama-example-streaming-chat+ import: warnings, lang+ main-is: StreamingChat.hs+ hs-source-dirs: examples+ build-depends:+ base+ , ollama-haskell+ , text++executable ollama-example-tool-calling+ import: warnings, lang+ main-is: ToolCalling.hs+ hs-source-dirs: examples+ build-depends:+ base+ , ollama-haskell++executable ollama-example-structured-output+ import: warnings, lang+ main-is: StructuredOutput.hs+ hs-source-dirs: examples+ build-depends:+ aeson+ , base+ , ollama-haskell+ , text++executable ollama-example-embeddings+ import: warnings, lang+ main-is: Embeddings.hs+ hs-source-dirs: examples+ build-depends:+ base+ , ollama-haskell++executable ollama-example-model-management+ import: warnings, lang+ main-is: ModelManagement.hs+ hs-source-dirs: examples+ build-depends:+ base+ , ollama-haskell++executable ollama-example-all-features+ import: warnings, lang+ main-is: AllFeatures.hs+ hs-source-dirs: examples+ build-depends:+ base+ , ollama-haskell+ , text+ , time+
− src/Data/Ollama/Chat.hs
@@ -1,401 +0,0 @@-{-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RecordWildCards #-}--module Data.Ollama.Chat- ( -- * Chat APIs- chat- , chatJson- , Message (..)- , Role (..)- , defaultChatOps- , ChatOps (..)- , ChatResponse (..)- , Format (..)- , schemaFromType - ) where--import Control.Exception (try)-import Data.Aeson-import qualified Data.ByteString.Char8 as BS-import qualified Data.ByteString.Lazy.Char8 as BSL-import Data.List.NonEmpty as NonEmpty-import Data.Maybe (fromMaybe, isNothing)-import Data.Ollama.Common.Utils as CU-import Data.Ollama.Common.Types (Format(..))-import Data.Text (Text)-import qualified Data.Text as T-import qualified Data.Text.Encoding as T-import Data.Time (UTCTime)-import GHC.Generics-import GHC.Int (Int64)-import Network.HTTP.Client-import qualified Data.Aeson.KeyMap as HM---- | Enumerated roles that can participate in a chat.-data Role = System | User | Assistant | Tool- deriving (Show, Eq)--instance ToJSON Role where- toJSON System = String "system"- toJSON User = String "user"- toJSON Assistant = String "assistant"- toJSON Tool = String "tool"--instance FromJSON Role where- parseJSON (String "system") = pure System- parseJSON (String "user") = pure User- parseJSON (String "assistant") = pure Assistant- parseJSON (String "tool") = pure Tool- parseJSON _ = fail "Invalid Role value"---- TODO : Add tool_calls parameter---- | Represents a message within a chat, including its role and content.-data Message = Message- { role :: Role- -- ^ The role of the entity sending the message (e.g., 'User', 'Assistant').- , content :: Text- -- ^ The textual content of the message.- , images :: Maybe [Text]- -- ^ Optional list of base64 encoded images that accompany the message.- , tool_calls :: Maybe [Value]- -- ^ a list of tools in JSON that the model wants to use- -- ^ Since 0.1.3.0- }- deriving (Show, Eq, Generic, ToJSON, FromJSON)---- TODO: Add Options parameter-data ChatOps = ChatOps- { chatModelName :: Text- -- ^ The name of the chat model to be used.- , messages :: NonEmpty Message- -- ^ A non-empty list of messages forming the conversation context.- , tools :: Maybe [Value]- -- ^ Optional tools that may be used in the chat.- , format :: Maybe Format- -- ^ An optional format for the chat response (json or JSON schema).- -- ^ Since 0.1.3.0- , stream :: Maybe (ChatResponse -> IO (), IO ())- -- ^ Optional streaming functions where the first handles each chunk of the response, and the second flushes the stream.- , keepAlive :: Maybe Text- -- ^ Optional text to specify keep-alive behavior.- , hostUrl :: Maybe Text- -- ^ Override default Ollama host url. Default url = "http://127.0.0.1:11434"- , responseTimeOut :: Maybe Int- -- ^ Override default response timeout in minutes. Default = 15 minutes- , options :: Maybe Value- -- ^ additional model parameters listed in the documentation for the Modelfile such as temperature- -- ^ Since 0.1.3.0- }--instance Show ChatOps where- show (ChatOps {chatModelName = m, messages = ms, tools = t, format = f, keepAlive = ka}) =- let messagesStr = show (toList ms)- toolsStr = show t- formatStr = show f- keepAliveStr = show ka- in T.unpack m- ++ "\nMessages:\n"- ++ messagesStr- ++ "\n"- ++ toolsStr- ++ "\n"- ++ formatStr- ++ "\n"- ++ keepAliveStr--instance Eq ChatOps where- (==) a b =- chatModelName a == chatModelName b- && messages a == messages b- && tools a == tools b- && format a == format b- && keepAlive a == keepAlive b--data ChatResponse = ChatResponse- { model :: Text- -- ^ The name of the model that generated this response.- , createdAt :: UTCTime- -- ^ The timestamp when the response was created.- , message :: Maybe Message- -- ^ The message content of the response, if any.- , done :: Bool- -- ^ Indicates whether the chat process has completed.- , totalDuration :: Maybe Int64- -- ^ Optional total duration in milliseconds for the chat process.- , loadDuration :: Maybe Int64- -- ^ Optional load duration in milliseconds for loading the model.- , promptEvalCount :: Maybe Int64- -- ^ Optional count of prompt evaluations during the chat process.- , promptEvalDuration :: Maybe Int64- -- ^ Optional duration in milliseconds for evaluating the prompt.- , evalCount :: Maybe Int64- -- ^ Optional count of evaluations during the chat process.- , evalDuration :: Maybe Int64- -- ^ Optional duration in milliseconds for evaluations during the chat process.- }- deriving (Show, Eq)--instance ToJSON ChatOps where- toJSON (ChatOps model_ messages_ tools_ format_ stream_ keepAlive_ _ _ options) =- object- [ "model" .= model_- , "messages" .= messages_- , "tools" .= tools_- , "format" .= format_- , "stream" .= if isNothing stream_ then Just False else Just True- , "keep_alive" .= keepAlive_- , "options" .= options- ]--instance FromJSON ChatResponse where- parseJSON = withObject "ChatResponse" $ \v ->- ChatResponse- <$> v .: "model"- <*> v .: "created_at"- <*> v .: "message"- <*> v .: "done"- <*> v .:? "total_duration"- <*> v .:? "load_duration"- <*> v .:? "prompt_eval_count"- <*> v .:? "prompt_eval_duration"- <*> v .:? "eval_count"- <*> v .:? "eval_duration"--{- |-A default configuration for initiating a chat with a model.-This can be used as a starting point and modified as needed.--Example:--> let ops = defaultChatOps { chatModelName = "customModel" }-> chat ops--}-defaultChatOps :: ChatOps-defaultChatOps =- ChatOps- { chatModelName = "llama3.2"- , messages = Message User "What is 2+2?" Nothing Nothing :| []- , tools = Nothing- , format = Nothing- , stream = Nothing- , keepAlive = Nothing- , hostUrl = Nothing- , responseTimeOut = Nothing- , options = Nothing- }--{- |-Initiates a chat session with the specified 'ChatOps' configuration and returns either-a 'ChatResponse' or an error message.--This function sends a request to the Ollama chat API with the given options.--Example:--> let ops = defaultChatOps-> result <- chat ops-> case result of-> Left errorMsg -> putStrLn ("Error: " ++ errorMsg)-> Right response -> print response--To request a JSON format response:--> let ops = defaultChatOps { format = Just JsonFormat }-> result <- chat ops--To request a structured output with a JSON schema:--> import Data.Aeson (object, (.=))-> let ops = defaultChatOps { format = Just (SchemaFormat schema) }-> result <- chat ops--}-chat :: ChatOps -> IO (Either String ChatResponse)-chat cOps = do- let url = fromMaybe defaultOllamaUrl (hostUrl cOps)- responseTimeout = fromMaybe 15 (responseTimeOut cOps)- manager <-- newManager- defaultManagerSettings -- Setting response timeout to 5 minutes, since llm takes time- { managerResponseTimeout = responseTimeoutMicro (responseTimeout * 60 * 1000000)- }- eInitialRequest <- try $ parseRequest $ T.unpack (url <> "/api/chat") :: IO (Either HttpException Request)- case eInitialRequest of- Left e -> return $ Left $ "Failed to parse host url: " <> show e- Right initialRequest -> do- let reqBody = cOps- request =- initialRequest- { method = "POST"- , requestBody = RequestBodyLBS $ encode reqBody- }- eRes <-- try (withResponse request manager $ handleRequest cOps) ::- IO (Either HttpException (Either String ChatResponse))- case eRes of- Left e -> return $ Left $ "HTTP error occured: " <> show e- Right r -> do - return r--handleRequest :: ChatOps -> Response BodyReader -> IO (Either String ChatResponse)-handleRequest cOps response = do- let streamResponse sendChunk flush = do- bs <- brRead $ responseBody response- if BS.null bs- then putStrLn "" >> pure (Left "")- else do- let eRes = eitherDecode (BSL.fromStrict bs) :: Either String ChatResponse- case eRes of- Left e -> pure (Left e)- Right r -> do- _ <- sendChunk r- _ <- flush- if done r then pure (Left "") else streamResponse sendChunk flush- let genResponse op = do- bs <- brRead $ responseBody response- if BS.null bs- then do- let eRes = eitherDecode (BSL.fromStrict op) :: Either String ChatResponse- case eRes of- Left e -> pure (Left e)- Right r -> pure (Right r)- else genResponse (op <> bs)- 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.-- Note: This function predates the format parameter in the API. For new code, consider using- the `format` parameter with a SchemaFormat instead, which leverages the model's native- JSON output capabilities.-- 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 ->- -- | Haskell type that you want your result in- jsonResult ->- -- | Max retries- Maybe Int ->- IO (Either String jsonResult)-chatJson cOps@ChatOps {..} jsonStructure mMaxRetries = do- -- For models that support the format parameter, use that directly- --let jsonSchema = encode jsonStructure- let useNativeFormat = False -- Set to True to use the native format parameter when appropriate- - if useNativeFormat- then do- let formattedOps = cOps { format = Just (SchemaFormat (Object $ HM.fromList [("schema", Object $ HM.fromList [("type", String "object")])])) }- chatResponse <- chat formattedOps- 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 -> case decode (BSL.fromStrict . T.encodeUtf8 $ content res) of- Nothing -> return $ Left "Decoding Failed :("- Just resultInType -> return $ Right resultInType- else do- -- Fall back to the original implementation using prompts- 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---- | Helper function to create a JSON schema from a Haskell type-schemaFromType :: ToJSON a => a -> BSL.ByteString-schemaFromType = encode -- This is a simplified version; a real implementation would generate a JSON Schema--{- |- Example usage of 'Ollama.chat' with a JSON schema format and options field.-- The first example sends a message requesting a JSON response conforming to a given schema.- The second example uses an alternative JSON format (here, @JsonFormat@).-- >>> import Data.Aeson (Value, object, (.=))- >>> import Data.List.NonEmpty (NonEmpty(..))- >>> import Ollama (defaultChatOps, Message(..), SchemaFormat, JsonFormat)- >>> let x :: Value- ... x = object [ "type" .= ("object" :: String)- ... , "properties" .= object [ "age" .= object ["type" .= ("integer" :: String)] ]- ... ]- >>> let msg = Message User "Ollama is 22 years old and is busy saving the world. Respond using JSON" Nothing- >>> let opts = object ["option" .= ("some value" :: String)]- >>> res <- chat defaultChatOps- ... { chatModelName = "llama3.2"- ... , messages = msg :| []- ... , format = Just (SchemaFormat x)- ... , options = opts- ... }- >>> print (message res)- Just (Message {role = Assistant, content = "{\n \"age\": 22\n}", images = Nothing})- >>> res2 <- chat defaultChatOps- ... { chatModelName = "llama3.2"- ... , messages = msg :| []- ... , format = Just JsonFormat- ... , options = object ["option" .= ("other value" :: String)]- ... }- >>> print (message res2)- Just (Message {role = Assistant, content = "{ \"Name\": \"Ollama\", \"Age\": 22, \"Occupation\": \"World Savior\", \"Goals\": [\"Save humanity from alien invasion\", \"Unite warring nations\", \"Protect the environment\"] }", images = Nothing})--}
− src/Data/Ollama/Common/Types.hs
@@ -1,64 +0,0 @@-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-}--module Data.Ollama.Common.Types- ( ModelDetails (..)- , OllamaClient (..)- , Format (..)- ) where--import Data.Aeson-import Data.Text (Text)--data ModelDetails = ModelDetails- { parentModel :: Maybe Text- , format :: Text- , familiy :: Text- , families :: [Text]- , parameterSize :: Text- , quantizationLevel :: Text- }- deriving (Eq, Show)--instance FromJSON ModelDetails where- parseJSON = withObject "ModelDetails" $ \v ->- ModelDetails- <$> v .: "parent_model"- <*> v .: "format"- <*> v .: "family"- <*> v .:? "families" .!= []- <*> v .: "parameter_size"- <*> v .: "quantization_level"--newtype OllamaClient = OllamaClient- { host :: Text- }- deriving (Eq, Show)---{-|-E.g SchemaFormat-{- "type": "object",- "properties": {- "age": {- "type": "integer"- },- "available": {- "type": "boolean"- }- },- "required": [- "age",- "available"- ]- }-|-}--- | Format specification for the chat output--- | Since 0.1.3.0-data Format = JsonFormat | SchemaFormat Value- deriving (Show, Eq)--instance ToJSON Format where- toJSON JsonFormat = String "json"- toJSON (SchemaFormat schema) = schema
− src/Data/Ollama/Common/Utils.hs
@@ -1,45 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--module Data.Ollama.Common.Utils (defaultOllamaUrl, 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--defaultOllamaUrl :: Text-defaultOllamaUrl = "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/Copy.hs
@@ -1,55 +0,0 @@-{-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-}--module Data.Ollama.Copy- ( -- * Copy Model API- copyModel- ) where--import Control.Monad (when)-import Data.Aeson-import Data.Ollama.Common.Utils qualified as CU-import Data.Text (Text)-import Data.Text qualified as T-import GHC.Generics-import Network.HTTP.Client-import Network.HTTP.Types.Status (status404)---- TODO: Add Options parameter--- TODO: Add Context parameter-data CopyModelOps = CopyModelOps- { source :: Text- , destination :: Text- }- deriving (Show, Eq, Generic, ToJSON)---- | Copy model from source to destination-copyModel ::- -- | Source model- Text ->- -- | Destination model- Text ->- IO ()-copyModel- source_- destination_ =- do- let url = CU.defaultOllamaUrl- manager <- newManager defaultManagerSettings- initialRequest <- parseRequest $ T.unpack (url <> "/api/copy")- let reqBody =- CopyModelOps- { source = source_- , destination = destination_- }- request =- initialRequest- { method = "POST"- , requestBody = RequestBodyLBS $ encode reqBody- }- response <- httpLbs request manager- when- (responseStatus response == status404)- (putStrLn "Source Model does not exist")
− src/Data/Ollama/Create.hs
@@ -1,120 +0,0 @@-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-}--module Data.Ollama.Create- ( -- * Create Model API- createModel- , createModelOps- ) where--import Control.Monad (unless)-import Data.Aeson-import Data.ByteString.Lazy.Char8 qualified as BSL-import Data.Ollama.Common.Utils as CU-import Data.Text (Text)-import Data.Text qualified as T-import Data.Text.IO qualified as T-import Network.HTTP.Client---- TODO: Add Options parameter--- TODO: Add Context parameter-data CreateModelOps = CreateModelOps- { name :: Text- , modelFile :: Maybe Text- , stream :: Maybe Bool- , path :: Maybe FilePath- }- deriving (Show, Eq)---- TODO: Add Context Param-newtype CreateModelResp = CreateModelResp {status :: Text}- deriving (Show, Eq)--instance ToJSON CreateModelOps where- toJSON- ( CreateModelOps- name_- modelFile_- stream_- path_- ) =- object- [ "name" .= name_- , "modelfile" .= modelFile_- , "stream" .= stream_- , "path" .= path_- ]--instance FromJSON CreateModelResp where- parseJSON = withObject "CreateModelResp" $ \v ->- CreateModelResp- <$> v .: "status"--{- | Create a new model either from ModelFile or Path-Please note, if you specify both ModelFile and Path, ModelFile will be used.--}-createModelOps ::- -- | Model Name- Text ->- -- | Model File- Maybe Text ->- -- | Stream- Maybe Bool ->- -- | Path- Maybe FilePath ->- IO ()-createModelOps- modelName- modelFile_- stream_- path_ =- do- let url = defaultOllamaUrl- manager <- newManager defaultManagerSettings- initialRequest <- parseRequest $ T.unpack (url <> "/api/create")- let reqBody =- CreateModelOps- { name = modelName- , modelFile = modelFile_- , stream = stream_- , path = path_- }- request =- initialRequest- { method = "POST"- , requestBody = RequestBodyLBS $ encode reqBody- }- withResponse request manager $ \response -> do- let go = do- bs <- brRead $ responseBody response- let eRes =- eitherDecode (BSL.fromStrict bs) ::- Either String CreateModelResp- case eRes of- Left err -> do- putStrLn $ "Error: " <> err- Right res -> do- unless- (status res /= "success")- ( do- T.putStr $ status res- go- )- go--{- | Create a new model-| Please note, if you specify both ModelFile and Path, ModelFile will be used.--}-createModel ::- -- | Model Name- Text ->- -- | Model File- Maybe Text ->- -- | Path- Maybe FilePath ->- IO ()-createModel modelName modelFile_ =- createModelOps- modelName- modelFile_- Nothing
− src/Data/Ollama/Delete.hs
@@ -1,44 +0,0 @@-{-# LANGUAGE DerivingStrategies #-}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE GeneralizedNewtypeDeriving #-}-{-# LANGUAGE OverloadedStrings #-}--module Data.Ollama.Delete- ( -- * Delete downloaded Models- deleteModel- ) where--import Control.Monad (when)-import Data.Aeson-import Data.Ollama.Common.Utils qualified as CU-import Data.Text (Text)-import Data.Text qualified as T-import Network.HTTP.Client-import Network.HTTP.Types.Status (status404)---- TODO: Add Options parameter--- TODO: Add Context parameter-newtype DeleteModelReq = DeleteModelReq {name :: Text}- deriving newtype (Show, Eq, ToJSON)---- | Delete a model-deleteModel ::- -- | Model name- Text ->- IO ()-deleteModel modelName =- do- let url = CU.defaultOllamaUrl- manager <- newManager defaultManagerSettings- initialRequest <- parseRequest $ T.unpack (url <> "/api/delete")- let reqBody =- DeleteModelReq {name = modelName}- request =- initialRequest- { method = "DELETE"- , requestBody = RequestBodyLBS $ encode reqBody- }- response <- httpLbs request manager- when- (responseStatus response == status404)- (putStrLn "Model does not exist")
− src/Data/Ollama/Embeddings.hs
@@ -1,102 +0,0 @@-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-}--module Data.Ollama.Embeddings- ( -- * Embedding API- embedding- , embeddingOps- , EmbeddingOps (..)- , EmbeddingResp (..)- ) where--import Data.Aeson-import Data.Ollama.Common.Utils as CU-import Data.Text (Text)-import Data.Text qualified as T-import Network.HTTP.Client-import Control.Exception (try)-import Data.ByteString.Lazy.Char8 (ByteString)---- TODO: Add Options parameter-data EmbeddingOps = EmbeddingOps- { model :: Text- , input :: Text- , truncate :: Maybe Bool- , keepAlive :: Maybe Text- }- deriving (Show, Eq)--data EmbeddingResp = EmbeddingResp- { model :: Text- , embedding_ :: [[Float]]- }- deriving (Show, Eq)--instance FromJSON EmbeddingResp where- parseJSON = withObject "EmbeddingResp" $ \v -> EmbeddingResp- <$> v .: "model"- <*> v .: "embeddings"--instance ToJSON EmbeddingOps where- toJSON (EmbeddingOps model_ input_ truncate' keepAlive_) =- object- [ "model" .= model_- , "input" .= input_- , "truncate" .= truncate'- , "keep_alive" .= keepAlive_- ]---- TODO: Add Options parameter---- | Embedding API-embeddingOps ::- -- | Model- Text ->- -- | Input- Text ->- -- | Truncate- Maybe Bool ->- -- | Keep Alive- Maybe Text ->- IO (Either String EmbeddingResp)-embeddingOps modelName input_ mTruncate mKeepAlive = do- let url = defaultOllamaUrl- manager <- newManager defaultManagerSettings- --einitialRequest <- parseRequest $ T.unpack (url <> "/api/embed")- eInitialRequest <-- try $ parseRequest $ T.unpack (url <> "/api/embed") :: IO (Either HttpException Request)- case eInitialRequest of- Left e -> do- return $ Left $ show e- Right initialRequest -> do- let reqBody =- EmbeddingOps- { model = modelName- , input = input_- , truncate = mTruncate- , keepAlive = mKeepAlive- }- request =- initialRequest- { method = "POST"- , requestBody = RequestBodyLBS $ encode reqBody- }- eResp <- try $ httpLbs request manager :: IO (Either HttpException (Response ByteString))- case eResp of- Left err -> return $ Left (show err)- Right resp -> - case decode (responseBody resp) of- Nothing -> return $ Left $ "Couldn't decode response: " <> show (responseBody resp)- Just r -> return $ Right r---- Higher level binding that only takes important params---- | Embedding API-embedding ::- -- | Model- Text ->- -- | Input- Text ->- IO (Either String EmbeddingResp)-embedding modelName input_ =- embeddingOps modelName input_ Nothing Nothing
− src/Data/Ollama/Generate.hs
@@ -1,410 +0,0 @@-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RecordWildCards #-}--module Data.Ollama.Generate- ( -- * Generate Texts- generate- , defaultGenerateOps- , generateJson- , GenerateOps (..)- , GenerateResponse (..)- ) where--import Control.Exception (try)-import Data.Aeson-import Data.ByteString.Char8 qualified as BS-import Data.ByteString.Lazy.Char8 qualified as BSL-import Data.Maybe-import Data.Ollama.Common.Utils as CU-import Data.Ollama.Common.Types (Format(..))-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---- TODO: Add Options parameter--- TODO: Add Context parameter--{- |- 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- > { modelName = "llama3.2"- > , prompt = "What is the meaning of life?"- > , suffix = Nothing- > , images = Nothing- > , format = Just "text"- > , system = Nothing- > , template = Nothing- > , stream = Nothing- > , raw = Just False- > , keepAlive = Just "yes"- > }--}-data GenerateOps = GenerateOps- { modelName :: Text- -- ^ The name of the model to be used for generation.- , prompt :: Text- -- ^ The prompt text that will be provided to the model for generating a response.- , suffix :: Maybe Text- -- ^ An optional suffix to append to the generated text.- , images :: Maybe [Text]- -- ^ Optional list of base64 encoded images to include with the request.- , format :: Maybe Format- -- ^ An optional format specifier for the response.- -- ^ Since 0.1.3.0- , system :: Maybe Text- -- ^ Optional system text that can be included in the generation context.- , template :: Maybe Text- -- ^ An optional template to format the response.- , stream :: Maybe (GenerateResponse -> IO (), IO ())- -- ^ An optional streaming function where the first function handles each chunk of response, and the second flushes the stream.- , raw :: Maybe Bool- -- ^ An optional flag to return the raw response.- , keepAlive :: Maybe Text- -- ^ Optional text to specify keep-alive behavior.- , hostUrl :: Maybe Text- -- ^ Override default Ollama host url. Default url = "http://127.0.0.1:11434"- , responseTimeOut :: Maybe Int- -- ^ Override default response timeout in minutes. Default = 15 minutes- , options :: Maybe Value- -- ^ additional model parameters listed in the documentation for the Modelfile such as temperature- -- ^ Since 0.1.3.0- }--instance Show GenerateOps where- show GenerateOps {..} =- "GenerateOps { "- <> "model : "- <> T.unpack modelName- <> ", prompt : "- <> T.unpack prompt- <> ", suffix : "- <> show suffix- <> ", images : "- <> show images- <> ", format : "- <> show format- <> ", system : "- <> show system- <> ", template : "- <> show template- <> ", stream : "- <> "Stream functions"- <> ", raw : "- <> show raw- <> ", keepAlive : "- <> show keepAlive- <> ", options : "- <> show options--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- && options a == options b---- TODO: Add Context Param--{- |-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.- , createdAt :: UTCTime- -- ^ The timestamp when the response was created.- , response_ :: Text- -- ^ The generated response from the model.- , done :: Bool- -- ^ A flag indicating whether the generation process is complete.- , totalDuration :: Maybe Int64- -- ^ Optional total duration in milliseconds for the generation process.- , loadDuration :: Maybe Int64- -- ^ Optional load duration in milliseconds for loading the model.- , promptEvalCount :: Maybe Int64- -- ^ Optional count of prompt evaluations during the generation process.- , promptEvalDuration :: Maybe Int64- -- ^ Optional duration in milliseconds for evaluating the prompt.- , evalCount :: Maybe Int64- -- ^ Optional count of evaluations during the generation process.- , evalDuration :: Maybe Int64- -- ^ Optional duration in milliseconds for evaluations during the generation process.- }- deriving (Show, Eq)--instance ToJSON GenerateOps where- toJSON- ( GenerateOps- model- prompt- suffix- images- format- system- template- stream- raw- keepAlive- _ -- Host url- _ -- Response timeout- options - ) =- object- [ "model" .= model- , "prompt" .= prompt- , "suffix" .= suffix- , "images" .= images- , "format" .= format- , "system" .= system- , "template" .= template- , "stream" .= if isNothing stream then Just False else Just True- , "raw" .= raw- , "keep_alive" .= keepAlive- , "options" .= options- ]--instance FromJSON GenerateResponse where- parseJSON = withObject "GenerateResponse" $ \v ->- GenerateResponse- <$> v .: "model"- <*> v .: "created_at"- <*> v .: "response"- <*> v .: "done"- <*> v .:? "total_duration"- <*> v .:? "load_duration"- <*> v .:? "prompt_eval_count"- <*> v .:? "prompt_eval_duration"- <*> 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.--}-defaultGenerateOps :: GenerateOps-defaultGenerateOps =- GenerateOps- { modelName = "llama3.2"- , prompt = "what is 2+2"- , suffix = Nothing- , images = Nothing- , format = Nothing- , system = Nothing- , template = Nothing- , stream = Nothing- , raw = Nothing- , keepAlive = Nothing- , hostUrl = Nothing- , responseTimeOut = Nothing- , options = 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 :: GenerateOps -> IO (Either String GenerateResponse)-generate genOps = do- let url = fromMaybe defaultOllamaUrl (hostUrl genOps)- responseTimeout = fromMaybe 15 (responseTimeOut genOps)- manager <-- newManager -- Setting response timeout to 5 minutes, since llm takes time- defaultManagerSettings- { managerResponseTimeout = responseTimeoutMicro (responseTimeout * 60 * 1000000)- }- eInitialRequest <-- try $ parseRequest $ T.unpack (url <> "/api/generate") :: IO (Either HttpException Request)- case eInitialRequest of- Left e -> do- return $ Left $ show e- Right initialRequest -> do- let reqBody = genOps- request =- initialRequest- { method = "POST"- , requestBody = RequestBodyLBS $ encode reqBody- }- eRes <-- try (withResponse request manager $ handleRequest genOps) ::- IO (Either HttpException (Either String GenerateResponse))- case eRes of- Left e -> do- return $ Left $ "HTTP error occured: " <> show e- Right r -> return r--handleRequest :: GenerateOps -> Response BodyReader -> IO (Either String GenerateResponse)-handleRequest genOps response = do- let streamResponse sendChunk flush = do- bs <- brRead $ responseBody response- if BS.null bs- then putStrLn "" >> pure (Left "")- else do- let eRes = eitherDecode (BSL.fromStrict bs) :: Either String GenerateResponse- case eRes of- Left e -> pure (Left e)- Right r -> do- _ <- sendChunk r- _ <- flush- if done r then pure (Right r) else streamResponse sendChunk flush- let genResponse op = do- bs <- brRead $ responseBody response- if bs == ""- then do- let eRes0 = eitherDecode (BSL.fromStrict op) :: Either String GenerateResponse- case eRes0 of- Left e -> pure (Left e)- Right r -> pure (Right r)- else genResponse (op <> bs)- 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---{- |- Example usage of 'Ollama.generate' with a JSON schema format and options field.-- In this example we pass a JSON schema that expects an object with an integer field @age@.- The options field is supplied as a JSON value.-- >>> import Data.Aeson (Value, object, (.=))- >>> import Ollama (GenerateOps, defaultGenerateOps, SchemaFormat)- >>> let x :: Value- ... x = object [ "type" .= ("object" :: String)- ... , "properties" .= object [ "age" .= object ["type" .= ("integer" :: String)] ]- ... ]- >>> let opts :: Value- ... opts = object ["option" .= ("some value" :: String)]- >>> generate defaultGenerateOps- ... { modelName = "llama3.2"- ... , prompt = "Ollama is 22 years old and is busy saving the world. Respond using JSON"- ... , format = Just (SchemaFormat x)- ... , options = opts- ... }- Right (GenerateResponse {model = "llama3.2", createdAt = 2025-03-25 09:34:15.853417157 UTC,- response_ = "{\n \"age\": 22\n}", done = True,- totalDuration = Just 6625631744, loadDuration = Just 2578791966,- promptEvalCount = Just 43, promptEvalDuration = Just 2983000000,- evalCount = Just 10, evalDuration = Just 1061000000})--}-
− src/Data/Ollama/List.hs
@@ -1,59 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--module Data.Ollama.List- ( -- * List Models API- list- , Models (..)- , ModelInfo (..)- )-where--import Data.Aeson-import Data.Ollama.Common.Types as CT-import Data.Ollama.Common.Utils as CU-import Data.Text (Text)-import Data.Text qualified as T-import Data.Time-import GHC.Int (Int64)-import Network.HTTP.Client-import Network.HTTP.Types.Status (statusCode)--newtype Models = Models [ModelInfo]- deriving (Eq, Show)--data ModelInfo = ModelInfo- { name :: Text- , modifiedAt :: UTCTime- , size :: Int64- , digest :: Text- , details :: ModelDetails- }- deriving (Eq, Show)---- Instances-instance FromJSON Models where- parseJSON = withObject "Models" $ \v -> Models <$> v .: "models"--instance FromJSON ModelInfo where- parseJSON = withObject "ModelInfo" $ \v ->- ModelInfo- <$> v .: "name"- <*> v .: "modified_at"- <*> v .: "size"- <*> v .: "digest"- <*> v .: "details"---- | List all models from local-list :: IO (Maybe Models)-list = do- let url = defaultOllamaUrl- manager <- newManager defaultManagerSettings- request <- parseRequest $ T.unpack (url <> "/api/tags")- response <- httpLbs request manager- if statusCode (responseStatus response) /= 200- then pure Nothing- else do- let res = decode (responseBody response) :: Maybe Models- case res of- Nothing -> pure Nothing- Just l -> pure $ Just l
− src/Data/Ollama/Ps.hs
@@ -1,61 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--module Data.Ollama.Ps- ( ps- , RunningModels (..)- , RunningModel (..)- ) where--import Data.Aeson-import Data.Ollama.Common.Types as CT-import Data.Ollama.Common.Utils as CU-import Data.Text (Text)-import Data.Text qualified as T-import Data.Time-import GHC.Int (Int64)-import Network.HTTP.Client-import Network.HTTP.Types.Status (statusCode)---- Types for Ps API-newtype RunningModels = RunningModels [RunningModel]- deriving (Eq, Show)--data RunningModel = RunningModel- { name_ :: Text- , modelName :: Text- , size_ :: Int64- , modelDigest :: Text- , modelDetails :: ModelDetails- , expiresAt :: UTCTime- , sizeVRam :: Int64- }- deriving (Eq, Show)--instance FromJSON RunningModels where- parseJSON = withObject "Models" $ \v -> RunningModels <$> v .: "models"--instance FromJSON RunningModel where- parseJSON = withObject "RunningModel" $ \v ->- RunningModel- <$> v .: "name"- <*> v .: "model"- <*> v .: "size"- <*> v .: "digest"- <*> v .: "details"- <*> v .: "expires_at"- <*> v .: "size_vram"---- | List running models-ps :: IO (Maybe RunningModels)-ps = do- let url = defaultOllamaUrl- manager <- newManager defaultManagerSettings- request <- parseRequest $ T.unpack (url <> "/api/ps")- response <- httpLbs request manager- if statusCode (responseStatus response) /= 200- then pure Nothing- else do- let res = decode (responseBody response) :: Maybe RunningModels- case res of- Nothing -> pure Nothing- Just l -> pure $ Just l
− src/Data/Ollama/Pull.hs
@@ -1,114 +0,0 @@-{-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-}--module Data.Ollama.Pull- ( -- * Downloaded Models- pull- , pullOps- ) where--import Data.Aeson-import Data.ByteString.Lazy.Char8 qualified as BSL-import Data.Maybe (fromMaybe)-import Data.Ollama.Common.Utils as CU-import Data.Text (Text)-import Data.Text qualified as T-import GHC.Generics-import GHC.Int (Int64)-import Network.HTTP.Client---- TODO: Add Options parameter---- | Configuration options for pulling a model.-data PullOps = PullOps- { name :: Text- -- ^ The name of the model to pull.- , insecure :: Maybe Bool- -- ^ Option to allow insecure connections.- -- If set to 'Just True', the pull operation will allow insecure connections.- , stream :: Maybe Bool- -- ^ Option to enable streaming of the download.- -- If set to 'Just True', the download will be streamed.- }- deriving (Show, Eq, Generic, ToJSON)---- | Response data from a pull operation.-data PullResp = PullResp- { status :: Text- -- ^ The status of the pull operation, e.g., "success" or "failure".- , digest :: Maybe Text- -- ^ The digest of the model, if available.- , total :: Maybe Int64- -- ^ The total size of the model in bytes, if available.- , completed :: Maybe Int64- -- ^ The number of bytes completed, if available.- }- deriving (Show, Eq, Generic, FromJSON)--{- |-Pull a model with additional options for insecure connections and streaming.-This function interacts directly with the Ollama API to download the specified model.--Example:--> pullOps "myModel" (Just True) (Just True)--This will attempt to pull "myModel" with insecure connections allowed and enable streaming.--}-pullOps ::- -- | Model Name- Text ->- -- | Insecure- Maybe Bool ->- -- | Stream- Maybe Bool ->- IO ()-pullOps modelName mInsecure mStream = do- let url = defaultOllamaUrl- manager <- newManager defaultManagerSettings- initialRequest <- parseRequest $ T.unpack (url <> "/api/pull")- let reqBody =- PullOps- { name = modelName- , insecure = mInsecure- , stream = mStream- }- request =- initialRequest- { method = "POST"- , requestBody = RequestBodyLBS $ encode reqBody- }- withResponse request manager $ \response -> do- let go = do- bs <- brRead $ responseBody response- let eRes = decode (BSL.fromStrict bs) :: Maybe PullResp- case eRes of- Nothing -> putStrLn "Something went wrong"- Just res -> do- if status res /= "success"- then do- let completed' = fromMaybe 0 (completed res)- let total' = fromMaybe 0 (total res)- putStrLn $ "Remaining bytes: " <> show (total' - completed')- go- else do- putStrLn "Completed"- go--{- |-Pull a model using default options. This simplifies the pull operation by-not requiring additional options.--Example:--> pull "myModel"--This will pull "myModel" using default settings (no insecure connections and no streaming).--}-pull ::- -- | Model Name- Text ->- IO ()-pull modelName = pullOps modelName Nothing Nothing
− src/Data/Ollama/Push.hs
@@ -1,85 +0,0 @@-{-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-}--module Data.Ollama.Push- ( -- * Push API- push- , pushOps- ) where--import Data.Aeson-import Data.ByteString.Lazy.Char8 qualified as BSL-import Data.Maybe (fromMaybe)-import Data.Ollama.Common.Utils as CU-import Data.Text (Text)-import Data.Text qualified as T-import GHC.Generics-import GHC.Int (Int64)-import Network.HTTP.Client---- TODO: Add Options parameter-data PushOps = PushOps- { name :: Text- , insecure :: Maybe Bool- , stream :: Maybe Bool- }- deriving (Show, Eq, Generic, ToJSON)--data PushResp = PushResp- { status :: Text- , digest :: Maybe Text- , total :: Maybe Int64- }- deriving (Show, Eq, Generic, FromJSON)---- | Push a model with options-pushOps ::- -- | Model name- Text ->- -- | Insecure- Maybe Bool ->- -- | Stream- Maybe Bool ->- IO ()-pushOps modelName mInsecure mStream = do- let url = defaultOllamaUrl- manager <- newManager defaultManagerSettings- initialRequest <- parseRequest $ T.unpack (url <> "/api/push")- let reqBody =- PushOps- { name = modelName- , insecure = mInsecure- , stream = mStream- }- request =- initialRequest- { method = "POST"- , requestBody = RequestBodyLBS $ encode reqBody- }- withResponse request manager $ \response -> do- let go = do- bs <- brRead $ responseBody response- let eRes = decode (BSL.fromStrict bs) :: Maybe PushResp- case eRes of- Nothing -> putStrLn "Something went wrong"- Just res -> do- if status res /= "success"- then do- let total' = fromMaybe 0 (total res)- putStrLn $ "Remaining bytes: " <> show total'- go- else do- putStrLn "Completed"- go---- Higher level API for Pull--- This API is untested. Will test soon!---- | Push a model-push ::- -- | Model name- Text ->- IO ()-push modelName = pushOps modelName Nothing Nothing
− src/Data/Ollama/Show.hs
@@ -1,175 +0,0 @@-{-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-}--module Data.Ollama.Show- ( -- * Show Model Info API- showModel- , showModelOps- , ShowModelResponse (..)- ) where--import Data.Aeson-import Data.Ollama.Common.Utils qualified as CU-import Data.Text (Text)-import Data.Text qualified as T-import GHC.Generics-import GHC.Int (Int64)-import Network.HTTP.Client---- TODO: Add Options parameter--- TODO: Add Context parameter--{- |- #ShowModelOps#- Input parameters for show model information.--}-data ShowModelOps = ShowModelOps- { name :: Text- , verbose :: Maybe Bool- }- deriving (Show, Eq, Generic, ToJSON)--{- |- #ShowModelResponse#-- Ouput structure for show model information.--}-data ShowModelResponse = ShowModelResponse- { modelFile :: Text- , parameters :: Text- , template :: Text- , details :: ModelDetails- , modelInfo :: ModelInfo- }- deriving (Show, Eq)--data ModelDetails = ModelDetails- { parentModel :: Text- , format :: Text- , familiy :: Text- , families :: [Text]- , parameterSize :: Text- , quantizationLevel :: Text- }- deriving (Show, Eq)--data ModelInfo = ModelInfo- { generalArchitecture :: Maybe Text- , generalFileType :: Maybe Int- , generalParameterCount :: Maybe Int64- , generalQuantizationVersion :: Maybe Int- , llamaAttentionHeadCount :: Maybe Int- , llamaAttentionHeadCountKV :: Maybe Int- , llamaAttentionLayerNormRMSEpsilon :: Maybe Float- , llamaBlockCount :: Maybe Int- , llamaContextLength :: Maybe Int- , llamaEmbeddingLength :: Maybe Int- , llamaFeedForwardLength :: Maybe Int- , llamaRopeDimensionCount :: Maybe Int- , llamaRopeFreqBase :: Maybe Int64- , llamaVocabSize :: Maybe Int64- , tokenizerGgmlBosToken_id :: Maybe Int- , tokenizerGgmlEosToken_id :: Maybe Int- , tokenizerGgmlMerges :: Maybe [Text]- , tokenizerGgmlMode :: Maybe Text- , tokenizerGgmlPre :: Maybe Text- , tokenizerGgmlTokenType :: Maybe [Text]- , tokenizerGgmlTokens :: Maybe [Text]- }- deriving (Show, Eq)---- FromJSON instances---- | The instance for show model response-instance FromJSON ShowModelResponse where- parseJSON = withObject "ShowModelResponse" $ \v ->- ShowModelResponse- <$> v .: "modelfile"- <*> v .: "parameters"- <*> v .: "template"- <*> v .: "details"- <*> v .: "model_info"--instance FromJSON ModelDetails where- parseJSON = withObject "ModelDetails" $ \v ->- ModelDetails- <$> v .: "parent_model"- <*> v .: "format"- <*> v .: "family"- <*> v .: "families"- <*> v .: "parameter_size"- <*> v .: "quantization_level"--instance FromJSON ModelInfo where- parseJSON = withObject "ModelInfo" $ \v ->- ModelInfo- <$> v .:? "general.architecture"- <*> v .:? "general.file_type"- <*> v .:? "general.parameter_count"- <*> v .:? "general.quantization_version"- <*> v .:? "llama.attention.head_count"- <*> v .:? "llama.attention.head_count_kv"- <*> v .:? "llama.attention.layer_norm_rms_epsilon"- <*> v .:? "llama.block_count"- <*> v .:? "llama.context_length"- <*> v .:? "llama.embedding_length"- <*> v .:? "llama.feed_forward_length"- <*> v .:? "llama.rope.dimension_count"- <*> v .:? "llama.rope.freq_base"- <*> v .:? "llama.vocab_size"- <*> v .:? "tokenizer.ggml.bos_token_id"- <*> v .:? "tokenizer.ggml.eos_token_id"- <*> v .:? "tokenizer.ggml.merges"- <*> v .:? "tokenizer.ggml.model"- <*> v .:? "tokenizer.ggml.pre"- <*> v .:? "tokenizer.ggml.token_type"- <*> v .:? "tokenizer.ggml.tokens"--{- | Show given model's information with options.--@since 1.0.0.0--}-showModelOps ::- -- | model name- Text ->- -- | verbose- Maybe Bool ->- IO (Maybe ShowModelResponse)-showModelOps- modelName- verbose_ =- do- let url = CU.defaultOllamaUrl- manager <- newManager defaultManagerSettings- initialRequest <- parseRequest $ T.unpack (url <> "/api/show")- let reqBody =- ShowModelOps- { name = modelName- , verbose = verbose_- }- request =- initialRequest- { method = "POST"- , requestBody = RequestBodyLBS $ encode reqBody- }- response <- httpLbs request manager- let eRes =- eitherDecode (responseBody response) ::- Either String ShowModelResponse- case eRes of- Left _ -> pure Nothing- Right r -> pure $ Just r--{- | Show given model's information.--Higher level API for show.-@since 1.0.0.0--}-showModel ::- -- | model name- Text ->- IO (Maybe ShowModelResponse)-showModel modelName =- showModelOps modelName Nothing
src/Ollama.hs view
@@ -1,94 +1,197 @@-{-# LANGUAGE DuplicateRecordFields #-} {- |- #Ollama-Haskell#- This library lets you run LlMs from within Haskell projects. Inspired by `ollama-python`.+Module : Ollama+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Top-level umbrella re-export module for the Ollama Haskell client library.++== Quick Example++@+import Ollama++main :: IO ()+main = do+ client <- defaultClient+ let req = chatRequest "llama3.2" (userMessage "Why is the sky blue?" :| [])+ result <- chat client req+ case result of+ Left err -> print err+ Right resp -> case crMessage resp of+ Just msg -> putStrLn (messageContent msg)+ Nothing -> putStrLn "No message returned"+@++@since 1.0.0.0 -}-module Ollama- ( -- * Main APIs+module Ollama (+ -- * Client+ OllamaClient,+ newClient,+ defaultClient,+ clientFromEnv,+ closeClient,+ withClient, - -- ** Generate Texts- generate- , generateJson- , defaultGenerateOps- , GenerateOps (..)- , GenerateResponse (..)+ -- * Config & Retry+ OllamaClientConfig (..),+ defaultConfig,+ RetryPolicy (..),+ LogLevel (..), - -- ** Chat with LLMs- , chat- , chatJson- , Role (..)- , defaultChatOps- , ChatResponse (..)- , ChatOps (..)+ -- * API Endpoints - -- ** Embeddings- , embedding- , embeddingOps- , EmbeddingOps (..)- , EmbeddingResp (..)+ -- ** Chat+ chat,+ chatStream,+ ChatRequest (..),+ ChatResponse (..),+ chatRequest,+ chatEvalTokensPerSecond,+ chatPromptEvalTokensPerSecond, - -- * Ollama operations+ -- ** Generate+ generate,+ generateStream,+ GenerateRequest (..),+ GenerateResponse (..),+ generateRequest,+ evalTokensPerSecond,+ promptEvalTokensPerSecond, - -- ** Copy Models- , copyModel+ -- ** Embeddings+ embed,+ EmbedRequest (..),+ EmbedResponse (..),+ embedRequest,+ embeddings,+ EmbeddingsRequest (..),+ EmbeddingsResponse (..), - -- ** Create Models- , createModel- , createModelOps+ -- ** Model Management+ listModels,+ showModel,+ copyModel,+ deleteModel,+ ListResponse (..),+ ModelInfo (..),+ RunningModel (..),+ ShowResponse (..), - -- ** Delete Models- , deleteModel+ -- ** Create+ createModel,+ createModelStream,+ defaultCreateRequest,+ CreateRequest (..),+ CreateResponse (..),+ QuantizationType (..), - -- ** List Models- , list+ -- ** Pull & Push+ pull,+ pullStream,+ push,+ pushStream,+ PullResponse (..),+ PushResponse (..), - -- ** List currently running models- , ps+ -- ** Blobs+ checkBlob,+ pushBlob, - -- ** Push and Pull- , push- , pushOps- , pull- , pullOps+ -- ** System+ getVersion,+ listRunning,+ RunningModelsResponse (..), - -- ** Show Model Info- , showModel- , showModelOps+ -- * Types & Primitives+ ModelName (..),+ mkModelName,+ Digest (..),+ Base64Image (..),+ Duration (..),+ durationToSeconds,+ durationToMillis,+ tokensPerSecond,+ Version (..),+ Think (..),+ ThinkingLevel (..), - -- * Types- , ShowModelResponse (..)- , Models (..)- , ModelInfo (..)- , RunningModels (..)- , RunningModel (..)- , Message (..)- , Format(..)- )-where+ -- ** Messages+ Role (..),+ Message (..),+ userMessage,+ systemMessage,+ assistantMessage,+ toolMessage,+ toolResultMessage,+ imageMessage, -import Data.Ollama.Chat- ( ChatOps (..)- , ChatResponse (..)- , Message (..)- , Role (..)- , chat- , chatJson- , defaultChatOps- )-import Data.Ollama.Copy (copyModel)-import Data.Ollama.Create (createModel, createModelOps)-import Data.Ollama.Delete (deleteModel)-import Data.Ollama.Embeddings (embedding, embeddingOps, EmbeddingOps (..), EmbeddingResp (..))-import Data.Ollama.Generate- ( GenerateOps (..)- , GenerateResponse (..)- , defaultGenerateOps- , generate- , generateJson- )-import Data.Ollama.List (ModelInfo (..), Models (..), list)-import Data.Ollama.Ps (RunningModel (..), RunningModels (..), ps)-import Data.Ollama.Pull (pull, pullOps)-import Data.Ollama.Push (push, pushOps)-import Data.Ollama.Show (ShowModelResponse (..), showModel, showModelOps)-import Data.Ollama.Common.Types (Format (..))+ -- ** Tools & Functions+ Tool (..),+ FunctionDef (..),+ FunctionParameters (..),+ ToolCall (..),+ ToolCallFunction (..),++ -- ** Options & Format+ ModelOptions (..),+ defaultOptions,+ Format (..),+ ToSchema (..),+ ToJsonType (..),+ schemaFor,+ formatFor,++ -- * Error Handling+ OllamaError (..),+ isRetryable,+ throwOllama,++ -- * Streaming+ HasDone (..),+ collectStream,+ foldStream,++ -- * Testing Infrastructure+ newMockClient,+ withMockClient,+ mockGenerateResponse,+ mockChatResponse,+ mockEmbedResponse,+ mockListModelsResponse,++ -- * Conversation Store+ Conversation (..),+ ConversationStore (..),+ InMemoryStore (..),+ initInMemoryStore,+ saveConversationInMemory,+ loadConversationInMemory,+ listConversationsInMemory,+ deleteConversationInMemory,++ -- * Model Context Protocol (MCP) Integration+ module Ollama.MCP,+) where++import Ollama.API.Blobs+import Ollama.API.Chat+import Ollama.API.Embed+import Ollama.API.Generate+import Ollama.API.Models+import Ollama.API.Models.Create+import Ollama.API.Models.Pull+import Ollama.API.Models.Push+import Ollama.API.Ps+import Ollama.API.Version+import Ollama.Client+import Ollama.Client.Config+import Ollama.Conversation+import Ollama.Error+import Ollama.MCP+import Ollama.Streaming+import Ollama.Testing+import Ollama.Types
+ src/Ollama/API/Blobs.hs view
@@ -0,0 +1,45 @@+{- |+Module : Ollama.API.Blobs+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Blob management endpoints (@/api/blobs/:digest@).++@since 1.0.0.0+-}+module Ollama.API.Blobs (+ checkBlob,+ pushBlob,+) where++import Control.Monad.IO.Class (MonadIO)+import Data.ByteString (ByteString)+import Data.Functor (void)+import Ollama.Client (OllamaClient)+import Ollama.Client.Internal (requestRaw)+import Ollama.Error (OllamaError (..))+import Ollama.Types.Common (Digest (..))++{- | Check if a blob exists on the server (@HEAD /api/blobs/:digest@).++@since 1.0.0.0+-}+checkBlob :: (MonadIO m) => OllamaClient -> Digest -> m (Either OllamaError Bool)+checkBlob client (Digest d) = do+ res <- requestRaw client "HEAD" ("/api/blobs/" <> d) Nothing+ pure $ case res of+ Right _ -> Right True+ Left (ApiError 404 _) -> Right False+ Left err -> Left err++{- | Push / upload a file blob to the server (@POST /api/blobs/:digest@).++@since 1.0.0.0+-}+pushBlob :: (MonadIO m) => OllamaClient -> Digest -> ByteString -> m (Either OllamaError ())+pushBlob client (Digest d) payload = do+ res <- requestRaw client "POST" ("/api/blobs/" <> d) (Just payload)+ pure $ void res
+ src/Ollama/API/Chat.hs view
@@ -0,0 +1,170 @@+{- |+Module : Ollama.API.Chat+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Chat completion API endpoint (@/api/chat@).++@since 1.0.0.0+-}+module Ollama.API.Chat (+ ChatRequest (..),+ ChatResponse (..),+ chatRequest,+ chat,+ chatStream,+ chatEvalTokensPerSecond,+ chatPromptEvalTokensPerSecond,+) where++import Conduit (ConduitT)+import Control.Monad.IO.Class (MonadIO)+import Control.Monad.IO.Unlift (MonadUnliftIO)+import Data.Aeson+import Data.List.NonEmpty (NonEmpty)+import Data.Maybe (catMaybes)+import Data.Text (Text)+import Data.Time (UTCTime)+import GHC.Generics (Generic)+import Ollama.Client (OllamaClient)+import Ollama.Client.Internal (request, requestStreaming)+import Ollama.Error (OllamaError)+import Ollama.Streaming (HasDone (..))+import Ollama.Types.Common (Duration, ModelName, Think, tokensPerSecond)+import Ollama.Types.Format (Format)+import Ollama.Types.Message (Message)+import Ollama.Types.Options (ModelOptions)+import Ollama.Types.Tool (Tool)++{- | Chat completion request payload.++@since 1.0.0.0+-}+data ChatRequest = ChatRequest+ { chatModel :: !ModelName+ , chatMessages :: !(NonEmpty Message)+ , chatTools :: !(Maybe [Tool])+ , chatFormat :: !(Maybe Format)+ , chatOptions :: !(Maybe ModelOptions)+ , chStream :: !(Maybe Bool)+ , chatKeepAlive :: !(Maybe Text)+ , chatThink :: !(Maybe Think)+ }+ deriving stock (Eq, Show, Generic)++instance ToJSON ChatRequest where+ toJSON ChatRequest {..} =+ object $+ catMaybes+ [ Just $ "model" .= chatModel+ , Just $ "messages" .= chatMessages+ , ("tools" .=) <$> chatTools+ , ("format" .=) <$> chatFormat+ , ("options" .=) <$> chatOptions+ , ("stream" .=) <$> chStream+ , ("keep_alive" .=) <$> chatKeepAlive+ , ("think" .=) <$> chatThink+ ]++{- | Create a default 'ChatRequest' for a model and message history.++@since 1.0.0.0+-}+chatRequest :: ModelName -> NonEmpty Message -> ChatRequest+chatRequest model msgs =+ ChatRequest+ { chatModel = model+ , chatMessages = msgs+ , chatTools = Nothing+ , chatFormat = Nothing+ , chatOptions = Nothing+ , chStream = Just False+ , chatKeepAlive = Nothing+ , chatThink = Nothing+ }++{- | Chat completion response payload.++@since 1.0.0.0+-}+data ChatResponse = ChatResponse+ { crModel :: !ModelName+ , crCreatedAt :: !UTCTime+ , crMessage :: !(Maybe Message)+ , crDone :: !Bool+ , crDoneReason :: !(Maybe Text)+ , crTotalDuration :: !(Maybe Duration)+ , crLoadDuration :: !(Maybe Duration)+ , crPromptEvalCount :: !(Maybe Int)+ , crPromptEvalDuration :: !(Maybe Duration)+ , crEvalCount :: !(Maybe Int)+ , crEvalDuration :: !(Maybe Duration)+ }+ deriving stock (Eq, Show, Generic)++instance FromJSON ChatResponse where+ parseJSON = withObject "ChatResponse" $ \v ->+ ChatResponse+ <$> v .: "model"+ <*> v .: "created_at"+ <*> v .:? "message"+ <*> v .: "done"+ <*> v .:? "done_reason"+ <*> v .:? "total_duration"+ <*> v .:? "load_duration"+ <*> v .:? "prompt_eval_count"+ <*> v .:? "prompt_eval_duration"+ <*> v .:? "eval_count"+ <*> v .:? "eval_duration"++instance ToJSON ChatResponse where+ toJSON ChatResponse {..} =+ object+ [ "model" .= crModel+ , "created_at" .= crCreatedAt+ , "message" .= crMessage+ , "done" .= crDone+ , "done_reason" .= crDoneReason+ , "total_duration" .= crTotalDuration+ , "load_duration" .= crLoadDuration+ , "prompt_eval_count" .= crPromptEvalCount+ , "prompt_eval_duration" .= crPromptEvalDuration+ , "eval_count" .= crEvalCount+ , "eval_duration" .= crEvalDuration+ ]++instance HasDone ChatResponse where+ isDone = crDone++{- | Non-streaming chat completion API.++@since 1.0.0.0+-}+chat :: (MonadIO m) => OllamaClient -> ChatRequest -> m (Either OllamaError ChatResponse)+chat client req = request client "POST" "/api/chat" (Just req {chStream = Just False})++{- | Streaming chat completion API yielding 'ChatResponse' chunks.++@since 1.0.0.0+-}+chatStream :: (MonadUnliftIO m) => OllamaClient -> ChatRequest -> ConduitT () ChatResponse m ()+chatStream client req = requestStreaming client "/api/chat" (req {chStream = Just True})++{- | Calculate generation throughput (eval tokens \/ second) from a 'ChatResponse'.++@since 1.0.0.0+-}+chatEvalTokensPerSecond :: ChatResponse -> Maybe Double+chatEvalTokensPerSecond ChatResponse {..} =+ tokensPerSecond <$> crEvalCount <*> crEvalDuration++{- | Calculate prompt evaluation throughput (prompt tokens \/ second) from a 'ChatResponse'.++@since 1.0.0.0+-}+chatPromptEvalTokensPerSecond :: ChatResponse -> Maybe Double+chatPromptEvalTokensPerSecond ChatResponse {..} =+ tokensPerSecond <$> crPromptEvalCount <*> crPromptEvalDuration
+ src/Ollama/API/Embed.hs view
@@ -0,0 +1,175 @@+{- |+Module : Ollama.API.Embed+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Vector embeddings API endpoint (@/api/embed@).++@since 1.0.0.0+-}+module Ollama.API.Embed (+ EmbedRequest (..),+ EmbedResponse (..),+ embedRequest,+ embed,++ -- * Deprecated Legacy API+ EmbeddingsRequest (..),+ EmbeddingsResponse (..),+ embeddings,+) where++import Control.Monad.IO.Class (MonadIO)+import Data.Aeson+import Data.Maybe (catMaybes)+import Data.Text (Text)+import GHC.Generics (Generic)+import Ollama.Client (OllamaClient)+import Ollama.Client.Internal (request)+import Ollama.Error (OllamaError)+import Ollama.Types.Common (Duration, ModelName)+import Ollama.Types.Options (ModelOptions)++{- | Embedding request payload for single text or batch list of texts.++@since 1.0.0.0+-}+data EmbedRequest = EmbedRequest+ { embModel :: !ModelName+ , embInput :: !(Either Text [Text])+ , embTruncate :: !(Maybe Bool)+ , embOptions :: !(Maybe ModelOptions)+ , embKeepAlive :: !(Maybe Text)+ , embDimensions :: !(Maybe Int)+ }+ deriving stock (Eq, Show, Generic)++instance ToJSON EmbedRequest where+ toJSON EmbedRequest {..} =+ object $+ catMaybes+ [ Just $ "model" .= embModel+ , Just $ case embInput of+ Left single -> "input" .= single+ Right multiple -> "input" .= multiple+ , ("truncate" .=) <$> embTruncate+ , ("options" .=) <$> embOptions+ , ("keep_alive" .=) <$> embKeepAlive+ , ("dimensions" .=) <$> embDimensions+ ]++{- | Create an 'EmbedRequest' for a list of input texts.++@since 1.0.0.0+-}+embedRequest :: ModelName -> [Text] -> EmbedRequest+embedRequest model inputs =+ EmbedRequest+ { embModel = model+ , embInput = Right inputs+ , embTruncate = Nothing+ , embOptions = Nothing+ , embKeepAlive = Nothing+ , embDimensions = Nothing+ }++{- | Embedding response payload containing vector embeddings.++@since 1.0.0.0+-}+data EmbedResponse = EmbedResponse+ { erModel :: !ModelName+ , erEmbeddings :: ![[Double]]+ , erTotalDuration :: !(Maybe Duration)+ , erLoadDuration :: !(Maybe Duration)+ , erPromptEvalCount :: !(Maybe Int)+ }+ deriving stock (Eq, Show, Generic)++instance FromJSON EmbedResponse where+ parseJSON = withObject "EmbedResponse" $ \v ->+ EmbedResponse+ <$> v .: "model"+ <*> v .: "embeddings"+ <*> v .:? "total_duration"+ <*> v .:? "load_duration"+ <*> v .:? "prompt_eval_count"++instance ToJSON EmbedResponse where+ toJSON EmbedResponse {..} =+ object+ [ "model" .= erModel+ , "embeddings" .= erEmbeddings+ , "total_duration" .= erTotalDuration+ , "load_duration" .= erLoadDuration+ , "prompt_eval_count" .= erPromptEvalCount+ ]++{- | Generate vector embeddings.++@since 1.0.0.0+-}+embed :: (MonadIO m) => OllamaClient -> EmbedRequest -> m (Either OllamaError EmbedResponse)+embed client req = request client "POST" "/api/embed" (Just req)++{- | Legacy request payload for deprecated @/api/embeddings@ endpoint.++@since 1.0.0.0+-}+data EmbeddingsRequest = EmbeddingsRequest+ { ebrModel :: !ModelName+ , ebrPrompt :: !Text+ , ebrOptions :: !(Maybe ModelOptions)+ , ebrKeepAlive :: !(Maybe Text)+ }+ deriving stock (Eq, Show, Generic)++instance ToJSON EmbeddingsRequest where+ toJSON EmbeddingsRequest {..} =+ object $+ catMaybes+ [ Just $ "model" .= ebrModel+ , Just $ "prompt" .= ebrPrompt+ , ("options" .=) <$> ebrOptions+ , ("keep_alive" .=) <$> ebrKeepAlive+ ]++instance FromJSON EmbeddingsRequest where+ parseJSON = withObject "EmbeddingsRequest" $ \v ->+ EmbeddingsRequest+ <$> v .: "model"+ <*> v .: "prompt"+ <*> v .:? "options"+ <*> v .:? "keep_alive"++{- | Legacy response payload for deprecated @/api/embeddings@ endpoint.++@since 1.0.0.0+-}+newtype EmbeddingsResponse = EmbeddingsResponse+ { ebrEmbedding :: [Double]+ }+ deriving stock (Eq, Show, Generic)++instance FromJSON EmbeddingsResponse where+ parseJSON = withObject "EmbeddingsResponse" $ \v ->+ EmbeddingsResponse <$> v .: "embedding"++instance ToJSON EmbeddingsResponse where+ toJSON EmbeddingsResponse {..} =+ object ["embedding" .= ebrEmbedding]++{- | Generate vector embeddings using the deprecated @/api/embeddings@ endpoint.++@since 1.0.0.0+-}+embeddings ::+ (MonadIO m) =>+ OllamaClient ->+ EmbeddingsRequest ->+ m (Either OllamaError EmbeddingsResponse)+embeddings client req = request client "POST" "/api/embeddings" (Just req)+{-# DEPRECATED embeddings "Use embed instead" #-}
+ src/Ollama/API/Generate.hs view
@@ -0,0 +1,199 @@+{- |+Module : Ollama.API.Generate+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Text completion API endpoint (@/api/generate@).++@since 1.0.0.0+-}+module Ollama.API.Generate (+ GenerateRequest (..),+ GenerateResponse (..),+ generateRequest,+ generate,+ generateStream,+ evalTokensPerSecond,+ promptEvalTokensPerSecond,+) where++import Conduit (ConduitT)+import Control.Monad.IO.Class (MonadIO)+import Control.Monad.IO.Unlift (MonadUnliftIO)+import Data.Aeson+import Data.Maybe (catMaybes)+import Data.Text (Text)+import Data.Time (UTCTime)+import GHC.Generics (Generic)+import Ollama.Client (OllamaClient)+import Ollama.Client.Internal (request, requestStreaming)+import Ollama.Error (OllamaError)+import Ollama.Streaming (HasDone (..))+import Ollama.Types.Common (Base64Image, Duration, ModelName, Think, tokensPerSecond)+import Ollama.Types.Format (Format)+import Ollama.Types.Options (ModelOptions)++{- | Request payload for text generation completion.++@since 1.0.0.0+-}+data GenerateRequest = GenerateRequest+ { genModel :: !ModelName+ , genPrompt :: !Text+ , genSuffix :: !(Maybe Text)+ , genImages :: !(Maybe [Base64Image])+ , genFormat :: !(Maybe Format)+ , genOptions :: !(Maybe ModelOptions)+ , genSystem :: !(Maybe Text)+ , genTemplate :: !(Maybe Text)+ , genStream :: !(Maybe Bool)+ , genRaw :: !(Maybe Bool)+ , genKeepAlive :: !(Maybe Text)+ , genThink :: !(Maybe Think)+ , genWidth :: !(Maybe Int)+ , genHeight :: !(Maybe Int)+ , genSteps :: !(Maybe Int)+ }+ deriving stock (Eq, Show, Generic)++instance ToJSON GenerateRequest where+ toJSON GenerateRequest {..} =+ object $+ catMaybes+ [ Just $ "model" .= genModel+ , Just $ "prompt" .= genPrompt+ , ("suffix" .=) <$> genSuffix+ , ("images" .=) <$> genImages+ , ("format" .=) <$> genFormat+ , ("options" .=) <$> genOptions+ , ("system" .=) <$> genSystem+ , ("template" .=) <$> genTemplate+ , ("stream" .=) <$> genStream+ , ("raw" .=) <$> genRaw+ , ("keep_alive" .=) <$> genKeepAlive+ , ("think" .=) <$> genThink+ , ("width" .=) <$> genWidth+ , ("height" .=) <$> genHeight+ , ("steps" .=) <$> genSteps+ ]++{- | Create a default 'GenerateRequest' for a model and prompt.++@since 1.0.0.0+-}+generateRequest :: ModelName -> Text -> GenerateRequest+generateRequest model prompt =+ GenerateRequest+ { genModel = model+ , genPrompt = prompt+ , genSuffix = Nothing+ , genImages = Nothing+ , genFormat = Nothing+ , genOptions = Nothing+ , genSystem = Nothing+ , genTemplate = Nothing+ , genStream = Just False+ , genRaw = Nothing+ , genKeepAlive = Nothing+ , genThink = Nothing+ , genWidth = Nothing+ , genHeight = Nothing+ , genSteps = Nothing+ }++{- | Response payload returned by text generation.++@since 1.0.0.0+-}+data GenerateResponse = GenerateResponse+ { grModel :: !ModelName+ , grCreatedAt :: !UTCTime+ , grResponse :: !Text+ , grDone :: !Bool+ , grDoneReason :: !(Maybe Text)+ , grContext :: !(Maybe [Int])+ , grTotalDuration :: !(Maybe Duration)+ , grLoadDuration :: !(Maybe Duration)+ , grPromptEvalCount :: !(Maybe Int)+ , grPromptEvalDuration :: !(Maybe Duration)+ , grEvalCount :: !(Maybe Int)+ , grEvalDuration :: !(Maybe Duration)+ , grThinking :: !(Maybe Text)+ , grImage :: !(Maybe Base64Image)+ }+ deriving stock (Eq, Show, Generic)++instance FromJSON GenerateResponse where+ parseJSON = withObject "GenerateResponse" $ \v ->+ GenerateResponse+ <$> v .: "model"+ <*> v .: "created_at"+ <*> v .: "response"+ <*> v .: "done"+ <*> v .:? "done_reason"+ <*> v .:? "context"+ <*> v .:? "total_duration"+ <*> v .:? "load_duration"+ <*> v .:? "prompt_eval_count"+ <*> v .:? "prompt_eval_duration"+ <*> v .:? "eval_count"+ <*> v .:? "eval_duration"+ <*> v .:? "thinking"+ <*> v .:? "image"++instance ToJSON GenerateResponse where+ toJSON GenerateResponse {..} =+ object+ [ "model" .= grModel+ , "created_at" .= grCreatedAt+ , "response" .= grResponse+ , "done" .= grDone+ , "done_reason" .= grDoneReason+ , "context" .= grContext+ , "total_duration" .= grTotalDuration+ , "load_duration" .= grLoadDuration+ , "prompt_eval_count" .= grPromptEvalCount+ , "prompt_eval_duration" .= grPromptEvalDuration+ , "eval_count" .= grEvalCount+ , "eval_duration" .= grEvalDuration+ , "thinking" .= grThinking+ , "image" .= grImage+ ]++instance HasDone GenerateResponse where+ isDone = grDone++{- | Non-streaming text completion API.++@since 1.0.0.0+-}+generate ::+ (MonadIO m) => OllamaClient -> GenerateRequest -> m (Either OllamaError GenerateResponse)+generate client req = request client "POST" "/api/generate" (Just req {genStream = Just False})++{- | Streaming text completion API yielding 'GenerateResponse' chunks.++@since 1.0.0.0+-}+generateStream ::+ (MonadUnliftIO m) => OllamaClient -> GenerateRequest -> ConduitT () GenerateResponse m ()+generateStream client req = requestStreaming client "/api/generate" (req {genStream = Just True})++{- | Calculate generation throughput (eval tokens \/ second) from a 'GenerateResponse'.++@since 1.0.0.0+-}+evalTokensPerSecond :: GenerateResponse -> Maybe Double+evalTokensPerSecond GenerateResponse {..} =+ tokensPerSecond <$> grEvalCount <*> grEvalDuration++{- | Calculate prompt evaluation throughput (prompt tokens \/ second) from a 'GenerateResponse'.++@since 1.0.0.0+-}+promptEvalTokensPerSecond :: GenerateResponse -> Maybe Double+promptEvalTokensPerSecond GenerateResponse {..} =+ tokensPerSecond <$> grPromptEvalCount <*> grPromptEvalDuration
+ src/Ollama/API/Models.hs view
@@ -0,0 +1,171 @@+{- |+Module : Ollama.API.Models+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Model management endpoints (list, show, copy, delete).++@since 1.0.0.0+-}+module Ollama.API.Models (+ -- * Listing+ listModels,+ ListResponse (..),++ -- * Show Info+ showModel,+ ShowRequest (..),+ ShowResponse (..),+ ShowModelInfo (..),++ -- * Copy+ copyModel,+ CopyRequest (..),++ -- * Delete+ deleteModel,+ DeleteRequest (..),+) where++import Control.Monad.IO.Class (MonadIO)+import Data.Aeson+import Data.Map.Strict (Map)+import Data.Maybe (catMaybes)+import Data.Text (Text)+import GHC.Generics (Generic)+import Ollama.Client (OllamaClient)+import Ollama.Client.Internal (request)+import Ollama.Error (OllamaError)+import Ollama.Types.Common (ModelName)+import Ollama.Types.Model (ListResponse (..), ModelDetails (..))++{- | Show model info request payload.++@since 1.0.0.0+-}+data ShowRequest = ShowRequest+ { srqModel :: !ModelName+ , srqVerbose :: !(Maybe Bool)+ }+ deriving stock (Eq, Show, Generic)++instance ToJSON ShowRequest where+ toJSON ShowRequest {..} =+ object $+ catMaybes+ [ Just $ "model" .= srqModel+ , ("verbose" .=) <$> srqVerbose+ ]++{- | Detailed technical parameters from model metadata.++@since 1.0.0.0+-}+newtype ShowModelInfo = ShowModelInfo+ { modelInfoMap :: Map Text Value+ }+ deriving stock (Eq, Show, Generic)++instance FromJSON ShowModelInfo where+ parseJSON v = ShowModelInfo <$> parseJSON v++instance ToJSON ShowModelInfo where+ toJSON (ShowModelInfo m) = toJSON m++{- | Model inspection response payload.++@since 1.0.0.0+-}+data ShowResponse = ShowResponse+ { srsModelfile :: !Text+ , srsParameters :: !(Maybe Text)+ , srsTemplate :: !(Maybe Text)+ , srsDetails :: !ModelDetails+ , srsModelInfo :: !(Maybe ShowModelInfo)+ , srsLicense :: !(Maybe Text)+ , srsCapabilities :: !(Maybe [Text])+ }+ deriving stock (Eq, Show, Generic)++instance FromJSON ShowResponse where+ parseJSON = withObject "ShowResponse" $ \v ->+ ShowResponse+ <$> v .:? "modelfile" .!= ""+ <*> v .:? "parameters"+ <*> v .:? "template"+ <*> v .:? "details" .!= ModelDetails Nothing "" "" [] "" ""+ <*> v .:? "model_info"+ <*> v .:? "license"+ <*> v .:? "capabilities"++instance ToJSON ShowResponse where+ toJSON ShowResponse {..} =+ object+ [ "modelfile" .= srsModelfile+ , "parameters" .= srsParameters+ , "template" .= srsTemplate+ , "details" .= srsDetails+ , "model_info" .= srsModelInfo+ , "license" .= srsLicense+ , "capabilities" .= srsCapabilities+ ]++{- | Copy model request payload.++@since 1.0.0.0+-}+data CopyRequest = CopyRequest+ { cpSource :: !ModelName+ , cpDestination :: !ModelName+ }+ deriving stock (Eq, Show, Generic)++instance ToJSON CopyRequest where+ toJSON CopyRequest {..} =+ object+ [ "source" .= cpSource+ , "destination" .= cpDestination+ ]++{- | Delete model request payload.++@since 1.0.0.0+-}+newtype DeleteRequest = DeleteRequest+ { delModel :: ModelName+ }+ deriving stock (Eq, Show, Generic)++instance ToJSON DeleteRequest where+ toJSON DeleteRequest {..} = object ["model" .= delModel]++{- | List installed local models (@GET /api/tags@).++@since 1.0.0.0+-}+listModels :: (MonadIO m) => OllamaClient -> m (Either OllamaError ListResponse)+listModels client = request client "GET" "/api/tags" (Nothing :: Maybe Value)++{- | Fetch information and Modelfile content for a model (@POST /api/show@).++@since 1.0.0.0+-}+showModel :: (MonadIO m) => OllamaClient -> ModelName -> m (Either OllamaError ShowResponse)+showModel client model = request client "POST" "/api/show" (Just $ ShowRequest model Nothing)++{- | Duplicate an existing model under a new tag (@POST /api/copy@).++@since 1.0.0.0+-}+copyModel :: (MonadIO m) => OllamaClient -> ModelName -> ModelName -> m (Either OllamaError ())+copyModel client src dst = request client "POST" "/api/copy" (Just $ CopyRequest src dst)++{- | Delete a local model (@DELETE /api/delete@).++@since 1.0.0.0+-}+deleteModel :: (MonadIO m) => OllamaClient -> ModelName -> m (Either OllamaError ())+deleteModel client model = request client "DELETE" "/api/delete" (Just $ DeleteRequest model)
+ src/Ollama/API/Models/Create.hs view
@@ -0,0 +1,165 @@+{- |+Module : Ollama.API.Models.Create+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Model creation endpoint (@/api/create@).++@since 1.0.0.0+-}+module Ollama.API.Models.Create (+ CreateRequest (..),+ CreateResponse (..),+ QuantizationType (..),+ createModel,+ createModelStream,+ defaultCreateRequest,+) where++import Conduit (ConduitT)+import Control.Monad.IO.Class (MonadIO)+import Control.Monad.IO.Unlift (MonadUnliftIO)+import Data.Aeson+import Data.Int (Int64)+import Data.Map.Strict (Map)+import Data.Maybe (catMaybes)+import Data.Text (Text)+import GHC.Generics (Generic)+import Ollama.Client (OllamaClient)+import Ollama.Client.Internal (request, requestStreaming)+import Ollama.Error (OllamaError)+import Ollama.Streaming (HasDone (..))+import Ollama.Types.Common (Digest, ModelName)+import Ollama.Types.Message (Message)+import Ollama.Types.Options (ModelOptions)++{- | Quantization precision options for model creation.++@since 1.0.0.0+-}+data QuantizationType = Q4_K_M | Q4_K_S | Q8_0+ deriving stock (Eq, Show, Bounded, Enum, Generic)++instance ToJSON QuantizationType where+ toJSON Q4_K_M = String "q4_K_M"+ toJSON Q4_K_S = String "q4_K_S"+ toJSON Q8_0 = String "q8_0"++instance FromJSON QuantizationType where+ parseJSON = withText "QuantizationType" $ \case+ "q4_K_M" -> pure Q4_K_M+ "q4_K_S" -> pure Q4_K_S+ "q8_0" -> pure Q8_0+ other -> fail $ "Invalid QuantizationType: " <> show other++{- | Model creation request configuration payload.++@since 1.0.0.0+-}+data CreateRequest = CreateRequest+ { crqModel :: !ModelName+ , crqFrom :: !(Maybe ModelName)+ , crqFiles :: !(Maybe (Map Text Digest))+ , crqAdapters :: !(Maybe (Map Text Digest))+ , crqTemplate :: !(Maybe Text)+ , crqRenderer :: !(Maybe Text)+ , crqParser :: !(Maybe Text)+ , crqLicense :: !(Maybe [Text])+ , crqSystem :: !(Maybe Text)+ , crqParameters :: !(Maybe ModelOptions)+ , crqMessages :: !(Maybe [Message])+ , crqStream :: !(Maybe Bool)+ , crqQuantize :: !(Maybe QuantizationType)+ }+ deriving stock (Eq, Show, Generic)++instance ToJSON CreateRequest where+ toJSON CreateRequest {..} =+ object $+ catMaybes+ [ Just $ "model" .= crqModel+ , ("from" .=) <$> crqFrom+ , ("files" .=) <$> crqFiles+ , ("adapters" .=) <$> crqAdapters+ , ("template" .=) <$> crqTemplate+ , ("renderer" .=) <$> crqRenderer+ , ("parser" .=) <$> crqParser+ , ("license" .=) <$> crqLicense+ , ("system" .=) <$> crqSystem+ , ("parameters" .=) <$> crqParameters+ , ("messages" .=) <$> crqMessages+ , ("stream" .=) <$> crqStream+ , ("quantize" .=) <$> crqQuantize+ ]++{- | Smart constructor for a basic 'CreateRequest'.++@since 1.0.0.0+-}+defaultCreateRequest :: ModelName -> CreateRequest+defaultCreateRequest name =+ CreateRequest+ { crqModel = name+ , crqFrom = Nothing+ , crqFiles = Nothing+ , crqAdapters = Nothing+ , crqTemplate = Nothing+ , crqRenderer = Nothing+ , crqParser = Nothing+ , crqLicense = Nothing+ , crqSystem = Nothing+ , crqParameters = Nothing+ , crqMessages = Nothing+ , crqStream = Just False+ , crqQuantize = Nothing+ }++{- | Progress / status response during model creation.++@since 1.0.0.0+-}+data CreateResponse = CreateResponse+ { crsStatus :: !Text+ , crsDigest :: !(Maybe Digest)+ , crsTotal :: !(Maybe Int64)+ , crsCompleted :: !(Maybe Int64)+ }+ deriving stock (Eq, Show, Generic)++instance FromJSON CreateResponse where+ parseJSON = withObject "CreateResponse" $ \v ->+ CreateResponse+ <$> v .: "status"+ <*> v .:? "digest"+ <*> v .:? "total"+ <*> v .:? "completed"++instance ToJSON CreateResponse where+ toJSON CreateResponse {..} =+ object+ [ "status" .= crsStatus+ , "digest" .= crsDigest+ , "total" .= crsTotal+ , "completed" .= crsCompleted+ ]++instance HasDone CreateResponse where+ isDone CreateResponse {..} = crsStatus == "success"++{- | Create a model non-streaming.++@since 1.0.0.0+-}+createModel :: (MonadIO m) => OllamaClient -> CreateRequest -> m (Either OllamaError CreateResponse)+createModel client req = request client "POST" "/api/create" (Just req {crqStream = Just False})++{- | Create a model streaming progress updates.++@since 1.0.0.0+-}+createModelStream ::+ (MonadUnliftIO m) => OllamaClient -> CreateRequest -> ConduitT () CreateResponse m ()+createModelStream client req = requestStreaming client "/api/create" (req {crqStream = Just True})
+ src/Ollama/API/Models/Pull.hs view
@@ -0,0 +1,100 @@+{- |+Module : Ollama.API.Models.Pull+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Model pull endpoint (@/api/pull@).++@since 1.0.0.0+-}+module Ollama.API.Models.Pull (+ PullRequest (..),+ PullResponse (..),+ pull,+ pullStream,+) where++import Conduit (ConduitT)+import Control.Monad.IO.Class (MonadIO)+import Control.Monad.IO.Unlift (MonadUnliftIO)+import Data.Aeson+import Data.Int (Int64)+import Data.Maybe (catMaybes)+import Data.Text (Text)+import GHC.Generics (Generic)+import Ollama.Client (OllamaClient)+import Ollama.Client.Internal (request, requestStreaming)+import Ollama.Error (OllamaError)+import Ollama.Streaming (HasDone (..))+import Ollama.Types.Common (Digest, ModelName)++{- | Pull model request configuration payload.++@since 1.0.0.0+-}+data PullRequest = PullRequest+ { prqModel :: !ModelName+ , prqInsecure :: !(Maybe Bool)+ , prqStream :: !(Maybe Bool)+ }+ deriving stock (Eq, Show, Generic)++instance ToJSON PullRequest where+ toJSON PullRequest {..} =+ object $+ catMaybes+ [ Just $ "model" .= prqModel+ , ("insecure" .=) <$> prqInsecure+ , ("stream" .=) <$> prqStream+ ]++{- | Pull progress/status response payload.++@since 1.0.0.0+-}+data PullResponse = PullResponse+ { prStatus :: !Text+ , prDigest :: !(Maybe Digest)+ , prTotal :: !(Maybe Int64)+ , prCompleted :: !(Maybe Int64)+ }+ deriving stock (Eq, Show, Generic)++instance FromJSON PullResponse where+ parseJSON = withObject "PullResponse" $ \v ->+ PullResponse+ <$> v .: "status"+ <*> v .:? "digest"+ <*> v .:? "total"+ <*> v .:? "completed"++instance ToJSON PullResponse where+ toJSON PullResponse {..} =+ object+ [ "status" .= prStatus+ , "digest" .= prDigest+ , "total" .= prTotal+ , "completed" .= prCompleted+ ]++instance HasDone PullResponse where+ isDone PullResponse {..} = prStatus == "success"++{- | Download / pull a model (non-streaming, blocks until complete).++@since 1.0.0.0+-}+pull :: (MonadIO m) => OllamaClient -> ModelName -> m (Either OllamaError PullResponse)+pull client model =+ request client "POST" "/api/pull" (Just $ PullRequest model Nothing (Just False))++{- | Download / pull a model streaming progress updates.++@since 1.0.0.0+-}+pullStream :: (MonadUnliftIO m) => OllamaClient -> ModelName -> ConduitT () PullResponse m ()+pullStream client model =+ requestStreaming client "/api/pull" (PullRequest model Nothing (Just True))
+ src/Ollama/API/Models/Push.hs view
@@ -0,0 +1,100 @@+{- |+Module : Ollama.API.Models.Push+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Model push endpoint (@/api/push@).++@since 1.0.0.0+-}+module Ollama.API.Models.Push (+ PushRequest (..),+ PushResponse (..),+ push,+ pushStream,+) where++import Conduit (ConduitT)+import Control.Monad.IO.Class (MonadIO)+import Control.Monad.IO.Unlift (MonadUnliftIO)+import Data.Aeson+import Data.Int (Int64)+import Data.Maybe (catMaybes)+import Data.Text (Text)+import GHC.Generics (Generic)+import Ollama.Client (OllamaClient)+import Ollama.Client.Internal (request, requestStreaming)+import Ollama.Error (OllamaError)+import Ollama.Streaming (HasDone (..))+import Ollama.Types.Common (Digest, ModelName)++{- | Push model request payload.++@since 1.0.0.0+-}+data PushRequest = PushRequest+ { psqModel :: !ModelName+ , psqInsecure :: !(Maybe Bool)+ , psqStream :: !(Maybe Bool)+ }+ deriving stock (Eq, Show, Generic)++instance ToJSON PushRequest where+ toJSON PushRequest {..} =+ object $+ catMaybes+ [ Just $ "model" .= psqModel+ , ("insecure" .=) <$> psqInsecure+ , ("stream" .=) <$> psqStream+ ]++{- | Push progress response payload.++@since 1.0.0.0+-}+data PushResponse = PushResponse+ { psStatus :: !Text+ , psDigest :: !(Maybe Digest)+ , psTotal :: !(Maybe Int64)+ , psCompleted :: !(Maybe Int64)+ }+ deriving stock (Eq, Show, Generic)++instance FromJSON PushResponse where+ parseJSON = withObject "PushResponse" $ \v ->+ PushResponse+ <$> v .: "status"+ <*> v .:? "digest"+ <*> v .:? "total"+ <*> v .:? "completed"++instance ToJSON PushResponse where+ toJSON PushResponse {..} =+ object+ [ "status" .= psStatus+ , "digest" .= psDigest+ , "total" .= psTotal+ , "completed" .= psCompleted+ ]++instance HasDone PushResponse where+ isDone PushResponse {..} = psStatus == "success"++{- | Push a model to a remote library (non-streaming).++@since 1.0.0.0+-}+push :: (MonadIO m) => OllamaClient -> ModelName -> m (Either OllamaError PushResponse)+push client model =+ request client "POST" "/api/push" (Just $ PushRequest model Nothing (Just False))++{- | Push a model streaming upload progress updates.++@since 1.0.0.0+-}+pushStream :: (MonadUnliftIO m) => OllamaClient -> ModelName -> ConduitT () PushResponse m ()+pushStream client model =+ requestStreaming client "/api/push" (PushRequest model Nothing (Just True))
+ src/Ollama/API/Ps.hs view
@@ -0,0 +1,31 @@+{- |+Module : Ollama.API.Ps+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Running models endpoint (@/api/ps@).++@since 1.0.0.0+-}+module Ollama.API.Ps (+ listRunning,+ RunningModelsResponse (..),+ RunningModel (..),+) where++import Control.Monad.IO.Class (MonadIO)+import Data.Aeson (Value)+import Ollama.Client (OllamaClient)+import Ollama.Client.Internal (request)+import Ollama.Error (OllamaError)+import Ollama.Types.Model (RunningModel (..), RunningModelsResponse (..))++{- | List models currently loaded into memory (@GET /api/ps@).++@since 1.0.0.0+-}+listRunning :: (MonadIO m) => OllamaClient -> m (Either OllamaError RunningModelsResponse)+listRunning client = request client "GET" "/api/ps" (Nothing :: Maybe Value)
+ src/Ollama/API/Version.hs view
@@ -0,0 +1,30 @@+{- |+Module : Ollama.API.Version+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Version endpoint (@/api/version@).++@since 1.0.0.0+-}+module Ollama.API.Version (+ getVersion,+ Version (..),+) where++import Control.Monad.IO.Class (MonadIO)+import Data.Aeson (Value)+import Ollama.Client (OllamaClient)+import Ollama.Client.Internal (request)+import Ollama.Error (OllamaError)+import Ollama.Types.Common (Version (..))++{- | Retrieve Ollama server engine version (@GET /api/version@).++@since 1.0.0.0+-}+getVersion :: (MonadIO m) => OllamaClient -> m (Either OllamaError Version)+getVersion client = request client "GET" "/api/version" (Nothing :: Maybe Value)
+ src/Ollama/Client.hs view
@@ -0,0 +1,110 @@+{- |+Module : Ollama.Client+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Opaque client management and construction functions.++@since 1.0.0.0+-}+module Ollama.Client (+ OllamaClient (..),+ newClient,+ defaultClient,+ clientFromEnv,+ closeClient,+ withClient,+) where++import Control.Monad.IO.Class (MonadIO (liftIO))+import Control.Monad.IO.Unlift (MonadUnliftIO, withRunInIO)+import Data.Text (Text)+import Data.Text qualified as T+import Network.HTTP.Client (Manager, newManager)+import Network.HTTP.Client.TLS (tlsManagerSettings)+import Ollama.Client.Config (OllamaClientConfig (..), defaultConfig)+import System.Environment (lookupEnv)++{- | Opaque client handle for dispatching API calls.++@since 1.0.0.0+-}+data OllamaClient = OllamaClient+ { clientManager :: !Manager+ , clientConfig :: !OllamaClientConfig+ , clientOwned :: !Bool+ }++{- | Construct a client with custom configuration settings.++@since 1.0.0.0+-}+newClient :: (MonadIO m) => OllamaClientConfig -> m OllamaClient+newClient cfg = liftIO $ do+ case configManager cfg of+ Just mgr -> pure $ OllamaClient mgr cfg False+ Nothing -> do+ mgr <- newManager tlsManagerSettings+ pure $ OllamaClient mgr cfg True++{- | Construct a client with default settings (@http://127.0.0.1:11434@).++@since 1.0.0.0+-}+defaultClient :: (MonadIO m) => m OllamaClient+defaultClient = newClient defaultConfig++{- | Construct a client resolving host and credentials from environment variables+(@OLLAMA_HOST@, @OLLAMA_API_KEY@).++Parses @OLLAMA_HOST@ robustly, handling these formats:++ * @host:port@ → @http:\/\/host:port@+ * @http:\/\/host:port@ → used as-is+ * @host@ → @http:\/\/host:11434@++@since 1.0.0.0+-}+clientFromEnv :: (MonadIO m) => m OllamaClient+clientFromEnv = liftIO $ do+ mbHost <- lookupEnv "OLLAMA_HOST"+ mbKey <- lookupEnv "OLLAMA_API_KEY"+ let hostText = maybe "http://127.0.0.1:11434" (normalizeHost . T.pack) mbHost+ keyText = T.pack <$> mbKey+ cfg = defaultConfig {configBaseUrl = hostText, configApiKey = keyText}+ newClient cfg++{- | Normalize an @OLLAMA_HOST@ value to a full URL with scheme.++Handles: bare @host:port@, @http(s):\/\/host:port@, and bare @host@.++@since 1.0.0.0+-}+normalizeHost :: Text -> Text+normalizeHost raw+ | "http://" `T.isPrefixOf` raw || "https://" `T.isPrefixOf` raw = T.dropWhileEnd (== '/') raw+ | ":" `T.isInfixOf` raw = "http://" <> T.dropWhileEnd (== '/') raw+ | otherwise = "http://" <> T.dropWhileEnd (== '/') raw <> ":11434"++{- | Close the underlying HTTP connection manager if owned by this client.++Note: Connection managers in @http-client@ are automatically reclaimed by garbage collection.++@since 1.0.0.0+-}+closeClient :: (MonadIO m) => OllamaClient -> m ()+closeClient _ = pure ()++{- | Resource bracket helper to initialize, run a computation, and close client resources.++@since 1.0.0.0+-}+withClient :: (MonadUnliftIO m) => OllamaClientConfig -> (OllamaClient -> m a) -> m a+withClient cfg action = withRunInIO $ \run -> do+ client <- newClient cfg+ res <- run (action client)+ closeClient client+ pure res
+ src/Ollama/Client/Config.hs view
@@ -0,0 +1,103 @@+{- |+Module : Ollama.Client.Config+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Client configuration settings, retry policies, and logging thresholds.++@since 0.3.0.0+-}+module Ollama.Client.Config (+ OllamaClientConfig (..),+ defaultConfig,+ RetryPolicy (..),+ noRetry,+ constantRetry,+ exponentialRetry,+ LogLevel (..),+) where++import Data.ByteString (ByteString)+import Data.CaseInsensitive (CI)+import Data.Text (Text)+import Network.HTTP.Client (Manager)++{- | Logging levels for structured client events.++@since 0.3.0.0+-}+data LogLevel = Debug | Info | Warn | Error+ deriving stock (Eq, Ord, Show, Bounded, Enum)++{- | Configurable retry strategy for recoverable network errors.++@since 0.3.0.0+-}+data RetryPolicy+ = -- | Disable all retries+ NoRetry+ | -- | Retry up to @count@ times with fixed @delayMicros@ interval+ ConstantRetry !Int !Int+ | -- | Retry up to @count@ times with exponential backoff starting at @baseDelayMicros@+ ExponentialRetry !Int !Int+ deriving stock (Eq, Show)++{- | Helper constructor for 'NoRetry'.++@since 0.3.0.0+-}+noRetry :: RetryPolicy+noRetry = NoRetry++{- | Helper constructor for 'ConstantRetry'.++@since 0.3.0.0+-}+constantRetry :: Int -> Int -> RetryPolicy+constantRetry = ConstantRetry++{- | Helper constructor for 'ExponentialRetry'.++@since 0.3.0.0+-}+exponentialRetry :: Int -> Int -> RetryPolicy+exponentialRetry = ExponentialRetry++{- | Configuration settings for an 'Ollama.Client.OllamaClient'.++@since 0.3.0.0+-}+data OllamaClientConfig = OllamaClientConfig+ { configBaseUrl :: !Text+ , configTimeout :: !Int+ , configRetry :: !RetryPolicy+ , configManager :: !(Maybe Manager)+ , configHeaders :: ![(CI ByteString, ByteString)]+ , configApiKey :: !(Maybe Text)+ , configLogger :: !(Maybe (LogLevel -> Text -> IO ()))+ , configOnStart :: !(Maybe (IO ()))+ , configOnSuccess :: !(Maybe (IO ()))+ , configOnError :: !(Maybe (IO ()))+ }++{- | Default configuration connecting to @http://127.0.0.1:11434@ with 300s timeout and 'NoRetry'.++@since 0.3.0.0+-}+defaultConfig :: OllamaClientConfig+defaultConfig =+ OllamaClientConfig+ { configBaseUrl = "http://127.0.0.1:11434"+ , configTimeout = 300+ , configRetry = NoRetry+ , configManager = Nothing+ , configHeaders = []+ , configApiKey = Nothing+ , configLogger = Nothing+ , configOnStart = Nothing+ , configOnSuccess = Nothing+ , configOnError = Nothing+ }
+ src/Ollama/Client/Internal.hs view
@@ -0,0 +1,276 @@+{- |+Module : Ollama.Client.Internal+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Low-level HTTP transport plumbing and request dispatchers.++Implements retry logic, lifecycle callbacks, structured logging,+authentication header injection, and conduit-based response streaming.++@since 1.0.0.0+-}+module Ollama.Client.Internal (+ request,+ requestRaw,+ requestStreaming,+) where++import Conduit (+ ConduitT,+ awaitForever,+ bracketP,+ filterC,+ takeWhileC,+ transPipe,+ yield,+ (.|),+ )+import Control.Exception (SomeException, catch, try)+import Control.Monad.IO.Class (MonadIO (liftIO))+import Control.Monad.IO.Unlift (MonadUnliftIO)+import Control.Monad.Trans.Resource (runResourceT)+import Control.Retry qualified as Retry+import Data.Aeson (FromJSON, ToJSON, eitherDecode, encode)+import Data.ByteString (ByteString)+import Data.ByteString qualified as BS+import Data.ByteString.Lazy qualified as BSL+import Data.CaseInsensitive (CI)+import Data.Conduit.Binary qualified as CB+import Data.Conduit.Combinators (repeatM)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding qualified as TE+import Network.HTTP.Client+import Network.HTTP.Types (statusCode)+import Ollama.Client (OllamaClient (..))+import Ollama.Client.Config (LogLevel (..), OllamaClientConfig (..), RetryPolicy (..))+import Ollama.Error (OllamaError (..), isRetryable)++-- ---------------------------------------------------------------------------+-- Public API+-- ---------------------------------------------------------------------------++{- | Dispatch a non-streaming JSON API request.++Applies authentication headers, retry policy, lifecycle callbacks,+and structured logging as configured on the client.++@since 1.0.0.0+-}+request ::+ (MonadIO m, ToJSON req, FromJSON resp) =>+ OllamaClient ->+ ByteString ->+ Text ->+ Maybe req ->+ m (Either OllamaError resp)+request client reqMethod endpoint mbPayload =+ liftIO $+ withRetry client endpoint $+ executeJsonRequest client reqMethod endpoint mbPayload++{- | Dispatch a raw (non-JSON) request returning raw bytes.++Used for blob endpoints where the response body is not JSON.+Applies authentication headers, retry policy, lifecycle callbacks,+and structured logging.++@since 1.0.0.0+-}+requestRaw ::+ (MonadIO m) =>+ OllamaClient ->+ ByteString ->+ Text ->+ Maybe ByteString ->+ m (Either OllamaError ByteString)+requestRaw client reqMethod endpoint mbPayload =+ liftIO $+ withRetry client endpoint $+ executeRawRequest client reqMethod endpoint mbPayload++{- | Dispatch a conduit-based streaming API request.++Opens an HTTP response stream, reads line-delimited JSON chunks as they arrive,+decodes each chunk, and yields values into a 'ConduitT'. Stops when the server+closes the connection.++@since 1.0.0.0+-}+requestStreaming ::+ (MonadUnliftIO m, ToJSON req, FromJSON resp) =>+ OllamaClient ->+ Text ->+ req ->+ ConduitT () resp m ()+requestStreaming OllamaClient {..} endpoint payload = do+ let fullUrl = T.unpack $ configBaseUrl clientConfig <> endpoint+ cfg = clientConfig+ initReq <- liftIO $ parseRequest fullUrl+ let req =+ initReq+ { method = "POST"+ , responseTimeout = responseTimeoutNone+ , requestHeaders =+ [ ("Content-Type", "application/json")+ , ("Accept", "application/x-ndjson, application/json")+ , ("Connection", "keep-alive")+ ]+ ++ authHeader cfg+ ++ configHeaders cfg+ , requestBody = RequestBodyLBS (encode payload)+ }+ transPipe runResourceT $+ bracketP+ (responseOpen req clientManager)+ responseClose+ ( \resp -> do+ let bodyReader = responseBody resp+ readChunk = liftIO $ brRead bodyReader `catch` \(_ :: HttpException) -> pure BS.empty+ source = repeatM readChunk .| takeWhileC (not . BS.null)+ source .| CB.lines .| filterC (not . BS.null) .| parseAndYield+ )+ where+ parseAndYield = awaitForever $ \line -> do+ case eitherDecode (BSL.fromStrict line) of+ Left _err -> pure ()+ Right val -> yield val++-- ---------------------------------------------------------------------------+-- Core request execution+-- ---------------------------------------------------------------------------++-- | Execute a JSON request without retry wrapping.+executeJsonRequest ::+ (ToJSON req, FromJSON resp) =>+ OllamaClient ->+ ByteString ->+ Text ->+ Maybe req ->+ IO (Either OllamaError resp)+executeJsonRequest OllamaClient {..} reqMethod endpoint mbPayload = do+ let fullUrl = T.unpack $ configBaseUrl clientConfig <> endpoint+ cfg = clientConfig+ initReq <- parseRequest fullUrl+ let timeoutMicro = configTimeout cfg * 1000000+ req =+ initReq+ { method = reqMethod+ , responseTimeout = responseTimeoutMicro timeoutMicro+ , requestHeaders =+ [("Content-Type", "application/json"), ("Accept", "application/json")]+ ++ authHeader cfg+ ++ configHeaders cfg+ , requestBody = maybe mempty (RequestBodyLBS . encode) mbPayload+ }+ result <- try @HttpException $ httpLbs req clientManager+ case result of+ Left httpErr -> pure $ Left $ HttpError httpErr+ Right resp -> do+ let status = statusCode (responseStatus resp)+ body = responseBody resp+ if status >= 200 && status < 300+ then+ if BSL.null body+ then case eitherDecode "null" of+ Left err -> pure $ Left $ DecodeError (T.pack err) (BSL.toStrict body)+ Right val -> pure $ Right val+ else case eitherDecode body of+ Left err -> pure $ Left $ DecodeError (T.pack err) (BSL.toStrict body)+ Right val -> pure $ Right val+ else pure $ Left $ ApiError status (TE.decodeUtf8 . BSL.toStrict $ body)++-- | Execute a raw byte request without retry wrapping.+executeRawRequest ::+ OllamaClient ->+ ByteString ->+ Text ->+ Maybe ByteString ->+ IO (Either OllamaError ByteString)+executeRawRequest OllamaClient {..} reqMethod endpoint mbPayload = do+ let fullUrl = T.unpack $ configBaseUrl clientConfig <> endpoint+ cfg = clientConfig+ initReq <- parseRequest fullUrl+ let timeoutMicro = configTimeout cfg * 1000000+ req =+ initReq+ { method = reqMethod+ , responseTimeout = responseTimeoutMicro timeoutMicro+ , requestHeaders = authHeader cfg ++ configHeaders cfg+ , requestBody = maybe mempty RequestBodyBS mbPayload+ }+ result <- try @HttpException $ httpLbs req clientManager+ case result of+ Left httpErr -> pure $ Left $ HttpError httpErr+ Right resp -> do+ let status = statusCode (responseStatus resp)+ if status >= 200 && status < 300+ then pure $ Right (BSL.toStrict $ responseBody resp)+ else pure $ Left $ ApiError status (TE.decodeUtf8 . BSL.toStrict $ body)+ where+ body = responseBody resp++-- ---------------------------------------------------------------------------+-- Retry logic+-- ---------------------------------------------------------------------------++{- | Wrap an IO action with the client's configured retry policy, lifecycle+callbacks, and structured logging.++Only retries when 'isRetryable' returns 'True' for the error.+-}+withRetry ::+ OllamaClient ->+ Text ->+ IO (Either OllamaError a) ->+ IO (Either OllamaError a)+withRetry OllamaClient {clientConfig = cfg} endpoint action = do+ let policy = toRetryPolicy (configRetry cfg)+ shouldRetry _status (Left err) = do+ logMsg cfg Warn $ "Retryable error on " <> endpoint <> ", will retry: " <> T.pack (show err)+ pure $ isRetryable err+ shouldRetry _status (Right _) = pure False+ fireCallback (configOnStart cfg)+ logMsg cfg Debug $ "Requesting " <> endpoint+ result <- Retry.retrying policy shouldRetry (const action)+ case result of+ Left err -> do+ fireCallback (configOnError cfg)+ logMsg cfg Error $ "Request failed: " <> endpoint <> " — " <> T.pack (show err)+ Right _ -> do+ fireCallback (configOnSuccess cfg)+ logMsg cfg Info $ "Request succeeded: " <> endpoint+ pure result++-- | Map our 'RetryPolicy' ADT to the @retry@ package's 'Retry.RetryPolicyM'.+toRetryPolicy :: RetryPolicy -> Retry.RetryPolicyM IO+toRetryPolicy NoRetry = Retry.limitRetries 0+toRetryPolicy (ConstantRetry count delaySec) =+ Retry.constantDelay (delaySec * 1000000) <> Retry.limitRetries count+toRetryPolicy (ExponentialRetry count initialDelayMs) =+ Retry.exponentialBackoff (initialDelayMs * 1000) <> Retry.limitRetries count++-- ---------------------------------------------------------------------------+-- Helpers+-- ---------------------------------------------------------------------------++-- | Build the Authorization header if an API key is configured.+authHeader :: OllamaClientConfig -> [(CI ByteString, ByteString)]+authHeader cfg = case configApiKey cfg of+ Nothing -> []+ Just key -> [("Authorization", "Bearer " <> TE.encodeUtf8 key)]++-- | Fire an optional callback, silently ignoring exceptions.+fireCallback :: Maybe (IO ()) -> IO ()+fireCallback Nothing = pure ()+fireCallback (Just cb) = cb `catch` \(_ :: SomeException) -> pure ()++-- | Log a message via the configured logger, if present.+logMsg :: OllamaClientConfig -> LogLevel -> Text -> IO ()+logMsg cfg level msg = case configLogger cfg of+ Nothing -> pure ()+ Just logger -> logger level msg `catch` \(_ :: SomeException) -> pure ()
+ src/Ollama/Conversation.hs view
@@ -0,0 +1,110 @@+{- |+Module : Ollama.Conversation+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Conversation store typeclass and in-memory transactional implementation.++@since 1.0.0.0+-}+module Ollama.Conversation (+ Conversation (..),+ ConversationStore (..),+ InMemoryStore (..),+ initInMemoryStore,+ saveConversationInMemory,+ loadConversationInMemory,+ listConversationsInMemory,+ deleteConversationInMemory,+) where++import Control.Concurrent.STM (TVar, atomically, modifyTVar', newTVarIO, readTVar, readTVarIO)+import Control.Monad.IO.Class (MonadIO, liftIO)+import Control.Monad.Reader (ReaderT, ask)+import Data.Aeson (FromJSON, ToJSON)+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Time (UTCTime)+import GHC.Generics (Generic)+import Ollama.Types.Common (ModelName)+import Ollama.Types.Message (Message)++{- | Recorded chat conversation session.++@since 1.0.0.0+-}+data Conversation = Conversation+ { conversationId :: !Text+ , messages :: ![Message]+ , model :: !ModelName+ , createdAt :: !UTCTime+ , lastUpdated :: !UTCTime+ }+ deriving stock (Eq, Show, Generic)+ deriving anyclass (ToJSON, FromJSON)++{- | Abstract interface for persisting and managing chat conversations.++@since 1.0.0.0+-}+class (Monad m) => ConversationStore m where+ saveConversation :: Conversation -> m ()+ loadConversation :: Text -> m (Maybe Conversation)+ listConversations :: m [Conversation]+ deleteConversation :: Text -> m Bool++{- | Thread-safe transactional in-memory conversation store.++@since 1.0.0.0+-}+newtype InMemoryStore = InMemoryStore (TVar (Map Text Conversation))++{- | Initialize a new empty 'InMemoryStore'.++@since 1.0.0.0+-}+initInMemoryStore :: IO InMemoryStore+initInMemoryStore = InMemoryStore <$> newTVarIO Map.empty++{- | Save a conversation into an 'InMemoryStore'.++@since 1.0.0.0+-}+saveConversationInMemory :: (MonadIO m) => InMemoryStore -> Conversation -> m ()+saveConversationInMemory (InMemoryStore ref) conv = liftIO $ atomically $ do+ modifyTVar' ref (Map.insert (conversationId conv) conv)++{- | Load a conversation by ID from an 'InMemoryStore'.++@since 1.0.0.0+-}+loadConversationInMemory :: (MonadIO m) => InMemoryStore -> Text -> m (Maybe Conversation)+loadConversationInMemory (InMemoryStore ref) cid = liftIO $ Map.lookup cid <$> readTVarIO ref++{- | List all stored conversations from an 'InMemoryStore'.++@since 1.0.0.0+-}+listConversationsInMemory :: (MonadIO m) => InMemoryStore -> m [Conversation]+listConversationsInMemory (InMemoryStore ref) = liftIO $ Map.elems <$> readTVarIO ref++{- | Delete a conversation by ID from an 'InMemoryStore'.++@since 1.0.0.0+-}+deleteConversationInMemory :: (MonadIO m) => InMemoryStore -> Text -> m Bool+deleteConversationInMemory (InMemoryStore ref) cid = liftIO $ atomically $ do+ m <- readTVar ref+ if Map.member cid m+ then modifyTVar' ref (Map.delete cid) >> pure True+ else pure False++instance (MonadIO m) => ConversationStore (ReaderT InMemoryStore m) where+ saveConversation conv = ask >>= \store -> saveConversationInMemory store conv+ loadConversation cid = ask >>= \store -> loadConversationInMemory store cid+ listConversations = ask >>= \store -> listConversationsInMemory store+ deleteConversation cid = ask >>= \store -> deleteConversationInMemory store cid
+ src/Ollama/Error.hs view
@@ -0,0 +1,66 @@+{- |+Module : Ollama.Error+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Core error type definitions for the Ollama library.++@since 3.0.0.0+-}+module Ollama.Error (+ OllamaError (..),+ isRetryable,+ throwOllama,+) where++import Control.Exception (Exception, throwIO)+import Data.ByteString (ByteString)+import Data.Text (Text)+import Network.HTTP.Client (HttpException)++{- | Unified error type representing all failure modes in the Ollama client.++@since 3.0.0.0+-}+data OllamaError+ = -- | HTTP transport failure (connection error, DNS failure, etc.)+ HttpError !HttpException+ | -- | Ollama API returned an error response (HTTP status code + message)+ ApiError !Int !Text+ | -- | Failed to decode JSON response body+ DecodeError !Text !ByteString+ | -- | Request timed out waiting for a response+ TimeoutError+ | -- | Client-side validation failure before sending the request+ InvalidRequest !Text+ deriving stock (Show)++instance Exception OllamaError++instance Eq OllamaError where+ ApiError s1 t1 == ApiError s2 t2 = s1 == s2 && t1 == t2+ DecodeError t1 _ == DecodeError t2 _ = t1 == t2+ TimeoutError == TimeoutError = True+ InvalidRequest t1 == InvalidRequest t2 = t1 == t2+ HttpError _ == HttpError _ = False+ _ == _ = False++{- | Determine whether an error is transient and safe to retry.++@since 3.0.0.0+-}+isRetryable :: OllamaError -> Bool+isRetryable (HttpError _) = True+isRetryable TimeoutError = True+isRetryable (ApiError status _) | status >= 500 = True+isRetryable _ = False++{- | Helper to throw an 'OllamaError' as an exception.++@since 3.0.0.0+-}+throwOllama :: OllamaError -> IO a+throwOllama = throwIO
+ src/Ollama/MCP.hs view
@@ -0,0 +1,260 @@+{-# LANGUAGE ExplicitNamespaces #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE PatternSynonyms #-}+{-# LANGUAGE RecordWildCards #-}++{- |+Module : Ollama.MCP+Description : Model Context Protocol (MCP) tool conversion and server bridging for Ollama using mcp-server.+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Provides direct bidirectional conversion between Ollama function calling definitions ('Tool')+and Model Context Protocol ('ToolDefinition', 'Content', etc.) directly using the official+Hackage @mcp-server@ package.++@since 0.4.0.0+-}+module Ollama.MCP (+ -- * Re-exported MCP.Server Types & Functions+ Content (..),+ ContentImageData (..),+ ContentAudioData (..),+ ResourceContent (..),+ PromptDefinition (..),+ ResourceDefinition (..),+ ToolDefinition (..),+ ArgumentDefinition (..),+ type McpSchema,+ pattern McpSchema,+ SchemaType (..),+ schemaDescription,+ schemaShape,+ schema,+ describedSchema,+ mkToolDefinition,+ mkPromptDefinition,+ mkResourceDefinition,+ McpServerInfo (..),+ McpServerHandlers (..),+ ServerCapabilities (..),+ PromptCapabilities (..),+ ResourceCapabilities (..),+ ToolCapabilities (..),+ LoggingCapabilities (..),+ PromptListHandler,+ PromptGetHandler,+ ResourceListHandler,+ ResourceReadHandler,+ ToolListHandler,+ ToolCallHandler,+ PromptName,+ ToolName,+ ArgumentName,+ ArgumentValue,+ URI,+ parseURI,+ runMcpServerStdio,+ runMcpServerHttp,+ runMcpServerHttpWithConfig,+ HttpConfig (..),+ jsonValueToText,+ McpProtocolError,++ -- * Conversion between Ollama and mcp-server Types+ toolToMcpDefinition,+ mcpDefinitionToTool,+ toolCallToMcpArgs,+ mcpContentToToolOutput,+) where++import Data.Aeson (Value (..), encode)+import Data.Map.Strict qualified as Map+import Data.Maybe (fromMaybe)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Lazy qualified as TL+import Data.Text.Lazy.Encoding qualified as TEncoding+import MCP.Server (+ HttpConfig (..),+ runMcpServerHttp,+ runMcpServerHttpWithConfig,+ runMcpServerStdio,+ )+import MCP.Server.Types as ServerTypes (+ ArgumentDefinition (..),+ ArgumentName,+ ArgumentValue,+ Content (..),+ ContentAudioData (..),+ ContentImageData (..),+ Error (..),+ LoggingCapabilities (..),+ McpServerHandlers (..),+ McpServerInfo (..),+ PromptCapabilities (..),+ PromptDefinition (..),+ PromptGetHandler,+ PromptListHandler,+ PromptName,+ ResourceCapabilities (..),+ ResourceContent (..),+ ResourceDefinition (..),+ ResourceListHandler,+ ResourceReadHandler,+ Schema (..),+ SchemaType (..),+ ServerCapabilities (..),+ ToolCallHandler,+ ToolCapabilities (..),+ ToolDefinition (..),+ ToolListHandler,+ ToolName,+ URI,+ describedSchema,+ mkPromptDefinition,+ mkResourceDefinition,+ mkToolDefinition,+ parseURI,+ schema,+ )+import Ollama.Types.Tool (+ FunctionDef (..),+ FunctionParameters (..),+ Tool (..),+ ToolCall (..),+ ToolCallFunction (..),+ )++-- | Alias for 'MCP.Server.Types.Error' to prevent collisions with 'Ollama.Error'.+type McpProtocolError = ServerTypes.Error++-- | Alias for 'MCP.Server.Types.Schema' to avoid name collision with 'Ollama.Types.Format.SchemaBuilder.Schema'.+type McpSchema = ServerTypes.Schema++-- | Pattern synonym for matching or constructing an 'McpSchema' ('ServerTypes.Schema').+pattern McpSchema :: Maybe Text -> SchemaType -> McpSchema+pattern McpSchema desc shape = ServerTypes.Schema desc shape++{-# COMPLETE McpSchema #-}++-- | Convert an Aeson 'Value' to 'Text'. Strings are returned unquoted, while other JSON values are serialized.+jsonValueToText :: Value -> Text+jsonValueToText (String t) = t+jsonValueToText v = TL.toStrict (TEncoding.decodeUtf8 (encode v))++-- | Convert an Ollama 'Tool' into an MCP 'ToolDefinition' from @mcp-server@.+toolToMcpDefinition :: Tool -> ToolDefinition+toolToMcpDefinition Tool {toolFunction = FunctionDef {..}} =+ let desc = fromMaybe "" fnDescription+ convertParam :: FunctionParameters -> ServerTypes.Schema+ convertParam p =+ let pDesc = fpDescription p+ shape = case fpType p of+ "string" -> SchemaString (fpEnum p)+ "integer" -> SchemaInteger+ "number" -> SchemaNumber+ "boolean" -> SchemaBoolean+ "array" -> SchemaArray (ServerTypes.Schema Nothing (SchemaString Nothing))+ "object" ->+ let props = maybe [] (\pm -> [(k, convertParam v) | (k, v) <- Map.toList pm]) (fpProperties p)+ req = fromMaybe [] (fpRequired p)+ in SchemaObject props req+ _ -> SchemaString Nothing+ in ServerTypes.Schema pDesc shape+ inputSchema = case fnParameters of+ Just p -> convertParam p+ Nothing -> ServerTypes.Schema Nothing (SchemaObject [] [])+ in mkToolDefinition fnName desc inputSchema++-- | Convert an MCP 'ToolDefinition' from @mcp-server@ into an Ollama 'Tool'.+mcpDefinitionToTool :: ToolDefinition -> Tool+mcpDefinitionToTool ToolDefinition {..} =+ let convertSchema :: ServerTypes.Schema -> FunctionParameters+ convertSchema (ServerTypes.Schema mDesc shape) =+ case shape of+ SchemaString mEnum ->+ FunctionParameters+ { fpType = "string"+ , fpProperties = Nothing+ , fpRequired = Nothing+ , fpAdditionalProperties = Nothing+ , fpDescription = mDesc+ , fpEnum = mEnum+ }+ SchemaInteger ->+ FunctionParameters+ { fpType = "integer"+ , fpProperties = Nothing+ , fpRequired = Nothing+ , fpAdditionalProperties = Nothing+ , fpDescription = mDesc+ , fpEnum = Nothing+ }+ SchemaNumber ->+ FunctionParameters+ { fpType = "number"+ , fpProperties = Nothing+ , fpRequired = Nothing+ , fpAdditionalProperties = Nothing+ , fpDescription = mDesc+ , fpEnum = Nothing+ }+ SchemaBoolean ->+ FunctionParameters+ { fpType = "boolean"+ , fpProperties = Nothing+ , fpRequired = Nothing+ , fpAdditionalProperties = Nothing+ , fpDescription = mDesc+ , fpEnum = Nothing+ }+ SchemaArray _itemSchema ->+ FunctionParameters+ { fpType = "array"+ , fpProperties = Nothing+ , fpRequired = Nothing+ , fpAdditionalProperties = Nothing+ , fpDescription = mDesc+ , fpEnum = Nothing+ }+ SchemaObject props req ->+ let propMap = Map.fromList [(k, convertSchema s) | (k, s) <- props]+ in FunctionParameters+ { fpType = "object"+ , fpProperties = if Map.null propMap then Nothing else Just propMap+ , fpRequired = if null req then Nothing else Just req+ , fpAdditionalProperties = Nothing+ , fpDescription = mDesc+ , fpEnum = Nothing+ }+ params = convertSchema toolDefinitionInputSchema+ in Tool+ { toolType = "function"+ , toolFunction =+ FunctionDef+ { fnName = toolDefinitionName+ , fnDescription = if T.null toolDefinitionDescription then Nothing else Just toolDefinitionDescription+ , fnParameters = Just params+ , fnStrict = Nothing+ }+ }++-- | Convert an Ollama 'ToolCall' into an MCP tool name and arguments list for @mcp-server@.+toolCallToMcpArgs :: ToolCall -> (Text, [(Text, Text)])+toolCallToMcpArgs (ToolCall (ToolCallFunction name args)) =+ (name, [(k, jsonValueToText v) | (k, v) <- Map.toList args])++-- | Extract textual output from an MCP 'Content' (from @mcp-server@).+mcpContentToToolOutput :: Content -> Text+mcpContentToToolOutput (ContentText t) = t+mcpContentToToolOutput (ContentImage (ContentImageData _ mime)) = "[Image: " <> mime <> "]"+mcpContentToToolOutput (ContentAudio (ContentAudioData _ mime)) = "[Audio: " <> mime <> "]"+mcpContentToToolOutput (ContentEmbeddedResource res) =+ "[Resource: " <> T.pack (show (resourceUri res)) <> "]"+mcpContentToToolOutput (ContentResourceLink resDef) =+ "[Resource: " <> resourceDefinitionURI resDef <> "]"+mcpContentToToolOutput (ContentAnnotated _ inner) = mcpContentToToolOutput inner
+ src/Ollama/Streaming.hs view
@@ -0,0 +1,41 @@+{- |+Module : Ollama.Streaming+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Streaming pipeline utilities, stream combinators, and 'HasDone' class for API responses.++@since 1.0.0.0+-}+module Ollama.Streaming (+ HasDone (..),+ collectStream,+ foldStream,+) where++import Conduit (ConduitT, foldlC, runConduit, sinkList, (.|))+import Control.Monad.IO.Unlift (MonadUnliftIO)++{- | Typeclass for responses that indicate completion in a stream.++@since 1.0.0.0+-}+class HasDone a where+ isDone :: a -> Bool++{- | Collect all yielded items from a streaming conduit into a list.++@since 1.0.0.0+-}+collectStream :: (MonadUnliftIO m) => ConduitT () a m () -> m [a]+collectStream stream = runConduit $ stream .| sinkList++{- | Fold over all yielded items from a streaming conduit.++@since 1.0.0.0+-}+foldStream :: (MonadUnliftIO m) => (b -> a -> b) -> b -> ConduitT () a m () -> m b+foldStream f acc stream = runConduit $ stream .| foldlC f acc
+ src/Ollama/Testing.hs view
@@ -0,0 +1,162 @@+{- |+Module : Ollama.Testing+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Mock client and test helpers for unit testing code without a live Ollama server.++@since 1.0.0.0+-}+module Ollama.Testing (+ newMockClient,+ withMockClient,+ mockGenerateResponse,+ mockChatResponse,+ mockEmbedResponse,+ mockListModelsResponse,+) where++import Control.Monad.IO.Class (MonadIO (liftIO))+import Control.Monad.IO.Unlift (MonadUnliftIO)+import Data.ByteString (ByteString)+import Data.ByteString.Char8 qualified as BS8+import Data.IORef (atomicModifyIORef, newIORef)+import Data.Text (Text)+import Data.Time (Day (..), UTCTime (..))+import Network.HTTP.Client (+ defaultManagerSettings,+ managerRawConnection,+ newManager,+ )+import Network.HTTP.Client.Internal (makeConnection)+import Ollama.API.Chat (ChatResponse (..))+import Ollama.API.Embed (EmbedResponse (..))+import Ollama.API.Generate (GenerateResponse (..))+import Ollama.Client (OllamaClient (..), closeClient)+import Ollama.Client.Config (defaultConfig)+import Ollama.Types.Common (Digest (..), Duration (..), ModelName (..))+import Ollama.Types.Message (assistantMessage)+import Ollama.Types.Model (ListResponse (..), ModelDetails (..), ModelInfo (..))++{- | Construct an 'OllamaClient' that returns mock HTTP response bytes for any request.++@since 1.0.0.0+-}+newMockClient :: (MonadIO m) => ByteString -> m OllamaClient+newMockClient bodyBytes = liftIO $ do+ let httpResponse =+ "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: "+ <> BS8.pack (show (BS8.length bodyBytes))+ <> "\r\n\r\n"+ <> bodyBytes+ ref <- newIORef [httpResponse]+ let mockConn =+ makeConnection+ ( atomicModifyIORef ref $ \case+ [] -> ([], "")+ (x : xs) -> (xs, x)+ )+ (\_ -> pure ())+ (pure ())+ settings = defaultManagerSettings {managerRawConnection = pure (\_ _ _ -> mockConn)}+ mgr <- newManager settings+ pure $ OllamaClient mgr defaultConfig True++{- | Resource bracket helper to create a mock client, execute an action, and close resources.++@since 1.0.0.0+-}+withMockClient :: (MonadUnliftIO m) => ByteString -> (OllamaClient -> m a) -> m a+withMockClient body action = do+ client <- newMockClient body+ res <- action client+ closeClient client+ pure res++{- | Construct a mock 'GenerateResponse'.++@since 1.0.0.0+-}+mockGenerateResponse :: ModelName -> Text -> GenerateResponse+mockGenerateResponse model text =+ GenerateResponse+ { grModel = model+ , grCreatedAt = UTCTime (ModifiedJulianDay 60000) 0+ , grResponse = text+ , grDone = True+ , grDoneReason = Just "stop"+ , grContext = Just [1, 2, 3]+ , grTotalDuration = Just (Duration 1000000000)+ , grLoadDuration = Just (Duration 100000000)+ , grPromptEvalCount = Just 10+ , grPromptEvalDuration = Just (Duration 200000000)+ , grEvalCount = Just 20+ , grEvalDuration = Just (Duration 700000000)+ , grThinking = Nothing+ , grImage = Nothing+ }++{- | Construct a mock 'ChatResponse'.++@since 1.0.0.0+-}+mockChatResponse :: ModelName -> Text -> ChatResponse+mockChatResponse model text =+ ChatResponse+ { crModel = model+ , crCreatedAt = UTCTime (ModifiedJulianDay 60000) 0+ , crMessage = Just (assistantMessage text)+ , crDone = True+ , crDoneReason = Just "stop"+ , crTotalDuration = Just (Duration 1000000000)+ , crLoadDuration = Just (Duration 100000000)+ , crPromptEvalCount = Just 10+ , crPromptEvalDuration = Just (Duration 200000000)+ , crEvalCount = Just 20+ , crEvalDuration = Just (Duration 700000000)+ }++{- | Construct a mock 'EmbedResponse'.++@since 1.0.0.0+-}+mockEmbedResponse :: ModelName -> [[Double]] -> EmbedResponse+mockEmbedResponse model vectors =+ EmbedResponse+ { erModel = model+ , erEmbeddings = vectors+ , erTotalDuration = Just (Duration 500000000)+ , erLoadDuration = Just (Duration 50000000)+ , erPromptEvalCount = Just 8+ }++{- | Construct a mock 'ListResponse'.++@since 1.0.0.0+-}+mockListModelsResponse :: [ModelName] -> ListResponse+mockListModelsResponse names =+ ListResponse+ { models = map mkModel names+ }+ where+ mkModel name =+ ModelInfo+ { miName = name+ , miModel = name+ , miModifiedAt = UTCTime (ModifiedJulianDay 60000) 0+ , miSize = 4000000000+ , miDigest = Digest "sha256:1234567890abcdef"+ , miDetails =+ ModelDetails+ { parentModel = Nothing+ , format = "gguf"+ , family = "llama"+ , families = ["llama"]+ , parameterSize = "7B"+ , quantizationLevel = "Q4_K_M"+ }+ }
+ src/Ollama/Types.hs view
@@ -0,0 +1,38 @@+{- |+Module : Ollama.Types+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Re-export all domain types for convenience.++@since 3.0.0.0+-}+module Ollama.Types (+ module Ollama.Types.Common,+ Role (System, User, Assistant),+ Message (..),+ userMessage,+ systemMessage,+ assistantMessage,+ toolMessage,+ toolResultMessage,+ imageMessage,+ Tool (..),+ FunctionDef (..),+ FunctionParameters (..),+ ToolCall (..),+ ToolCallFunction (..),+ module Ollama.Types.Options,+ module Ollama.Types.Format,+ module Ollama.Types.Model,+) where++import Ollama.Types.Common+import Ollama.Types.Format+import Ollama.Types.Message+import Ollama.Types.Model+import Ollama.Types.Options+import Ollama.Types.Tool
+ src/Ollama/Types/Common.hs view
@@ -0,0 +1,152 @@+{- |+Module : Ollama.Types.Common+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Common newtypes and domain primitives for the Ollama API.++@since 3.0.0.0+-}+module Ollama.Types.Common (+ ModelName (..),+ mkModelName,+ Digest (..),+ Base64Image (..),+ Duration (..),+ durationToSeconds,+ durationToMillis,+ tokensPerSecond,+ Version (..),+ Think (..),+ ThinkingLevel (..),+) where++import Data.Aeson (FromJSON (..), ToJSON (..), Value (..), object, withText, (.:), (.=))+import Data.Aeson.Types (typeMismatch)+import Data.Hashable (Hashable)+import Data.Int (Int64)+import Data.String (IsString)+import Data.Text (Text)+import Data.Text qualified as T+import GHC.Generics (Generic)++{- | Model name following the @model:tag@ format.++@since 3.0.0.0+-}+newtype ModelName = ModelName {unModelName :: Text}+ deriving newtype (Eq, Ord, Show, IsString, ToJSON, FromJSON, Hashable)++{- | Smart constructor that validates that a model name is non-empty.++@since 3.0.0.0+-}+mkModelName :: Text -> Either Text ModelName+mkModelName t+ | T.null t = Left "Model name cannot be empty"+ | otherwise = Right (ModelName t)++{- | SHA256 digest of a layer blob.++@since 3.0.0.0+-}+newtype Digest = Digest {unDigest :: Text}+ deriving newtype (Eq, Ord, Show, ToJSON, FromJSON, Hashable)++{- | Base64-encoded image data for multimodal inputs.++@since 3.0.0.0+-}+newtype Base64Image = Base64Image {unBase64Image :: Text}+ deriving newtype (Eq, Show, ToJSON, FromJSON)++{- | Duration in nanoseconds as returned by the Ollama API.++@since 3.0.0.0+-}+newtype Duration = Duration {durationNanos :: Int64}+ deriving newtype (Eq, Ord, Show, ToJSON, FromJSON, Num)++{- | Convert duration nanoseconds to seconds.++@since 3.0.0.0+-}+durationToSeconds :: Duration -> Double+durationToSeconds (Duration ns) = fromIntegral ns / 1e9++{- | Convert duration nanoseconds to milliseconds.++@since 1.0.0.0+-}+durationToMillis :: Duration -> Double+durationToMillis (Duration ns) = fromIntegral ns / 1e6++{- | Calculate tokens per second (tokens\/s) given a token count and a 'Duration'.++@since 1.0.0.0+-}+tokensPerSecond :: Int -> Duration -> Double+tokensPerSecond count dur =+ let secs = durationToSeconds dur+ in if secs <= 0 then 0.0 else fromIntegral count / secs++{- | Ollama server engine version string.++@since 3.0.0.0+-}+newtype Version = Version {unVersion :: Text}+ deriving stock (Eq, Show, Generic)++instance FromJSON Version where+ parseJSON = \case+ String s -> pure $ Version s+ Object v -> Version <$> v .: "version"+ v -> typeMismatch "Version" v++instance ToJSON Version where+ toJSON (Version s) = object ["version" .= s]++{- | Thinking level settings for reasoning models.++@since 3.0.0.0+-}+data ThinkingLevel = ThinkLow | ThinkMedium | ThinkHigh | ThinkMax+ deriving stock (Eq, Show, Bounded, Enum, Generic)++instance ToJSON ThinkingLevel where+ toJSON ThinkLow = String "low"+ toJSON ThinkMedium = String "medium"+ toJSON ThinkHigh = String "high"+ toJSON ThinkMax = String "max"++instance FromJSON ThinkingLevel where+ parseJSON = withText "ThinkingLevel" $ \case+ "low" -> pure ThinkLow+ "medium" -> pure ThinkMedium+ "high" -> pure ThinkHigh+ "max" -> pure ThinkMax+ other -> fail $ "Unknown thinking level: " <> T.unpack other++{- | Controls whether a thinking/reasoning model outputs its thoughts.++@since 3.0.0.0+-}+data Think+ = ThinkEnabled+ | ThinkDisabled+ | ThinkLevel !ThinkingLevel+ deriving stock (Eq, Show, Generic)++instance ToJSON Think where+ toJSON ThinkEnabled = Bool True+ toJSON ThinkDisabled = Bool False+ toJSON (ThinkLevel lvl) = toJSON lvl++instance FromJSON Think where+ parseJSON (Bool True) = pure ThinkEnabled+ parseJSON (Bool False) = pure ThinkDisabled+ parseJSON (String s) = ThinkLevel <$> parseJSON (String s)+ parseJSON v = typeMismatch "Think" v
+ src/Ollama/Types/Format.hs view
@@ -0,0 +1,56 @@+{-# LANGUAGE AllowAmbiguousTypes #-}++{- |+Module : Ollama.Types.Format+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Response formatting specifications for structured model output.++@since 1.0.0.0+-}+module Ollama.Types.Format (+ Format (..),+ formatFor,+ module Ollama.Types.Format.SchemaBuilder,+ module Ollama.Types.Format.SchemaDerive,+) where++import Data.Aeson+import Ollama.Types.Format.SchemaBuilder+import Ollama.Types.Format.SchemaDerive++{- | Response output format hint.++@since 1.0.0.0+-}+data Format+ = -- | Constrain response output to generic valid JSON+ JsonFormat+ | -- | Constrain response output to a specific JSON Schema+ SchemaFormat !Schema+ deriving stock (Eq, Show)++instance ToJSON Format where+ toJSON JsonFormat = String "json"+ toJSON (SchemaFormat sch) = toJSON sch++instance FromJSON Format where+ parseJSON (String "json") = pure JsonFormat+ parseJSON v = SchemaFormat <$> parseJSON v++{- | Produce a 'Format' value suitable for the @genFormat@ \/ @chatFormat@+request fields.++@+req = (generateRequest model prompt)+ { genFormat = Just ('formatFor' \@Person) }+@++@since 0.4.0.0+-}+formatFor :: forall a. (ToSchema a) => Format+formatFor = SchemaFormat (schemaFor @a)
+ src/Ollama/Types/Format/SchemaBuilder.hs view
@@ -0,0 +1,223 @@+{- |+Module : Ollama.Types.Format.SchemaBuilder+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++DSL for constructing structured JSON Schemas for Ollama's structured output API.++@since 1.0.0.0+-}+module Ollama.Types.Format.SchemaBuilder (+ JsonType (..),+ Property (..),+ Schema (..),+ SchemaBuilder,+ emptyObject,+ addProperty,+ addObjectProperty,+ requireField,+ requireFields,+ buildSchema,+ objectOf,+ arrayOf,+ printSchema,+ (|+),+ (|++),+ (|!),+ (|!!),+) where++import Data.Aeson+import Data.Map.Strict qualified as HM+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Lazy qualified as TL+import Data.Text.Lazy.Encoding qualified as TEncoding+import GHC.Generics (Generic)++{- | Supported JSON primitive and compound types.++@since 1.0.0.0+-}+data JsonType+ = JString+ | JNumber+ | JInteger+ | JBoolean+ | JNull+ | JArray !JsonType+ | JObject !Schema+ deriving stock (Show, Eq, Generic)++instance ToJSON JsonType where+ toJSON JString = "string"+ toJSON JNumber = "number"+ toJSON JInteger = "integer"+ toJSON JBoolean = "boolean"+ toJSON JNull = "null"+ toJSON (JArray _) = "array"+ toJSON (JObject _) = "object"++{- | Property metadata for schema properties.++@since 1.0.0.0+-}+newtype Property = Property JsonType+ deriving stock (Show, Eq, Generic)++instance ToJSON Property where+ toJSON (Property (JArray itemType)) =+ object ["type" .= ("array" :: Text), "items" .= Property itemType]+ toJSON (Property (JObject schema)) = toJSON schema+ toJSON (Property typ) = object ["type" .= typ]++{- | JSON schema specification.++@since 1.0.0.0+-}+data Schema = Schema+ { schemaProperties :: !(HM.Map Text Property)+ , schemaRequired :: ![Text]+ }+ deriving stock (Show, Eq, Generic)++instance ToJSON Schema where+ toJSON (Schema props req) =+ object+ [ "type" .= ("object" :: Text)+ , "properties" .= props+ , "required" .= req+ ]++instance FromJSON JsonType where+ parseJSON = withText "JsonType" $ \case+ "string" -> pure JString+ "number" -> pure JNumber+ "integer" -> pure JInteger+ "boolean" -> pure JBoolean+ "null" -> pure JNull+ "array" -> pure (JArray JString)+ "object" -> pure (JObject (Schema HM.empty []))+ other -> fail $ "Unknown JsonType: " <> T.unpack other++instance FromJSON Property where+ parseJSON = withObject "Property" $ \v -> do+ t <- v .: "type"+ case (t :: Text) of+ "array" -> Property . JArray <$> (v .: "items" >>= parseJSON)+ "object" -> Property . JObject <$> parseJSON (Object v)+ _ -> Property <$> parseJSON (String t)++instance FromJSON Schema where+ parseJSON = withObject "Schema" $ \v ->+ Schema+ <$> v .:? "properties" .!= HM.empty+ <*> v .:? "required" .!= []++{- | Opaque builder for fluid schema construction.++@since 1.0.0.0+-}+newtype SchemaBuilder = SchemaBuilder Schema+ deriving stock (Show, Eq)++{- | Create an empty schema builder object.++@since 1.0.0.0+-}+emptyObject :: SchemaBuilder+emptyObject = SchemaBuilder $ Schema HM.empty []++{- | Add a primitive property to the schema builder.++@since 1.0.0.0+-}+addProperty :: Text -> JsonType -> SchemaBuilder -> SchemaBuilder+addProperty name typ (SchemaBuilder s) =+ SchemaBuilder $ s {schemaProperties = HM.insert name (Property typ) (schemaProperties s)}++{- | Add a nested object property to the schema builder.++@since 1.0.0.0+-}+addObjectProperty :: Text -> Schema -> SchemaBuilder -> SchemaBuilder+addObjectProperty name nestedSchema (SchemaBuilder s) =+ SchemaBuilder $+ s {schemaProperties = HM.insert name (Property (JObject nestedSchema)) (schemaProperties s)}++{- | Mark a field as required.++@since 1.0.0.0+-}+requireField :: Text -> SchemaBuilder -> SchemaBuilder+requireField name (SchemaBuilder s) =+ SchemaBuilder $ s {schemaRequired = name : schemaRequired s}++{- | Mark multiple fields as required.++@since 1.0.0.0+-}+requireFields :: [Text] -> SchemaBuilder -> SchemaBuilder+requireFields names builder = foldr requireField builder names++{- | Finalize a 'Schema' from a 'SchemaBuilder'.++@since 1.0.0.0+-}+buildSchema :: SchemaBuilder -> Schema+buildSchema (SchemaBuilder s) = s++{- | Treat a 'SchemaBuilder' as a nested object type.++@since 1.0.0.0+-}+objectOf :: SchemaBuilder -> JsonType+objectOf builder = JObject (buildSchema builder)++{- | Create an array schema type of an element type.++@since 1.0.0.0+-}+arrayOf :: JsonType -> JsonType+arrayOf = JArray++{- | Pretty-print a schema as formatted JSON.++@since 1.0.0.0+-}+printSchema :: Schema -> IO ()+printSchema = putStrLn . T.unpack . TL.toStrict . TEncoding.decodeUtf8 . encode++{- | Infix alias for 'addProperty'.++@since 1.0.0.0+-}+(|+) :: SchemaBuilder -> (Text, JsonType) -> SchemaBuilder+builder |+ (name, typ) = addProperty name typ builder++{- | Infix alias for 'addObjectProperty'.++@since 1.0.0.0+-}+(|++) :: SchemaBuilder -> (Text, Schema) -> SchemaBuilder+builder |++ (name, schema) = addObjectProperty name schema builder++{- | Infix alias for 'requireField'.++@since 1.0.0.0+-}+(|!) :: SchemaBuilder -> Text -> SchemaBuilder+builder |! name = requireField name builder++{- | Infix alias for 'requireFields'.++@since 1.0.0.0+-}+(|!!) :: SchemaBuilder -> [Text] -> SchemaBuilder+builder |!! names = requireFields names builder++infixl 7 |+, |+++infixl 6 |!, |!!
+ src/Ollama/Types/Format/SchemaDerive.hs view
@@ -0,0 +1,267 @@+{-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE DefaultSignatures #-}+{-# LANGUAGE UndecidableInstances #-}++{- |+Module : Ollama.Types.Format.SchemaDerive+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : experimental+Portability : portable++Generic derivation of JSON 'Schema' from Haskell record types.++This module provides the 'ToSchema' typeclass, which can automatically+derive a JSON Schema ('Schema') from any Haskell data type that has a+'GHC.Generics.Generic' instance. This eliminates the need to manually+construct schemas using the 'Ollama.Types.Format.SchemaBuilder.SchemaBuilder' DSL when working with+Ollama's structured output API.++== Usage++Simply derive 'Generic' and declare a 'ToSchema' instance:++@+data Person = Person+ { name :: Text+ , age :: Int+ } deriving stock ('GHC.Generics.Generic', Show)+ deriving anyclass ('ToSchema')++\-\- Use it:+\-\- >>> 'schemaFor' \@Person+\-\- >>> 'Ollama.Types.Format.formatFor' \@Person+@++== Supported types++* __Record types__: All fields become properties; non‑'Maybe' fields+ are marked as required.+* __'Maybe' fields__: Present in the schema properties but excluded+ from the @required@ array.+* __Nested records__: Recursively derived as nested @object@ schemas.+* __Lists__: Mapped to @array@ schemas.+* __Simple enums__: Sum types with all nullary constructors are+ represented as @{\"type\": \"string\", \"enum\": [...]}@.++@since 0.4.0.0+-}+module Ollama.Types.Format.SchemaDerive (+ -- * Typeclass+ ToSchema (..),+ ToJsonType (..),++ -- * Convenience functions+ schemaFor,++ -- * Generic Machinery (internal)+ GToSchema (..),+ GCollectFields (..),+ GEnumConstructors (..),+ GToSchemaDispatch (..),+) where++import Data.Int (Int16, Int32, Int64, Int8)+import Data.Kind (Type)+import Data.Map.Strict qualified as Map+import Data.Proxy (Proxy (..))+import Data.Text (Text)+import Data.Text qualified as T+import Data.Word (Word16, Word32, Word64, Word8)+import GHC.Generics+import GHC.TypeLits (KnownSymbol, symbolVal)+import Ollama.Types.Format.SchemaBuilder (JsonType (..), Property (..), Schema (..))++-- ---------------------------------------------------------------------------+-- Public API+-- ---------------------------------------------------------------------------++{- | Typeclass for types whose structure can be represented as a JSON 'Schema'.++A default implementation is provided via @GHC.Generics@, so you can derive+it for any record type that has a 'Generic' instance:++@+data MyType = MyType { field1 :: Text, field2 :: Int }+ deriving stock ('Generic')+ deriving anyclass ('ToSchema')+@++@since 0.4.0.0+-}+class ToSchema a where+ {- | Produce the JSON 'Schema' for type @a@.++ @since 0.4.0.0+ -}+ toSchema :: Schema+ default toSchema :: (GToSchema (Rep a)) => Schema+ toSchema = gToSchema @(Rep a)++{- | Convenience alias for @'toSchema' \@a@.++@since 0.4.0.0+-}+schemaFor :: forall a. (ToSchema a) => Schema+schemaFor = toSchema @a++{- | Map a Haskell type to its JSON Schema 'JsonType'.++Instances are provided for common primitive types ('Text', 'Int', 'Bool',+'Double', etc.), 'Maybe', lists, and any type with a 'ToSchema' instance+(which maps to a nested @object@ schema).++For nested record types, you don't need to write an instance manually —+simply ensure the nested type has a 'ToSchema' instance and the default+method will handle it:++@+data Address = Address { city :: Text, zip :: Text }+ deriving stock ('Generic')+ deriving anyclass ('ToSchema', 'ToJsonType')+@++@since 0.4.0.0+-}+class ToJsonType a where+ toJsonType :: JsonType+ default toJsonType :: (ToSchema a) => JsonType+ toJsonType = JObject (toSchema @a)++-- ---------------------------------------------------------------------------+-- GHC.Generics machinery (internal)+-- ---------------------------------------------------------------------------++-- | Walk a generic representation to produce a 'Schema'.+class GToSchema (f :: Type -> Type) where+ gToSchema :: Schema++-- | Collect fields (properties + required list) from a generic representation.+class GCollectFields (f :: Type -> Type) where+ gCollectFields :: ([(Text, Property)], [Text])++-- | Collect constructor names from a sum type for enum schemas.+class GEnumConstructors (f :: Type -> Type) where+ gEnumConstructors :: [Text]++-- ---------------------------------------------------------------------------+-- Datatype / Constructor / Sum dispatch+-- ---------------------------------------------------------------------------++-- | Strip datatype metadata wrapper.+instance (GToSchemaDispatch f) => GToSchema (M1 D meta f) where+ gToSchema = gToSchemaDispatch @f++-- | Dispatch: determine if this is a single-constructor record or a sum type.+class GToSchemaDispatch (f :: Type -> Type) where+ gToSchemaDispatch :: Schema++-- | Single constructor → collect fields into an object schema.+instance (GCollectFields f) => GToSchemaDispatch (M1 C meta f) where+ gToSchemaDispatch =+ let (props, req) = gCollectFields @f+ in Schema (Map.fromList props) req++{- | Sum type → try to build an enum schema.+We treat all-nullary sum types as string enums.+-}+instance (GEnumConstructors f, GEnumConstructors g) => GToSchemaDispatch (f :+: g) where+ gToSchemaDispatch =+ let constructors = gEnumConstructors @f <> gEnumConstructors @g+ in Schema Map.empty constructors++-- ---------------------------------------------------------------------------+-- Constructor / field collection+-- ---------------------------------------------------------------------------++-- | Strip constructor metadata wrapper.+instance (GCollectFields f) => GCollectFields (M1 C meta f) where+ gCollectFields = gCollectFields @f++-- | Product of fields: combine both sides.+instance (GCollectFields f, GCollectFields g) => GCollectFields (f :*: g) where+ gCollectFields =+ let (ps1, rs1) = gCollectFields @f+ (ps2, rs2) = gCollectFields @g+ in (ps1 <> ps2, rs1 <> rs2)++-- | Unit constructor (no fields).+instance GCollectFields U1 where+ gCollectFields = ([], [])++-- | Record field with a 'Maybe' type (optional — not required).+instance+ {-# OVERLAPPING #-}+ (KnownSymbol name, ToJsonType a) =>+ GCollectFields (M1 S ('MetaSel ('Just name) su ss ds) (K1 R (Maybe a)))+ where+ gCollectFields =+ let fieldName = T.pack $ symbolVal (Proxy @name)+ prop = Property (toJsonType @a)+ in ([(fieldName, prop)], [])++-- | Record field with a non-'Maybe' type (required).+instance+ {-# OVERLAPPABLE #-}+ (KnownSymbol name, ToJsonType a) =>+ GCollectFields (M1 S ('MetaSel ('Just name) su ss ds) (K1 R a))+ where+ gCollectFields =+ let fieldName = T.pack $ symbolVal (Proxy @name)+ prop = Property (toJsonType @a)+ in ([(fieldName, prop)], [fieldName])++-- ---------------------------------------------------------------------------+-- ToJsonType instances+-- ---------------------------------------------------------------------------++-- Strings+instance ToJsonType Text where toJsonType = JString+instance ToJsonType String where toJsonType = JString++-- Integers+instance ToJsonType Int where toJsonType = JInteger+instance ToJsonType Int8 where toJsonType = JInteger+instance ToJsonType Int16 where toJsonType = JInteger+instance ToJsonType Int32 where toJsonType = JInteger+instance ToJsonType Int64 where toJsonType = JInteger+instance ToJsonType Integer where toJsonType = JInteger+instance ToJsonType Word where toJsonType = JInteger+instance ToJsonType Word8 where toJsonType = JInteger+instance ToJsonType Word16 where toJsonType = JInteger+instance ToJsonType Word32 where toJsonType = JInteger+instance ToJsonType Word64 where toJsonType = JInteger++-- Floating point+instance ToJsonType Double where toJsonType = JNumber+instance ToJsonType Float where toJsonType = JNumber++-- Boolean+instance ToJsonType Bool where toJsonType = JBoolean++-- Maybe: unwrap to the inner type+instance (ToJsonType a) => ToJsonType (Maybe a) where+ toJsonType = toJsonType @a++-- Lists / arrays+instance (ToJsonType a) => ToJsonType [a] where+ toJsonType = JArray (toJsonType @a)++-- ---------------------------------------------------------------------------+-- Enum constructors+-- ---------------------------------------------------------------------------++-- | Sum of two branches.+instance (GEnumConstructors f, GEnumConstructors g) => GEnumConstructors (f :+: g) where+ gEnumConstructors = gEnumConstructors @f <> gEnumConstructors @g++-- | A nullary constructor (unit): extract constructor name.+instance (KnownSymbol name) => GEnumConstructors (M1 C ('MetaCons name fx 'False) U1) where+ gEnumConstructors = [T.toLower . T.pack $ symbolVal (Proxy @name)]++{- | A constructor with fields — not a simple enum. Produce a type error+at the instance level by not providing an instance (compile-time failure).+Users will get "No instance for GEnumConstructors ..." which is clear enough.+-}
+ src/Ollama/Types/Message.hs view
@@ -0,0 +1,129 @@+{- |+Module : Ollama.Types.Message+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Chat message definitions and helper constructors.++@since 3.0.0.0+-}+module Ollama.Types.Message (+ Role (..),+ Message (..),+ userMessage,+ systemMessage,+ assistantMessage,+ toolMessage,+ toolResultMessage,+ imageMessage,+) where++import Data.Aeson+import Data.Maybe (catMaybes)+import Data.Text (Text)+import GHC.Generics (Generic)+import Ollama.Types.Common (Base64Image)+import Ollama.Types.Tool (ToolCall)++{- | Entity role in a conversation.++@since 3.0.0.0+-}+data Role = System | User | Assistant | Tool+ deriving stock (Eq, Ord, Show, Bounded, Enum, Generic)++instance ToJSON Role where+ toJSON System = String "system"+ toJSON User = String "user"+ toJSON Assistant = String "assistant"+ toJSON Tool = String "tool"++instance FromJSON Role where+ parseJSON = withText "Role" $ \case+ "system" -> pure System+ "user" -> pure User+ "assistant" -> pure Assistant+ "tool" -> pure Tool+ other -> fail $ "Invalid Role: " <> show other++{- | Chat message within a conversation payload.++@since 3.0.0.0+-}+data Message = Message+ { messageRole :: !Role+ , messageContent :: !Text+ , messageImages :: !(Maybe [Base64Image])+ , messageToolCalls :: !(Maybe [ToolCall])+ , messageToolName :: !(Maybe Text)+ , messageThinking :: !(Maybe Text)+ }+ deriving stock (Eq, Show, Generic)++instance ToJSON Message where+ toJSON Message {..} =+ object $+ catMaybes+ [ Just $ "role" .= messageRole+ , Just $ "content" .= messageContent+ , ("images" .=) <$> messageImages+ , ("tool_calls" .=) <$> messageToolCalls+ , ("tool_name" .=) <$> messageToolName+ , ("thinking" .=) <$> messageThinking+ ]++instance FromJSON Message where+ parseJSON = withObject "Message" $ \v ->+ Message+ <$> v .: "role"+ <*> v .: "content"+ <*> v .:? "images"+ <*> v .:? "tool_calls"+ <*> v .:? "tool_name"+ <*> v .:? "thinking"++{- | Create a 'User' role message.++@since 3.0.0.0+-}+userMessage :: Text -> Message+userMessage t = Message User t Nothing Nothing Nothing Nothing++{- | Create a 'System' role message.++@since 3.0.0.0+-}+systemMessage :: Text -> Message+systemMessage t = Message System t Nothing Nothing Nothing Nothing++{- | Create an 'Assistant' role message.++@since 3.0.0.0+-}+assistantMessage :: Text -> Message+assistantMessage t = Message Assistant t Nothing Nothing Nothing Nothing++{- | Create a 'Tool' role message.++@since 3.0.0.0+-}+toolMessage :: Text -> Message+toolMessage t = Message Tool t Nothing Nothing Nothing Nothing++{- | Create a 'Tool' role message with specific @tool_name@ informing the model of tool execution.++@since 3.0.0.0+-}+toolResultMessage :: Text -> Text -> Message+toolResultMessage content toolName =+ Message Tool content Nothing Nothing (Just toolName) Nothing++{- | Create a 'User' message with attached Base64 image data.++@since 3.0.0.0+-}+imageMessage :: Text -> [Base64Image] -> Message+imageMessage t imgs = Message User t (Just imgs) Nothing Nothing Nothing
+ src/Ollama/Types/Model.hs view
@@ -0,0 +1,161 @@+{- |+Module : Ollama.Types.Model+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Model info, details, listing, and metadata types.++@since 1.0.0.0+-}+module Ollama.Types.Model (+ ModelDetails (..),+ ModelInfo (..),+ ListResponse (..),+ RunningModel (..),+ RunningModelsResponse (..),+) where++import Data.Aeson+import Data.Int (Int64)+import Data.Text (Text)+import Data.Time (UTCTime)+import GHC.Generics (Generic)+import Ollama.Types.Common (Digest, ModelName)++{- | Detailed specifications of a model's architecture and family.++@since 1.0.0.0+-}+data ModelDetails = ModelDetails+ { parentModel :: !(Maybe Text)+ , format :: !Text+ , family :: !Text+ , families :: ![Text]+ , parameterSize :: !Text+ , quantizationLevel :: !Text+ }+ deriving stock (Eq, Show, Generic)++instance FromJSON ModelDetails where+ parseJSON = withObject "ModelDetails" $ \v ->+ ModelDetails+ <$> v .:? "parent_model"+ <*> v .:? "format" .!= ""+ <*> v .:? "family" .!= ""+ <*> v .:? "families" .!= []+ <*> v .:? "parameter_size" .!= ""+ <*> v .:? "quantization_level" .!= ""++instance ToJSON ModelDetails where+ toJSON ModelDetails {..} =+ object+ [ "parent_model" .= parentModel+ , "format" .= format+ , "family" .= family+ , "families" .= families+ , "parameter_size" .= parameterSize+ , "quantization_level" .= quantizationLevel+ ]++{- | Summary information for an installed local model.++@since 1.0.0.0+-}+data ModelInfo = ModelInfo+ { miName :: !ModelName+ , miModel :: !ModelName+ , miModifiedAt :: !UTCTime+ , miSize :: !Int64+ , miDigest :: !Digest+ , miDetails :: !ModelDetails+ }+ deriving stock (Eq, Show, Generic)++instance FromJSON ModelInfo where+ parseJSON = withObject "ModelInfo" $ \v ->+ ModelInfo+ <$> v .: "name"+ <*> v .: "model"+ <*> v .: "modified_at"+ <*> v .: "size"+ <*> v .: "digest"+ <*> v .: "details"++instance ToJSON ModelInfo where+ toJSON ModelInfo {..} =+ object+ [ "name" .= miName+ , "model" .= miModel+ , "modified_at" .= miModifiedAt+ , "size" .= miSize+ , "digest" .= miDigest+ , "details" .= miDetails+ ]++{- | Response listing available local models.++@since 1.0.0.0+-}+newtype ListResponse = ListResponse+ { models :: [ModelInfo]+ }+ deriving stock (Eq, Show, Generic)+ deriving anyclass (ToJSON, FromJSON)++{- | Summary information for a model currently loaded in memory.++@since 1.0.0.0+-}+data RunningModel = RunningModel+ { rmName :: !ModelName+ , rmModel :: !ModelName+ , rmSize :: !Int64+ , rmDigest :: !Digest+ , rmDetails :: !ModelDetails+ , rmExpiresAt :: !UTCTime+ , rmSizeVram :: !Int64+ }+ deriving stock (Eq, Show, Generic)++instance FromJSON RunningModel where+ parseJSON = withObject "RunningModel" $ \v ->+ RunningModel+ <$> v .: "name"+ <*> v .: "model"+ <*> v .: "size"+ <*> v .: "digest"+ <*> v .: "details"+ <*> v .: "expires_at"+ <*> v .: "size_vram"++instance ToJSON RunningModel where+ toJSON RunningModel {..} =+ object+ [ "name" .= rmName+ , "model" .= rmModel+ , "size" .= rmSize+ , "digest" .= rmDigest+ , "details" .= rmDetails+ , "expires_at" .= rmExpiresAt+ , "size_vram" .= rmSizeVram+ ]++{- | Response listing loaded running models.++@since 1.0.0.0+-}+newtype RunningModelsResponse = RunningModelsResponse+ { runningModels :: [RunningModel]+ }+ deriving stock (Eq, Show, Generic)++instance FromJSON RunningModelsResponse where+ parseJSON = withObject "RunningModelsResponse" $ \v ->+ RunningModelsResponse <$> v .: "models"++instance ToJSON RunningModelsResponse where+ toJSON RunningModelsResponse {..} =+ object ["models" .= runningModels]
+ src/Ollama/Types/Options.hs view
@@ -0,0 +1,135 @@+{- |+Module : Ollama.Types.Options+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Model parameters and runtime execution options.++@since 3.0.0.0+-}+module Ollama.Types.Options (+ ModelOptions (..),+ defaultOptions,+) where++import Data.Aeson (FromJSON (..), ToJSON (..), object, withObject, (.:?), (.=))+import Data.Maybe (catMaybes)+import Data.Text (Text)+import GHC.Generics (Generic)++{- | Optional inference and hardware tuning parameters.++@since 3.0.0.0+-}+data ModelOptions = ModelOptions+ { optNumKeep :: !(Maybe Int)+ , optSeed :: !(Maybe Int)+ , optNumPredict :: !(Maybe Int)+ , optDraftNumPredict :: !(Maybe Int)+ , optTopK :: !(Maybe Int)+ , optTopP :: !(Maybe Double)+ , optMinP :: !(Maybe Double)+ , optTypicalP :: !(Maybe Double)+ , optRepeatLastN :: !(Maybe Int)+ , optTemperature :: !(Maybe Double)+ , optRepeatPenalty :: !(Maybe Double)+ , optPresencePenalty :: !(Maybe Double)+ , optFrequencyPenalty :: !(Maybe Double)+ , optPenalizeNewline :: !(Maybe Bool)+ , optStop :: !(Maybe [Text])+ , optNuma :: !(Maybe Bool)+ , optNumCtx :: !(Maybe Int)+ , optNumBatch :: !(Maybe Int)+ , optNumGpu :: !(Maybe Int)+ , optMainGpu :: !(Maybe Int)+ , optUseMmap :: !(Maybe Bool)+ , optNumThread :: !(Maybe Int)+ }+ deriving stock (Eq, Show, Generic)++instance ToJSON ModelOptions where+ toJSON opts =+ object $+ catMaybes+ [ ("num_keep" .=) <$> optNumKeep opts+ , ("seed" .=) <$> optSeed opts+ , ("num_predict" .=) <$> optNumPredict opts+ , ("draft_num_predict" .=) <$> optDraftNumPredict opts+ , ("top_k" .=) <$> optTopK opts+ , ("top_p" .=) <$> optTopP opts+ , ("min_p" .=) <$> optMinP opts+ , ("typical_p" .=) <$> optTypicalP opts+ , ("repeat_last_n" .=) <$> optRepeatLastN opts+ , ("temperature" .=) <$> optTemperature opts+ , ("repeat_penalty" .=) <$> optRepeatPenalty opts+ , ("presence_penalty" .=) <$> optPresencePenalty opts+ , ("frequency_penalty" .=) <$> optFrequencyPenalty opts+ , ("penalize_newline" .=) <$> optPenalizeNewline opts+ , ("stop" .=) <$> optStop opts+ , ("numa" .=) <$> optNuma opts+ , ("num_ctx" .=) <$> optNumCtx opts+ , ("num_batch" .=) <$> optNumBatch opts+ , ("num_gpu" .=) <$> optNumGpu opts+ , ("main_gpu" .=) <$> optMainGpu opts+ , ("use_mmap" .=) <$> optUseMmap opts+ , ("num_thread" .=) <$> optNumThread opts+ ]++instance FromJSON ModelOptions where+ parseJSON = withObject "ModelOptions" $ \v ->+ ModelOptions+ <$> v .:? "num_keep"+ <*> v .:? "seed"+ <*> v .:? "num_predict"+ <*> v .:? "draft_num_predict"+ <*> v .:? "top_k"+ <*> v .:? "top_p"+ <*> v .:? "min_p"+ <*> v .:? "typical_p"+ <*> v .:? "repeat_last_n"+ <*> v .:? "temperature"+ <*> v .:? "repeat_penalty"+ <*> v .:? "presence_penalty"+ <*> v .:? "frequency_penalty"+ <*> v .:? "penalize_newline"+ <*> v .:? "stop"+ <*> v .:? "numa"+ <*> v .:? "num_ctx"+ <*> v .:? "num_batch"+ <*> v .:? "num_gpu"+ <*> v .:? "main_gpu"+ <*> v .:? "use_mmap"+ <*> v .:? "num_thread"++{- | Default empty options (all settings default to server Modelfile values).++@since 3.0.0.0+-}+defaultOptions :: ModelOptions+defaultOptions =+ ModelOptions+ Nothing+ Nothing+ Nothing+ Nothing+ Nothing+ Nothing+ Nothing+ Nothing+ Nothing+ Nothing+ Nothing+ Nothing+ Nothing+ Nothing+ Nothing+ Nothing+ Nothing+ Nothing+ Nothing+ Nothing+ Nothing+ Nothing
+ src/Ollama/Types/Tool.hs view
@@ -0,0 +1,148 @@+{- |+Module : Ollama.Types.Tool+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Tool calling and structured function interfaces for the Ollama API.++@since 3.0.0.0+-}+module Ollama.Types.Tool (+ Tool (..),+ FunctionDef (..),+ FunctionParameters (..),+ ToolCall (..),+ ToolCallFunction (..),+) where++import Data.Aeson+import Data.Map.Strict (Map)+import Data.Text (Text)+import GHC.Generics (Generic)++{- | Tool definition provided to the model.++@since 3.0.0.0+-}+data Tool = Tool+ { toolType :: !Text+ , toolFunction :: !FunctionDef+ }+ deriving stock (Eq, Show, Generic)++instance ToJSON Tool where+ toJSON Tool {..} =+ object+ [ "type" .= toolType+ , "function" .= toolFunction+ ]++instance FromJSON Tool where+ parseJSON = withObject "Tool" $ \v ->+ Tool+ <$> v .: "type"+ <*> v .: "function"++{- | Definition of a function that can be called by the model.++@since 3.0.0.0+-}+data FunctionDef = FunctionDef+ { fnName :: !Text+ , fnDescription :: !(Maybe Text)+ , fnParameters :: !(Maybe FunctionParameters)+ , fnStrict :: !(Maybe Bool)+ }+ deriving stock (Eq, Show, Generic)++instance ToJSON FunctionDef where+ toJSON FunctionDef {..} =+ object $+ ["name" .= fnName]+ ++ maybe [] (\d -> ["description" .= d]) fnDescription+ ++ maybe [] (\p -> ["parameters" .= p]) fnParameters+ ++ maybe [] (\s -> ["strict" .= s]) fnStrict++instance FromJSON FunctionDef where+ parseJSON = withObject "FunctionDef" $ \v ->+ FunctionDef+ <$> v .: "name"+ <*> v .:? "description"+ <*> v .:? "parameters"+ <*> v .:? "strict"++{- | Parameters schema for a function call.++@since 3.0.0.0+-}+data FunctionParameters = FunctionParameters+ { fpType :: !Text+ , fpProperties :: !(Maybe (Map Text FunctionParameters))+ , fpRequired :: !(Maybe [Text])+ , fpAdditionalProperties :: !(Maybe Bool)+ , fpDescription :: !(Maybe Text)+ , fpEnum :: !(Maybe [Text])+ }+ deriving stock (Eq, Show, Generic)++instance ToJSON FunctionParameters where+ toJSON FunctionParameters {..} =+ object $+ ["type" .= fpType]+ ++ maybe [] (\p -> ["properties" .= p]) fpProperties+ ++ maybe [] (\r -> ["required" .= r]) fpRequired+ ++ maybe [] (\a -> ["additionalProperties" .= a]) fpAdditionalProperties+ ++ maybe [] (\d -> ["description" .= d]) fpDescription+ ++ maybe [] (\e -> ["enum" .= e]) fpEnum++instance FromJSON FunctionParameters where+ parseJSON = withObject "FunctionParameters" $ \v ->+ FunctionParameters+ <$> v .: "type"+ <*> v .:? "properties"+ <*> v .:? "required"+ <*> v .:? "additionalProperties"+ <*> v .:? "description"+ <*> v .:? "enum"++{- | Tool call returned in model's assistant response.++@since 3.0.0.0+-}+newtype ToolCall = ToolCall+ { tcFunction :: ToolCallFunction+ }+ deriving stock (Eq, Show, Generic)++instance ToJSON ToolCall where+ toJSON ToolCall {..} = object ["function" .= tcFunction]++instance FromJSON ToolCall where+ parseJSON = withObject "ToolCall" $ \v ->+ ToolCall <$> v .: "function"++{- | Function invocation payload inside a tool call.++@since 3.0.0.0+-}+data ToolCallFunction = ToolCallFunction+ { tcfName :: !Text+ , tcfArguments :: !(Map Text Value)+ }+ deriving stock (Eq, Show, Generic)++instance ToJSON ToolCallFunction where+ toJSON ToolCallFunction {..} =+ object+ [ "name" .= tcfName+ , "arguments" .= tcfArguments+ ]++instance FromJSON ToolCallFunction where+ parseJSON = withObject "ToolCallFunction" $ \v ->+ ToolCallFunction+ <$> v .: "name"+ <*> v .: "arguments"
+ test-integration/Main.hs view
@@ -0,0 +1,176 @@+module Main (main) where++import Data.List.NonEmpty (NonEmpty ((:|)))+import Data.Text qualified as T+import Data.Time (getCurrentTime)+import Ollama+import Test.Tasty+import Test.Tasty.HUnit++testModel :: ModelName+testModel = "qwen3.5:2b"++fastOptions :: Maybe ModelOptions+fastOptions = Just (defaultOptions {optNumPredict = Just 15})++tests :: TestTree+tests =+ testGroup+ "ollama-haskell Live Server End-to-End Test Suite"+ [ testCase "GET /api/version — getVersion" $ do+ client <- defaultClient+ res <- getVersion client+ case res of+ Left err -> assertFailure $ "Version request failed: " <> show err+ Right ver -> assertBool "Version non-empty" (not $ T.null $ unVersion ver)+ , testCase "GET /api/tags — listModels" $ do+ client <- defaultClient+ res <- listModels client+ case res of+ Left err -> assertFailure $ "List models failed: " <> show err+ Right (ListResponse ms) ->+ assertBool "Has installed models" (not $ null ms)+ , testCase "POST /api/show — showModel" $ do+ client <- defaultClient+ res <- showModel client testModel+ case res of+ Left err -> assertFailure $ "Show model failed: " <> show err+ Right resp ->+ assertBool "Modelfile or details present" (not $ T.null $ srsModelfile resp)+ , testCase "GET /api/ps — listRunning" $ do+ client <- defaultClient+ res <- listRunning client+ case res of+ Left err -> assertFailure $ "List running failed: " <> show err+ Right _ -> pure ()+ , testCase "POST /api/chat — non-streaming chat" $ do+ client <- defaultClient+ let msgs = systemMessage "You are a helpful assistant." :| [userMessage "Say hello in one word."]+ req = (chatRequest testModel msgs) {chatOptions = fastOptions, chatThink = Just ThinkDisabled}+ res <- chat client req+ case res of+ Left err -> assertFailure $ "Chat request failed: " <> show err+ Right resp -> do+ assertBool "Chat response done" (crDone resp)+ case crMessage resp of+ Nothing -> assertFailure "Expected message in response"+ Just msg ->+ assertBool+ "Message content or thinking present"+ (not (T.null (messageContent msg)) || maybe False (not . T.null) (messageThinking msg))+ , testCase "POST /api/chat — streaming chat with conduit" $ do+ client <- defaultClient+ let req = (chatRequest testModel (userMessage "Count from 1 to 5." :| [])) {chatThink = Just ThinkDisabled}+ chunks <- collectStream (chatStream client req)+ assertBool "Stream produced response chunks" (not $ null chunks)+ , testCase "POST /api/generate — non-streaming generate" $ do+ client <- defaultClient+ let req =+ (generateRequest testModel "Write 3 words.")+ { genOptions = fastOptions+ , genThink = Just ThinkDisabled+ }+ res <- generate client req+ case res of+ Left err -> assertFailure $ "Generate request failed: " <> show err+ Right resp -> do+ assertBool "Generate response done" (grDone resp)+ assertBool "Generated response non-empty" (not $ T.null $ grResponse resp)+ , testCase "POST /api/generate — streaming generate with conduit" $ do+ client <- defaultClient+ let req = (generateRequest testModel "Say hi.") {genThink = Just ThinkDisabled}+ chunks <- collectStream (generateStream client req)+ assertBool "Stream produced generate chunks" (not $ null chunks)+ , testCase "POST /api/generate — thinking model support" $ do+ client <- defaultClient+ let req =+ (generateRequest testModel "What is 2 + 2?")+ { genThink = Just ThinkEnabled+ , genOptions = fastOptions+ }+ res <- generate client req+ case res of+ Left err -> assertFailure $ "Thinking generate failed: " <> show err+ Right resp -> assertBool "Response done" (grDone resp)+ , testCase "POST /api/chat — tool calling definition and execution" $ do+ client <- defaultClient+ let weatherTool =+ Tool+ { toolType = "function"+ , toolFunction =+ FunctionDef+ { fnName = "get_current_weather"+ , fnDescription = Just "Get current weather for a city"+ , fnParameters =+ Just+ FunctionParameters+ { fpType = "object"+ , fpProperties = Nothing+ , fpRequired = Just ["location"]+ , fpAdditionalProperties = Nothing+ , fpDescription = Nothing+ , fpEnum = Nothing+ }+ , fnStrict = Nothing+ }+ }+ req =+ (chatRequest testModel (userMessage "What is the weather in Tokyo?" :| []))+ { chatTools = Just [weatherTool]+ , chatOptions = fastOptions+ , chatThink = Just ThinkDisabled+ }+ res <- chat client req+ case res of+ Left err -> assertFailure $ "Tool chat request failed: " <> show err+ Right resp -> assertBool "Chat response completed" (crDone resp)+ , testCase "POST /api/chat — structured output format" $ do+ client <- defaultClient+ let req =+ (chatRequest testModel (userMessage "Respond with JSON listing 2 colors" :| []))+ { chatFormat = Just JsonFormat+ , chatOptions = fastOptions+ , chatThink = Just ThinkDisabled+ }+ res <- chat client req+ case res of+ Left err -> assertFailure $ "Structured chat failed: " <> show err+ Right resp -> assertBool "Response done" (crDone resp)+ , testCase "POST /api/embed — vector embeddings" $ do+ client <- defaultClient+ let req = embedRequest testModel ["Hello world", "Haskell LLM client"]+ res <- embed client req+ case res of+ Left (ApiError 501 _) -> pure ()+ Left err -> assertFailure $ "Embed request failed: " <> show err+ Right resp ->+ assertBool "Embeddings non-empty" (not $ null $ erEmbeddings resp)+ , testCase "POST /api/copy & DELETE /api/delete — model lifecycle" $ do+ client <- defaultClient+ let copyTarget = "qwen3.5:2b-test-copy"+ copyRes <- copyModel client testModel copyTarget+ case copyRes of+ Left err -> assertFailure $ "Copy model failed: " <> show err+ Right () -> do+ delRes <- deleteModel client copyTarget+ case delRes of+ Left err -> assertFailure $ "Delete model failed: " <> show err+ Right () -> pure ()+ , testCase "ConversationStore — InMemoryStore with real conversation" $ do+ store <- initInMemoryStore+ now <- getCurrentTime+ let cid = "test-conv-1"+ conv =+ Conversation+ cid+ [systemMessage "You are a concise assistant.", userMessage "My favorite color is green."]+ testModel+ now+ now+ saveConversationInMemory store conv+ mConv <- loadConversationInMemory store cid+ assertEqual "Loaded saved conversation" (Just conv) mConv+ ]++main :: IO ()+main = defaultMain tests
test/Main.hs view
@@ -1,212 +1,36 @@-{-# LANGUAGE OverloadedStrings #-}- module Main (main) where -import Data.Either-import Data.List.NonEmpty hiding (length)-import Data.Maybe-import Data.Ollama.Chat qualified as Chat-import Data.Text.IO qualified as T-import Ollama (GenerateOps (..), Role (..), defaultChatOps, defaultGenerateOps)-import Ollama qualified-import System.IO.Silently (capture)+import Test.Ollama.Golden.Chat qualified as GoldenChat+import Test.Ollama.Golden.Embed qualified as GoldenEmbed+import Test.Ollama.Golden.Generate qualified as GoldenGenerate+import Test.Ollama.Golden.Models qualified as GoldenModels+import Test.Ollama.Property.Roundtrip qualified as PropertyRoundtrip+import Test.Ollama.Unit.Config qualified as UnitConfig+import Test.Ollama.Unit.Error qualified as UnitError+import Test.Ollama.Unit.MCP qualified as UnitMCP+import Test.Ollama.Unit.SchemaBuilder qualified as UnitSchemaBuilder+import Test.Ollama.Unit.SchemaDerive qualified as UnitSchemaDerive+import Test.Ollama.Unit.Testing qualified as UnitTesting+import Test.Ollama.Unit.Types qualified as UnitTypes import Test.Tasty-import Test.Tasty.HUnit-import qualified Data.Text as T -import Data.Aeson tests :: TestTree tests = testGroup- "Tests"- [ generateTest- , chatTest- , psTest- , showTest- , embeddingTest- , generateFormatTest- , chatFormatTest- ]--generateTest :: TestTree-generateTest =- testGroup- "Generate tests"- [ testCase "generate stream" $ do- output <-- capture $- Ollama.generate- defaultGenerateOps- { modelName = "llama3.2"- , prompt = "what is 4 + 2?"- , stream = Just (T.putStr . Ollama.response_, pure ())- }- assertBool "Checking if generate function is printing anything" (length output > 0)- , testCase "Generate non-stream" $ do- eRes <-- Ollama.generate- defaultGenerateOps- { modelName = "llama3.2"- , prompt = "what is 4 + 2?"- }- assertBool "Checking if generate function returns a valid value" (isRight eRes)- , testCase "Generate with invalid host" $ do- eRes <- Ollama.generate defaultGenerateOps {- modelName = "llama3.2"- , prompt = "what is 23 + 9?"- , hostUrl = pure "http://some-site"- , responseTimeOut = pure 2- }- print ("got response" :: String ,eRes)- assertBool "Expecting Left" (isLeft eRes)- , testCase "Generate with invalid model" $ do- eRes <- Ollama.generate defaultGenerateOps {modelName = "invalid-model"}- assertBool "Expecting generation to fail with invalid model" (isLeft eRes)- ]--generateFormatTest :: TestTree-generateFormatTest =- testGroup "Generate tests with format and options"- [ testCase "Generate with SchemaFormat and options" $ do- let schema = object- [ "type" .= ("object" :: String)- , "properties" .= object [ "age" .= object ["type" .= ("integer" :: String)] ]- ]- eRes <-- Ollama.generate- defaultGenerateOps- { modelName = "llama3.2"- , prompt = "Ollama is 22 years old and is busy saving the world. Respond using JSON"- , format = Just (Ollama.SchemaFormat schema)- }- case eRes of- Right res ->- assertBool "Response should contain JSON with key \"age\"" ( "age" `T.isInfixOf` Ollama.response_ res )- Left err ->- assertFailure $ "Generation failed with error: " ++ show err- , testCase "Generate with JsonFormat and options" $ do- eRes <-- Ollama.generate- defaultGenerateOps- { modelName = "llama3.2"- , prompt = "Provide a simple JSON response describing Ollama."- , format = Just Ollama.JsonFormat- }- case eRes of- Right res ->- assertBool "Response should start with '{'" ( "{" `T.isPrefixOf` Ollama.response_ res )- Left err ->- assertFailure $ "Generation failed with error: " ++ show err- ]--chatTest :: TestTree-chatTest =- testGroup- "Chat tests"- [ testCase "chat stream" $ do- let msg = Ollama.Message User "What is 29 + 3?" Nothing Nothing- defaultMsg = Ollama.Message User "" Nothing Nothing- output <-- capture $- Ollama.chat- defaultChatOps- { Chat.chatModelName = "llama3.2"- , Chat.messages = msg :| []- , Chat.stream = Just (T.putStr . Chat.content . fromMaybe defaultMsg . Chat.message, pure ())- }- assertBool "Checking if chat function is printing anything" (length output > 0)- , testCase "Chat non-stream" $ do- let msg = Ollama.Message User "What is 29 + 3?" Nothing Nothing- eRes <-- Ollama.chat- defaultChatOps- { Chat.chatModelName = "llama3.2"- , Chat.messages = msg :| []- }- assertBool "Checking if chat function returns a valid value" (isRight eRes)- , testCase "Chat invalid host url" $ do- let msg = Ollama.Message User "What is 29 + 3?" Nothing Nothing- eRes <-- Ollama.chat- defaultChatOps- { Chat.chatModelName = "llama3.2"- , Chat.messages = msg :| []- , Chat.hostUrl = pure "some random value"- , Chat.responseTimeOut = pure 2- }- assertBool "It should return Left" (isLeft eRes)- ]--chatFormatTest :: TestTree-chatFormatTest =- testGroup "Chat tests with format and options"- [ testCase "Chat with SchemaFormat and options" $ do- let schema = object- [ "type" .= ("object" :: String)- , "properties" .= object [ "age" .= object ["type" .= ("integer" :: String)] ]- ]- msg = Ollama.Message User "Ollama is 22 years old and is busy saving the world. Respond using JSON" Nothing Nothing- eRes <-- Ollama.chat- defaultChatOps- { Chat.chatModelName = "llama3.2"- , Chat.messages = msg :| []- , Chat.format = Just (Ollama.SchemaFormat schema)- , Chat.options = Just $ object ["penalize_newline" .= Bool True]- }- case eRes of- Right res ->- assertBool "Chat response should contain key \"age\"" $- maybe False (\m -> "age" `T.isInfixOf` Chat.content m) (Chat.message res)- Left err ->- assertFailure $ "Chat failed with error: " ++ show err- , testCase "Chat with JsonFormat and options" $ do- let msg = Ollama.Message User "Tell me about Ollama in JSON format." Nothing Nothing- eRes <-- Ollama.chat- defaultChatOps- { Chat.chatModelName = "llama3.2"- , Chat.messages = msg :| []- , Chat.format = Just Ollama.JsonFormat- }- case eRes of- Right res ->- assertBool "Chat response should start with '{'" $- maybe False (\m -> "{" `T.isPrefixOf` Chat.content m) (Chat.message res)- Left err ->- assertFailure $ "Chat failed with error: " ++ show err- ]--psTest :: TestTree-psTest =- testGroup- "PS test"- [ testCase "check ps" $ do- mRes <- Ollama.ps- assertBool "Check if ps returns anything" (isJust mRes)- ]--showTest :: TestTree-showTest =- testGroup- "Show test"- [ testCase "check show" $ do- mRes <- Ollama.showModel "llama3.2"- assertBool "Check if model exists or not" (isJust mRes)- ]--embeddingTest :: TestTree-embeddingTest =- testGroup- "Embedding test"- [ testCase "check embedding" $ do- eRes <- Ollama.embedding "llama3.2" "Why is sky blue?"- assertBool "Check if embedding returns anything" (isRight eRes)+ "ollama-haskell Pure Test Suite"+ [ UnitTypes.tests+ , UnitError.tests+ , UnitConfig.tests+ , UnitSchemaBuilder.tests+ , UnitSchemaDerive.tests+ , UnitTesting.testingTests+ , UnitMCP.tests+ , PropertyRoundtrip.tests+ , GoldenChat.tests+ , GoldenGenerate.tests+ , GoldenEmbed.tests+ , GoldenModels.tests ] main :: IO ()-main = do- mRes <- Ollama.list- case mRes of- Nothing -> pure () -- Ollama is likely not running. Not running tests.- Just _ -> defaultMain tests+main = defaultMain tests
+ test/Test/Ollama/Golden/Chat.hs view
@@ -0,0 +1,25 @@+module Test.Ollama.Golden.Chat (tests) where++import Data.Aeson (encode)+import Data.List.NonEmpty (NonEmpty ((:|)))+import Ollama+import Test.Tasty+import Test.Tasty.Golden++tests :: TestTree+tests =+ testGroup+ "Golden JSON Serialization Tests"+ [ goldenVsString+ "ChatRequest JSON wire format matches golden fixture"+ "test/golden/chat_request.golden"+ (pure $ encode $ chatRequest "llama3.2" (userMessage "Why is the sky blue?" :| []))+ , goldenVsString+ "GenerateRequest JSON wire format matches golden fixture"+ "test/golden/generate_request.golden"+ (pure $ encode $ generateRequest "llama3.2" "Why is the sky blue?")+ , goldenVsString+ "EmbedRequest JSON wire format matches golden fixture"+ "test/golden/embed_request.golden"+ (pure $ encode $ embedRequest "nomic-embed-text" ["hello", "world"])+ ]
+ test/Test/Ollama/Golden/Embed.hs view
@@ -0,0 +1,16 @@+module Test.Ollama.Golden.Embed (tests) where++import Data.Aeson (encode)+import Ollama+import Test.Tasty+import Test.Tasty.Golden++tests :: TestTree+tests =+ testGroup+ "Embed Golden JSON Serialization Tests"+ [ goldenVsString+ "EmbedRequest golden serialization"+ "test/golden/embed_request.golden"+ (pure $ encode $ embedRequest "nomic-embed-text" ["hello", "world"])+ ]
+ test/Test/Ollama/Golden/Generate.hs view
@@ -0,0 +1,16 @@+module Test.Ollama.Golden.Generate (tests) where++import Data.Aeson (encode)+import Ollama+import Test.Tasty+import Test.Tasty.Golden++tests :: TestTree+tests =+ testGroup+ "Generate Golden JSON Serialization Tests"+ [ goldenVsString+ "GenerateRequest golden serialization"+ "test/golden/generate_request.golden"+ (pure $ encode $ generateRequest "llama3.2" "Why is the sky blue?")+ ]
+ test/Test/Ollama/Golden/Models.hs view
@@ -0,0 +1,16 @@+module Test.Ollama.Golden.Models (tests) where++import Data.Aeson (encode)+import Ollama+import Test.Tasty+import Test.Tasty.Golden++tests :: TestTree+tests =+ testGroup+ "Model Management Golden JSON Serialization Tests"+ [ goldenVsString+ "CreateRequest golden serialization"+ "test/golden/create_request.golden"+ (pure $ encode $ defaultCreateRequest "custom-model")+ ]
+ test/Test/Ollama/Property/Arbitrary.hs view
@@ -0,0 +1,51 @@+{-# OPTIONS_GHC -fno-warn-orphans #-}++module Test.Ollama.Property.Arbitrary () where++import Data.Text (Text)+import Data.Text qualified as T+import Ollama+import Ollama.Types.Message qualified as M+import Test.QuickCheck++genNonEmptyText :: Gen Text+genNonEmptyText = T.pack <$> listOf1 (elements ['a' .. 'z'])++instance Arbitrary Role where+ arbitrary = elements [System, User, Assistant, M.Tool]++instance Arbitrary ModelName where+ arbitrary = ModelName <$> genNonEmptyText++instance Arbitrary Digest where+ arbitrary = Digest . ("sha256:" <>) <$> genNonEmptyText++instance Arbitrary Base64Image where+ arbitrary = Base64Image <$> genNonEmptyText++instance Arbitrary Duration where+ arbitrary = Duration <$> choose (1, 1000000000)++instance Arbitrary ThinkingLevel where+ arbitrary = elements [ThinkLow, ThinkMedium, ThinkHigh, ThinkMax]++instance Arbitrary Think where+ arbitrary =+ oneof+ [ pure ThinkEnabled+ , pure ThinkDisabled+ , ThinkLevel <$> arbitrary+ ]++instance Arbitrary Message where+ arbitrary =+ Message+ <$> arbitrary+ <*> genNonEmptyText+ <*> pure Nothing+ <*> pure Nothing+ <*> pure Nothing+ <*> pure Nothing++instance Arbitrary Format where+ arbitrary = pure JsonFormat
+ test/Test/Ollama/Property/Roundtrip.hs view
@@ -0,0 +1,34 @@+module Test.Ollama.Property.Roundtrip (tests) where++import Data.Aeson (decode, encode)+import Data.Text qualified as T+import Ollama+import Test.Ollama.Property.Arbitrary ()+import Test.Tasty+import Test.Tasty.QuickCheck++tests :: TestTree+tests =+ testGroup+ "Property & Roundtrip QuickCheck Tests"+ [ testProperty "Role JSON roundtrip: ∀ x. decode (encode x) == Just x" $+ \(r :: Role) -> decode (encode r) === Just r+ , testProperty "Message JSON roundtrip: ∀ x. decode (encode x) == Just x" $+ \(msg :: Message) -> decode (encode msg) === Just msg+ , testProperty "ModelName JSON roundtrip: ∀ x. decode (encode x) == Just x" $+ \(m :: ModelName) -> decode (encode m) === Just m+ , testProperty "Think JSON roundtrip: ∀ x. decode (encode x) == Just x" $+ \(t :: Think) -> decode (encode t) === Just t+ , testProperty "Digest JSON roundtrip: ∀ x. decode (encode x) == Just x" $+ \(d :: Digest) -> decode (encode d) === Just d+ , testProperty "Duration JSON roundtrip: ∀ x. decode (encode x) == Just x" $+ \(dur :: Duration) -> decode (encode dur) === Just dur+ , testProperty "Idempotency property: ∀ x. encode (decoded x) == encode x" $+ \(msg :: Message) -> case decode (encode msg) :: Maybe Message of+ Nothing -> property False+ Just decoded -> encode decoded === encode msg+ , testProperty "Smart constructor invariant: mkModelName never returns empty ModelName" $+ \txt -> case mkModelName (T.pack txt) of+ Left err -> err === "Model name cannot be empty"+ Right (ModelName name) -> not (T.null name) === True+ ]
+ test/Test/Ollama/Unit/Config.hs view
@@ -0,0 +1,19 @@+module Test.Ollama.Unit.Config (tests) where++import Ollama.Client.Config+import Test.Tasty+import Test.Tasty.HUnit++tests :: TestTree+tests =+ testGroup+ "Unit Config Tests"+ [ testCase "defaultConfig settings" $ do+ assertEqual "Default base URL" "http://127.0.0.1:11434" (configBaseUrl defaultConfig)+ assertEqual "Default timeout" 300 (configTimeout defaultConfig)+ assertEqual "Default retry policy" NoRetry (configRetry defaultConfig)+ , testCase "RetryPolicy smart constructors" $ do+ assertEqual "noRetry" NoRetry noRetry+ assertEqual "constantRetry" (ConstantRetry 3 1000000) (constantRetry 3 1000000)+ assertEqual "exponentialRetry" (ExponentialRetry 5 500000) (exponentialRetry 5 500000)+ ]
+ test/Test/Ollama/Unit/Error.hs view
@@ -0,0 +1,22 @@+module Test.Ollama.Unit.Error (tests) where++import Control.Exception (try)+import Ollama.Error+import Test.Tasty+import Test.Tasty.HUnit++tests :: TestTree+tests =+ testGroup+ "Unit Error Tests"+ [ testCase "isRetryable correctly classifies transient vs non-transient errors" $ do+ assertBool "TimeoutError is retryable" (isRetryable TimeoutError)+ assertBool "ApiError is not retryable" (not $ isRetryable (ApiError 404 "Not Found"))+ assertBool "InvalidRequest is not retryable" (not $ isRetryable (InvalidRequest "Bad model"))+ , testCase "OllamaError Eq instance compares ApiError and TimeoutError" $ do+ assertEqual "ApiError equal" (ApiError 500 "Server Error") (ApiError 500 "Server Error")+ assertBool "ApiError not equal to TimeoutError" (ApiError 500 "Server Error" /= TimeoutError)+ , testCase "throwOllama raises OllamaError as an Exception" $ do+ res <- try (throwOllama TimeoutError) :: IO (Either OllamaError ())+ assertEqual "Caught thrown OllamaError" (Left TimeoutError) res+ ]
+ test/Test/Ollama/Unit/MCP.hs view
@@ -0,0 +1,100 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}++module Test.Ollama.Unit.MCP (tests) where++import Data.Map.Strict qualified as Map+import MCP.Server.Types (+ Content (..),+ ContentImageData (..),+ ResourceContent (..),+ ToolDefinition (..),+ describedSchema,+ mkToolDefinition,+ parseURI,+ schema,+ )+import Ollama.MCP (+ SchemaType (..),+ mcpContentToToolOutput,+ mcpDefinitionToTool,+ schemaDescription,+ schemaShape,+ toolCallToMcpArgs,+ toolToMcpDefinition,+ )+import Ollama.Types.Tool (+ FunctionDef (..),+ FunctionParameters (..),+ Tool (..),+ ToolCall (..),+ ToolCallFunction (..),+ )+import Test.Tasty+import Test.Tasty.HUnit++tests :: TestTree+tests =+ testGroup+ "Unit MCP Integration Tests (mcp-server)"+ [ testCase "toolToMcpDefinition converts Ollama Tool to mcp-server ToolDefinition" $ do+ let propMap =+ Map.fromList+ [ ("query", FunctionParameters "string" Nothing Nothing Nothing (Just "Search query") Nothing)+ , ("limit", FunctionParameters "integer" Nothing Nothing Nothing (Just "Max results") Nothing)+ ]+ params = FunctionParameters "object" (Just propMap) (Just ["query"]) Nothing Nothing Nothing+ ollamaTool = Tool "function" $ FunctionDef "search" (Just "Search codebase") (Just params) Nothing+ mcpDef = toolToMcpDefinition ollamaTool++ toolDefinitionName mcpDef @?= "search"+ toolDefinitionDescription mcpDef @?= "Search codebase"+ case schemaShape (toolDefinitionInputSchema mcpDef) of+ SchemaObject props req -> do+ req @?= ["query"]+ length props @?= 2+ case lookup "query" props of+ Just s -> do+ schemaDescription s @?= Just "Search query"+ schemaShape s @?= SchemaString Nothing+ Nothing -> assertFailure "Expected query in props"+ _ -> assertFailure "Expected SchemaObject"+ , testCase "mcpDefinitionToTool converts mcp-server ToolDefinition to Ollama Tool" $ do+ let props =+ [ ("path", describedSchema "File path" (SchemaString Nothing))+ , ("content", describedSchema "File content" (SchemaString Nothing))+ ]+ s = schema (SchemaObject props ["path", "content"])+ mcpDef = mkToolDefinition "write_file" "Write file to disk" s+ ollamaTool = mcpDefinitionToTool mcpDef++ toolType ollamaTool @?= "function"+ let fn = toolFunction ollamaTool+ fnName fn @?= "write_file"+ fnDescription fn @?= Just "Write file to disk"+ case fnParameters fn of+ Just FunctionParameters {..} -> do+ fpType @?= "object"+ fpRequired @?= Just ["path", "content"]+ case fpProperties of+ Just pm -> Map.member "path" pm @?= True+ Nothing -> assertFailure "Expected properties"+ Nothing -> assertFailure "Expected parameters"+ , testCase "toolCallToMcpArgs converts Ollama ToolCall to mcp-server argument pairs" $ do+ let call = ToolCall (ToolCallFunction "greet" (Map.fromList [("name", "Alice")]))+ (fn, args) = toolCallToMcpArgs call+ fn @?= "greet"+ lookup "name" args @?= Just "Alice"+ , testCase "mcpContentToToolOutput extracts text from mcp-server Content" $ do+ let textContent = ContentText "Execution output"+ mcpContentToToolOutput textContent @?= "Execution output"++ let imgContent = ContentImage (ContentImageData "base64..." "image/png")+ mcpContentToToolOutput imgContent @?= "[Image: image/png]"++ case parseURI "file:///workspace/test.txt" of+ Just uri -> do+ let resContent = ContentEmbeddedResource (ResourceText uri "text/plain" "file contents")+ mcpContentToToolOutput resContent @?= "[Resource: file:///workspace/test.txt]"+ Nothing -> assertFailure "Failed to parse URI"+ ]
+ test/Test/Ollama/Unit/SchemaBuilder.hs view
@@ -0,0 +1,33 @@+module Test.Ollama.Unit.SchemaBuilder (tests) where++import Data.Aeson (decode, encode)+import Ollama.Types.Format+import Test.Tasty+import Test.Tasty.HUnit++tests :: TestTree+tests =+ testGroup+ "Unit SchemaBuilder DSL Tests"+ [ testCase "Build object schema with required and optional fields" $ do+ let builder =+ emptyObject+ |+ ("name", JString)+ |+ ("age", JInteger)+ |+ ("is_student", JBoolean)+ |! "name"+ sch = buildSchema builder+ encoded = encode sch+ assertBool "Non-empty JSON schema" (not $ null $ show encoded)+ , testCase "Build array schema" $ do+ let itemType = arrayOf JString+ builder = emptyObject |+ ("items", itemType)+ sch = buildSchema builder+ encoded = encode sch+ assertBool "Non-empty array schema" (not $ null $ show encoded)+ , testCase "SchemaFormat wrapping & JSON roundtrip" $ do+ let builder = emptyObject |+ ("count", JInteger) |! "count"+ sch = buildSchema builder+ fmt = SchemaFormat sch+ assertEqual "SchemaFormat JSON roundtrip check" (Just fmt) (decode $ encode fmt)+ ]
+ test/Test/Ollama/Unit/SchemaDerive.hs view
@@ -0,0 +1,170 @@+module Test.Ollama.Unit.SchemaDerive (tests) where++import Data.Map.Strict qualified as Map+import Data.Text (Text)+import GHC.Generics (Generic)+import Ollama.Types.Format+import Test.Tasty+import Test.Tasty.HUnit++-- ---------------------------------------------------------------------------+-- Test types+-- ---------------------------------------------------------------------------++data SimplePerson = SimplePerson+ { name :: Text+ , age :: Int+ }+ deriving stock (Generic)+ deriving anyclass (ToSchema)++data PersonWithOptional = PersonWithOptional+ { personName :: Text+ , personAge :: Int+ , personNickname :: Maybe Text+ }+ deriving stock (Generic)+ deriving anyclass (ToSchema)++data Address = Address+ { city :: Text+ , zipCode :: Text+ }+ deriving stock (Generic)+ deriving anyclass (ToSchema, ToJsonType)++data PersonWithAddress = PersonWithAddress+ { fullName :: Text+ , homeAddress :: Address+ }+ deriving stock (Generic)+ deriving anyclass (ToSchema)++data PersonWithHobbies = PersonWithHobbies+ { hobbyName :: Text+ , hobbies :: [Text]+ }+ deriving stock (Generic)+ deriving anyclass (ToSchema)++data Color = Red | Green | Blue+ deriving stock (Generic)+ deriving anyclass (ToSchema)++data PersonWithMaybeInt = PersonWithMaybeInt+ { pmName :: Text+ , pmScore :: Maybe Int+ }+ deriving stock (Generic)+ deriving anyclass (ToSchema)++-- ---------------------------------------------------------------------------+-- Tests+-- ---------------------------------------------------------------------------++tests :: TestTree+tests =+ testGroup+ "Unit SchemaDerive Tests"+ [ testSimpleRecord+ , testOptionalFields+ , testNestedRecord+ , testArrayFields+ , testEnumType+ , testMaybeUnwrapsType+ , testSchemaForAlias+ , testFormatFor+ , testMatchesManualSchema+ ]++testSimpleRecord :: TestTree+testSimpleRecord = testCase "Simple record produces correct schema" $ do+ let schema = schemaFor @SimplePerson+ props = schemaProperties schema+ req = schemaRequired schema+ assertEqual "Has 'name' property" (Just (Property JString)) (Map.lookup "name" props)+ assertEqual "Has 'age' property" (Just (Property JInteger)) (Map.lookup "age" props)+ assertEqual "Has 2 properties" 2 (Map.size props)+ assertBool "'name' is required" ("name" `elem` req)+ assertBool "'age' is required" ("age" `elem` req)+ assertEqual "2 required fields" 2 (length req)++testOptionalFields :: TestTree+testOptionalFields = testCase "Maybe fields are not required" $ do+ let schema = schemaFor @PersonWithOptional+ props = schemaProperties schema+ req = schemaRequired schema+ assertEqual "Has 3 properties" 3 (Map.size props)+ assertEqual+ "'personNickname' maps to JString"+ (Just (Property JString))+ (Map.lookup "personNickname" props)+ assertBool "'personName' is required" ("personName" `elem` req)+ assertBool "'personAge' is required" ("personAge" `elem` req)+ assertBool "'personNickname' is NOT required" ("personNickname" `notElem` req)+ assertEqual "2 required fields" 2 (length req)++testNestedRecord :: TestTree+testNestedRecord = testCase "Nested record becomes JObject" $ do+ let schema = schemaFor @PersonWithAddress+ props = schemaProperties schema+ case Map.lookup "homeAddress" props of+ Just (Property (JObject innerSchema)) -> do+ let innerProps = schemaProperties innerSchema+ assertEqual "Inner has 'city'" (Just (Property JString)) (Map.lookup "city" innerProps)+ assertEqual "Inner has 'zipCode'" (Just (Property JString)) (Map.lookup "zipCode" innerProps)+ other -> assertFailure $ "Expected JObject for homeAddress, got: " <> show other++testArrayFields :: TestTree+testArrayFields = testCase "List fields become JArray" $ do+ let schema = schemaFor @PersonWithHobbies+ props = schemaProperties schema+ assertEqual+ "'hobbies' maps to JArray JString"+ (Just (Property (JArray JString)))+ (Map.lookup "hobbies" props)++testEnumType :: TestTree+testEnumType = testCase "Simple enum produces schema with constructor names" $ do+ let schema = schemaFor @Color+ req = schemaRequired schema+ -- Enum constructors are stored in required list (as enum values)+ assertBool "Contains 'red'" ("red" `elem` req)+ assertBool "Contains 'green'" ("green" `elem` req)+ assertBool "Contains 'blue'" ("blue" `elem` req)+ assertEqual "3 enum values" 3 (length req)+ assertEqual "No properties" 0 (Map.size $ schemaProperties schema)++testMaybeUnwrapsType :: TestTree+testMaybeUnwrapsType = testCase "Maybe Int field maps to JInteger" $ do+ let schema = schemaFor @PersonWithMaybeInt+ props = schemaProperties schema+ assertEqual "'pmScore' maps to JInteger" (Just (Property JInteger)) (Map.lookup "pmScore" props)++testSchemaForAlias :: TestTree+testSchemaForAlias = testCase "schemaFor is equivalent to toSchema" $ do+ let s1 = schemaFor @SimplePerson+ s2 = toSchema @SimplePerson+ assertEqual "schemaFor == toSchema" s1 s2++testFormatFor :: TestTree+testFormatFor = testCase "formatFor wraps in SchemaFormat" $ do+ let fmt = formatFor @SimplePerson+ expected = SchemaFormat (schemaFor @SimplePerson)+ assertEqual "formatFor wraps schema" expected fmt++testMatchesManualSchema :: TestTree+testMatchesManualSchema = testCase "Derived schema matches manual DSL schema" $ do+ let derived = schemaFor @SimplePerson+ manual =+ buildSchema $+ emptyObject+ |+ ("name", JString)+ |+ ("age", JInteger)+ |!! ["name", "age"]+ assertEqual "Properties match" (schemaProperties manual) (schemaProperties derived)+ -- Required fields should contain the same elements (order may differ)+ let derivedReq = schemaRequired derived+ manualReq = schemaRequired manual+ assertBool "All derived required fields in manual" (all (`elem` manualReq) derivedReq)+ assertBool "All manual required fields in derived" (all (`elem` derivedReq) manualReq)
+ test/Test/Ollama/Unit/Testing.hs view
@@ -0,0 +1,87 @@+{-# OPTIONS_GHC -Wno-deprecations #-}++{- |+Module : Test.Ollama.Unit.Testing+Copyright : (c) 2024-2026 Tushar Adhatrao+License : MIT+Maintainer : tusharadhatrao@gmail.com+Stability : stable+Portability : portable++Unit tests for Ollama.Testing mock client and helper functions.++@since 1.0.0.0+-}+module Test.Ollama.Unit.Testing (testingTests) where++import Data.Aeson (encode)+import Data.ByteString.Lazy qualified as LBS+import Data.List.NonEmpty (NonEmpty ((:|)))+import Ollama.API.Chat (ChatResponse (..), chat, chatRequest)+import Ollama.API.Embed (+ EmbedResponse (..),+ EmbeddingsRequest (..),+ EmbeddingsResponse (..),+ embed,+ embedRequest,+ embeddings,+ )+import Ollama.API.Generate (GenerateResponse (..), generate, generateRequest)+import Ollama.API.Models (ListResponse (..), listModels)+import Ollama.Testing (+ mockChatResponse,+ mockEmbedResponse,+ mockGenerateResponse,+ mockListModelsResponse,+ withMockClient,+ )+import Ollama.Types.Common (ModelName (..))+import Ollama.Types.Message (userMessage)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertEqual, testCase)++testingTests :: TestTree+testingTests =+ testGroup+ "Unit Testing Infrastructure Tests"+ [ testCase "Mock generate response" $ do+ let respPayload = mockGenerateResponse (ModelName "llama3.2") "Sky is blue"+ encoded = encode respPayload+ withMockClient (LBS.toStrict encoded) $ \client -> do+ res <- generate client (generateRequest (ModelName "llama3.2") "Why?")+ case res of+ Left err -> fail $ "Expected success, got: " <> show err+ Right resp -> assertEqual "Matching text" "Sky is blue" (grResponse resp)+ , testCase "Mock chat response" $ do+ let respPayload = mockChatResponse (ModelName "llama3.2") "Hello there"+ encoded = encode respPayload+ withMockClient (LBS.toStrict encoded) $ \client -> do+ res <- chat client (chatRequest (ModelName "llama3.2") (userMessage "Hi" :| []))+ case res of+ Left err -> fail $ "Expected success, got: " <> show err+ Right resp -> assertEqual "Matching model" (ModelName "llama3.2") (crModel resp)+ , testCase "Mock embed response" $ do+ let respPayload = mockEmbedResponse (ModelName "nomic") [[0.1, 0.2]]+ encoded = encode respPayload+ withMockClient (LBS.toStrict encoded) $ \client -> do+ res <- embed client (embedRequest (ModelName "nomic") ["test"])+ case res of+ Left err -> fail $ "Expected success, got: " <> show err+ Right resp -> assertEqual "Matching embeddings" [[0.1, 0.2]] (erEmbeddings resp)+ , testCase "Mock deprecated embeddings endpoint" $ do+ let respPayload = EmbeddingsResponse [0.5, 0.6]+ encoded = encode respPayload+ withMockClient (LBS.toStrict encoded) $ \client -> do+ res <- embeddings client (EmbeddingsRequest (ModelName "nomic") "test" Nothing Nothing)+ case res of+ Left err -> fail $ "Expected success, got: " <> show err+ Right resp -> assertEqual "Matching embedding" [0.5, 0.6] (ebrEmbedding resp)+ , testCase "Mock list models response" $ do+ let respPayload = mockListModelsResponse [ModelName "llama3.2", ModelName "nomic"]+ encoded = encode respPayload+ withMockClient (LBS.toStrict encoded) $ \client -> do+ res <- listModels client+ case res of+ Left err -> fail $ "Expected success, got: " <> show err+ Right resp -> assertEqual "Matching count" 2 (length (models resp))+ ]
+ test/Test/Ollama/Unit/Types.hs view
@@ -0,0 +1,72 @@+module Test.Ollama.Unit.Types (tests) where++import Conduit (yield)+import Data.Aeson (decode, encode)+import Data.List.NonEmpty (NonEmpty ((:|)))+import Data.Time (getCurrentTime)+import Ollama+import Ollama.Types.Message qualified as M+import Test.Tasty+import Test.Tasty.HUnit++tests :: TestTree+tests =+ testGroup+ "Unit Types & API Tests"+ [ testCase "ModelName smart constructor validation" $ do+ assertEqual "Empty name invalid" (Left "Model name cannot be empty") (mkModelName "")+ assertEqual "Valid name" (Right "llama3.2") (mkModelName "llama3.2")+ , testCase "GenerateRequest smart constructor" $ do+ let req = generateRequest "llama3.2" "Hello world"+ assertEqual "Model match" (ModelName "llama3.2") (genModel req)+ assertEqual "Prompt match" "Hello world" (genPrompt req)+ assertEqual "Stream default" (Just False) (genStream req)+ , testCase "ChatRequest smart constructor" $ do+ let msg = userMessage "Hi"+ req = chatRequest "llama3.2" (msg :| [])+ assertEqual "Model match" (ModelName "llama3.2") (chatModel req)+ assertEqual "Messages length" 1 (length (chatMessages req))+ , testCase "EmbedRequest smart constructor" $ do+ let req = embedRequest "nomic-embed-text" ["hello", "world"]+ assertEqual "Model match" (ModelName "nomic-embed-text") (embModel req)+ assertEqual "Input match" (Right ["hello", "world"]) (embInput req)+ , testCase "CreateRequest smart constructor" $ do+ let req = defaultCreateRequest "custom-model"+ assertEqual "Model match" (ModelName "custom-model") (crqModel req)+ , testCase "Role JSON roundtrip" $ do+ assertEqual "User role" (Just User) (decode (encode User))+ assertEqual "Assistant role" (Just Assistant) (decode (encode Assistant))+ assertEqual "System role" (Just System) (decode (encode System))+ assertEqual "Tool role" (Just M.Tool) (decode (encode M.Tool))+ , testCase "InMemoryStore operations" $ do+ store <- initInMemoryStore+ now <- getCurrentTime+ let conv =+ Conversation+ { conversationId = "c1"+ , messages = [userMessage "hi"]+ , model = "llama3.2"+ , createdAt = now+ , lastUpdated = now+ }+ saveConversationInMemory store conv+ mLoaded <- loadConversationInMemory store "c1"+ assertEqual "Load conversation" (Just conv) mLoaded++ allConvs <- listConversationsInMemory store+ assertEqual "List conversations" [conv] allConvs++ deleted <- deleteConversationInMemory store "c1"+ assertBool "Delete succeeded" deleted++ mLoadedAfter <- loadConversationInMemory store "c1"+ assertEqual "Load after delete" Nothing mLoadedAfter+ , testCase "Streaming collectStream helper" $ do+ let mockStream = mapM_ yield [1 .. 5 :: Int]+ items <- collectStream mockStream+ assertEqual "Collected list" [1, 2, 3, 4, 5] items+ , testCase "Streaming foldStream helper" $ do+ let mockStream = mapM_ yield [1 .. 5 :: Int]+ sumVal <- foldStream (+) 0 mockStream+ assertEqual "Folded sum" 15 sumVal+ ]