ollama-haskell 0.2.1.0 → 0.3.0.0
raw patch · 81 files changed
+4664/−4997 lines, 81 filesdep +QuickCheckdep +case-insensitivedep +conduitdep −base64-bytestringdep −directorydep −filepathdep ~aesondep ~basedep ~bytestringnew-component:exe:ollama-example-all-featuresnew-component:exe:ollama-example-basic-chatnew-component:exe:ollama-example-embeddingsnew-component:exe:ollama-example-model-managementnew-component:exe:ollama-example-streaming-chatnew-component:exe:ollama-example-structured-outputnew-component:exe:ollama-example-tool-callingPVP ok
version bump matches the API change (PVP)
Dependencies added: QuickCheck, case-insensitive, conduit, conduit-extra, hashable, resourcet, retry, tasty-bench, tasty-golden, tasty-quickcheck, unliftio-core
Dependencies removed: base64-bytestring, directory, filepath, scientific, silently
Dependency ranges changed: aeson, base, bytestring, containers, mtl, stm, tasty, text, time
API changes (from Hackage documentation)
Files
- CHANGELOG.md +27/−61
- README.md +136/−57
- bench/Main.hs +66/−0
- doc/COMPARISON.md +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 +28/−0
- examples/ToolCalling.hs +41/−0
- ollama-haskell.cabal +223/−97
- src/Data/Ollama/Blob.hs +0/−176
- src/Data/Ollama/Chat.hs +0/−295
- src/Data/Ollama/Common/Config.hs +0/−90
- src/Data/Ollama/Common/Error.hs +0/−65
- src/Data/Ollama/Common/SchemaBuilder.hs +0/−191
- src/Data/Ollama/Common/Types.hs +0/−464
- src/Data/Ollama/Common/Utils.hs +0/−303
- src/Data/Ollama/Conversation.hs +0/−186
- src/Data/Ollama/Copy.hs +0/−86
- src/Data/Ollama/Create.hs +0/−214
- src/Data/Ollama/Delete.hs +0/−71
- src/Data/Ollama/Embeddings.hs +0/−189
- src/Data/Ollama/Generate.hs +0/−248
- src/Data/Ollama/List.hs +0/−105
- src/Data/Ollama/Load.hs +0/−103
- src/Data/Ollama/Ps.hs +0/−109
- src/Data/Ollama/Pull.hs +0/−153
- src/Data/Ollama/Push.hs +0/−115
- src/Data/Ollama/Show.hs +0/−230
- src/Ollama.hs +160/−170
- 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 +65/−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 +38/−0
- src/Ollama/Types/Format/SchemaBuilder.hs +223/−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 +22/−37
- test/Test/Ollama/Blob.hs +0/−50
- test/Test/Ollama/Chat.hs +0/−307
- test/Test/Ollama/Common.hs +0/−91
- test/Test/Ollama/Copy.hs +0/−45
- test/Test/Ollama/Create.hs +0/−79
- test/Test/Ollama/Delete.hs +0/−42
- test/Test/Ollama/Embedding.hs +0/−53
- test/Test/Ollama/Generate.hs +0/−360
- 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/List.hs +0/−41
- test/Test/Ollama/Load.hs +0/−53
- test/Test/Ollama/Property/Arbitrary.hs +51/−0
- test/Test/Ollama/Property/Roundtrip.hs +34/−0
- test/Test/Ollama/Show.hs +0/−61
- test/Test/Ollama/Unit/Config.hs +19/−0
- test/Test/Ollama/Unit/Error.hs +22/−0
- test/Test/Ollama/Unit/SchemaBuilder.hs +33/−0
- test/Test/Ollama/Unit/Testing.hs +87/−0
- test/Test/Ollama/Unit/Types.hs +72/−0
@@ -1,67 +1,33 @@-# Revision history for ollama-haskell--## Unreleased--## 0.2.1.0 -- 2025-09-24--* Added blob module for managing binary large objects (GGUF files/safetensors).-* Breaking: Added `dimensions` option in embeddings for specifying embedding dimensions.-* Breaking: Added `doneReason` field in chat and generate responses.-* Improved test suite with more comprehensive test cases.-* Added `onComplete` callback function in stream handlers for better streaming control.-* Breaking: Standardized field names across the library for consistency.-* Applied fourmolu code formatting and added hlint for better code quality.--## 0.2.0.0 -- 2025-06-05--* Added stack matrix to ensure lib is buildable from lts-19.33-* Made parameters & template fields optional in `ShowModelResponse`.-* Added extra parameters fields in `ModelInfo`.-* Added strict annotations for all fields.-* Fixed ToJSON instance for delete model request body.-* Removed duplicate code by using unified `withOllamaRequest` function for all API calls.-* Added unified config type `OllamaConfig` to hold common configuration options.-* Added validation for generate and chat functions to ensure required fields are present.-* Added convience functions for generating Message and ToolCall types.-* Added thinking field for chat and generate function.-* Added ModelOptions type to encapsulate model options.-* Added get ollama version function.-* Added Common Manager, Callback functions and retry option in OllamaConfig.-* Fixed tool_calls.-* Added MonadIO versions of api functions.-* Added more comprehensive error handling for API calls.-* Added more comprehensive test cases for all functions.-* Added schema builder for passing json format for structured output.--## 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.--## 0.1.1.3 -- 2024-11-08--* Increase response timeout to 15 minutes-* Added encodeImage utility function that converts image filePath to base64 image data.-* Added generateJson and chatJson. High level function to return response in Haskell type.--## 0.1.0.3 -- 2024-11-05--* Moving to stack instead of cabal.+# Changelog -## 0.1.0.2 -- 2024-10-18+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/). -* 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`.
@@ -1,102 +1,181 @@-# 🦙 Ollama Haskell+# ollama-haskell -<div style="text-align: center;">-<img src="./examples/ollama_haskell.png" alt="logo image" height="200"/>-</div>+[](https://hackage.haskell.org/package/ollama-haskell)+[](LICENSE) -**`ollama-haskell`** is an unofficial Haskell client for [Ollama](https://ollama.com), inspired by [`ollama-python`](https://github.com/ollama/ollama-python). It enables interaction with locally running LLMs through the Ollama HTTP API — directly from Haskell.+Industry-grade, feature-complete, modern Haskell client library for the [Ollama](https://ollama.com) local LLM engine. +## 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`).+- **Complete API Surface**: Text generation, chat completions, vector embeddings, model management (list, show, copy, delete, pull, push, create), and system endpoints.+- **Structured Outputs**: 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`), 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).+ --- -## ✨ Features+## Installation -* 💬 Chat with models-* ✍️ Text generation (with streaming)-* ✅ Chat with structured messages and tools-* 🧠 Embeddings-* 🧰 Model management (list, pull, push, show, delete)-* 🗃️ In-memory conversation history-* ⚙️ Configurable timeouts, retries, streaming handlers+Add `ollama-haskell` to your `.cabal` file: +```cabal+build-depends:+ base >= 4.17 && < 5+ , ollama-haskell >= 0.3.0.0+```++Or using Stack in `package.yaml`:++```yaml+dependencies:+ - ollama-haskell >= 0.3.0.0+```+ --- -## ⚡ Quick Example+## Quick Start (5 Lines) ```haskell-{-# LANGUAGE OverloadedStrings #-}-module Main where--import Data.Ollama.Generate-import qualified Data.Text.IO as T+import Data.List.NonEmpty (NonEmpty ((:|)))+import Data.Text.IO qualified as TIO+import Ollama main :: IO () main = do- let ops =- defaultGenerateOps- { modelName = "gemma3"- , prompt = "What is the meaning of life?"- }- eRes <- generate ops Nothing- case eRes of- Left err -> putStrLn $ "Something went wrong: " ++ show err- Right r -> do- putStr "LLM response: "- T.putStrLn (genResponse r)+ 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) ``` --- -## 📦 Installation+## Streaming Responses with Conduit -Add to your `.cabal` file:+Stream LLM responses token-by-token as they generate: -```cabal-build-depends:- base >=4.7 && <5,- ollama-haskell-```+```haskell+import Data.List.NonEmpty (NonEmpty ((:|)))+import Data.Text.IO qualified as TIO+import Ollama -Or use with `stack`/`nix-shell`.+main :: IO ()+main = do+ 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 ""+``` --- -## 📚 More Examples+## Function & Tool Calling -See [`examples/OllamaExamples.hs`](examples/OllamaExamples.hs) for:+Define function signatures and let the LLM execute structured tool calls: -* Chat with conversation memory-* Structured JSON output-* Embeddings-* Tool/function calling-* Multimodal input-* Streaming and non-streaming variants+```haskell+import Data.List.NonEmpty (NonEmpty ((:|)))+import Ollama +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)+```+ --- -## 🛠 Prerequisite+## Structured Outputs (JSON Schema DSL) -Make sure you have [Ollama installed and running locally](https://ollama.com/download). Run `ollama pull llama3` to download a model.+Enforce structured JSON output formats using `SchemaBuilder`: +```haskell+import Data.Text.IO qualified as TIO+import Ollama+import Ollama.Types.Format.SchemaBuilder++personSchema :: Schema+personSchema = buildSchema $ emptyObject+ |+ ("name", JString)+ |+ ("age", JInteger)+ |! "name"++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)+```+ --- -## 🧪 Dev & Nix Support+## Environment Variables & Configuration -Use Nix:+Construct a client using environment variables (`OLLAMA_HOST`, `OLLAMA_API_KEY`): -```bash-nix-shell+```haskell+main :: IO ()+main = do+ client <- clientFromEnv+ -- Automatically connects to OLLAMA_HOST with optional Authorization: Bearer header+ ... ``` -This will install `stack` and Ollama.+Or configure custom retry policies and loggers: +```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)+ }++main :: IO ()+main = withClient customConfig $ \client -> do+ ...+```+ --- -## 👨💻 Author+## Documentation & SDK Comparison -Created and maintained by [@tusharad](https://github.com/tusharad). PRs and feedback are welcome!+- [doc/COMPARISON.md](doc/COMPARISON.md) — SDK Feature Matrix comparing `ollama-haskell` with Python, JS/TS, and Go SDKs.+- [ARCHITECTURE.md](ARCHITECTURE.md) — Detailed internal module design and extension guide.+- [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. --- -## 🤝 Contributing+## License -Have ideas or improvements? Feel free to [open an issue](https://github.com/tusharad/ollama-haskell/issues) or submit a PR!+MIT © 2024–2026 Tushar Adhatrao
@@ -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]]
@@ -0,0 +1,66 @@+# SDK Feature & Architecture Comparison++This document provides a comprehensive technical comparison between **`ollama-haskell` (v0.3.0.0)**, official SDKs in other ecosystems (**Python**, **JavaScript/TypeScript**, **Go**), and existing Haskell LLM libraries.++---++## 1. Feature Support Matrix Across Language SDKs++| Feature / Endpoint | `ollama-haskell` (v0.3.0.0) | `ollama-python` | `ollama-js` | `ollama/ollama/api` (Go) |+| :--- | :---: | :---: | :---: | :---: |+| **Language Paradigm** | Pure Functional / Typed | Dynamic / Async | Dynamic / Promises | Imperative / Structs |+| **Response Streaming** | `conduit` Pipelines | Iterator / AsyncGenerator | AsyncIterable | Channel / Callback |+| **Chat Completions (`/api/chat`)** | ✅ | ✅ | ✅ | ✅ |+| **Text Generation (`/api/generate`)** | ✅ | ✅ | ✅ | ✅ |+| **Thinking / Reasoning Models** | ✅ `Think` ADT (`qwen3.5`, `deepseek-r1`) | ⚠️ Raw JSON parameter | ⚠️ Raw JSON parameter | ⚠️ Raw JSON parameter |+| **Structured Output Schema** | ✅ Type-safe `SchemaBuilder` DSL | ⚠️ Pydantic / Raw Dict | ⚠️ Zod / Raw Schema | ⚠️ Struct tag / Raw Schema |+| **Function / Tool Calling** | ✅ Strongly-typed `Tool` ADT | ✅ Dict / Callables | ✅ JSON schema objects | ✅ Go Structs |+| **Vector Embeddings (`/api/embed`)** | ✅ | ✅ | ✅ | ✅ |+| **Model Lifecycle (`copy`, `delete`, `show`, `ps`)** | ✅ | ✅ | ✅ | ✅ |+| **Blob Management (`checkBlob`, `pushBlob`)** | ✅ | ✅ | ✅ | ✅ |+| **Automatic Retry Policy** | ✅ `NoRetry`, `Constant`, `Exponential` | ❌ (User implementation) | ❌ (User implementation) | ❌ (User implementation) |+| **Connection Lifecycles** | ✅ Automatic GC & Bracket | ⚠️ Manual session | ⚠️ Fetch client | ⚠️ Manual `http.Client` |+| **STM Session Management** | ✅ `InMemoryStore` & `ConversationStore` | ❌ | ❌ | ❌ |+| **Mock Testing Harness** | ✅ `Ollama.Testing` (`withMockClient`) | ❌ | ❌ | ❌ |++---++## 2. Comparison with Other Haskell LLM Libraries++| Feature | `ollama-haskell` | `openai-hs` | `langchain-hs` |+| :--- | :--- | :--- | :--- |+| **Target Provider** | Local Ollama Engine | OpenAI Cloud API | Multi-provider Framework |+| **Streaming Abstraction** | First-class `conduit` streams | Lazy ByteString / SSE | Custom Stream types |+| **Retry & Resilience** | Integrated `retry` policy backoff | None | Basic |+| **Mocking & Testing** | Built-in `Ollama.Testing` module | None | Mock handlers |+| **Memory Persistence** | STM-backed `InMemoryStore` | Manual | Vector store abstractions |++---++## 3. Key Architectural Advantages of `ollama-haskell`++1. **Type Safety & Smart Constructors**:+ - String parameters are wrapped in domain-specific newtypes (`ModelName`, `Digest`, `Base64Image`, `Duration`, `Version`).+ - `mkModelName` validates model name invariants at runtime.++2. **First-Class Streaming via `conduit`**:+ - `chatStream` and `generateStream` stream line-delimited JSON objects over constant memory pipelines without buffering entire responses in memory.+ - Stream combinators `collectStream` and `foldStream` simplify consumption.++3. **Domain-Specific Schema Builder (`SchemaBuilder`)**:+ - Construct complex JSON Schemas using readable infix operators:+ ```haskell+ userSchema = buildSchema $+ emptyObject+ |+ ("name", JString)+ |+ ("age", JInteger)+ |! "name"+ ```++4. **Built-In Resilient Transport**:+ - Exponential backoff retry strategies (`ExponentialRetry 3 1.0`).+ - Lifecycle callbacks (`configOnStart`, `configOnSuccess`, `configOnError`).+ - Customizable logger thresholds (`Debug`, `Info`, `Warn`, `Error`).++5. **Mock Infrastructure (`Ollama.Testing`)**:+ - Test application code deterministically offline using `withMockClient` without needing a running Ollama server during CI unit testing.
@@ -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 "=========================================================="
@@ -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"
@@ -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."
@@ -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
@@ -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 ""
@@ -0,0 +1,28 @@+module Main (main) where++import Data.Text.IO qualified as TIO+import Ollama+import Ollama.Types.Format.SchemaBuilder++personSchema :: Schema+personSchema =+ buildSchema $+ emptyObject+ |+ ("name", JString)+ |+ ("age", JInteger)+ |! "name"++main :: IO ()+main = do+ client <- defaultClient+ let opts = Just (defaultOptions {optNumPredict = Just 20})+ req =+ (generateRequest "qwen3.5:2b" "Generate a person profile.")+ { genFormat = Just (SchemaFormat personSchema)+ , genOptions = opts+ , genThink = Just ThinkDisabled+ }+ res <- generate client req+ case res of+ Left err -> putStrLn $ "Error: " <> show err+ Right resp -> TIO.putStrLn $ "Structured Response:\n" <> grResponse resp
@@ -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)
@@ -1,115 +1,241 @@-cabal-version: 1.12---- This file has been generated from package.yaml by hpack version 0.38.1.------ see: https://github.com/sol/hpack--name: ollama-haskell-version: 0.2.1.0-synopsis: Haskell client 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.3.0.0+synopsis: Industry-grade Haskell client for the Ollama API+description:+ A type-safe, well-tested 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, and more.+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+ doc/COMPARISON.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++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.Blob- Data.Ollama.Chat- Data.Ollama.Common.Config- Data.Ollama.Common.Error- Data.Ollama.Common.SchemaBuilder- Data.Ollama.Common.Types- Data.Ollama.Common.Utils- Data.Ollama.Conversation- Data.Ollama.Copy- Data.Ollama.Create- Data.Ollama.Delete- Data.Ollama.Embeddings- Data.Ollama.Generate- Data.Ollama.List- Data.Ollama.Load- 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.Common+ Ollama.Error+ Ollama.Streaming+ Ollama.Testing+ Ollama.Conversation 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 ==2.*- , base >=4.7 && <5- , base64-bytestring ==1.*- , bytestring >=0.10 && <0.13- , containers >=0.6 && <0.9- , directory >=1 && <1.4- , filepath >=1 && <1.6- , http-client >=0.6 && <0.8- , http-client-tls >=0.2 && <0.4- , http-types >=0.7 && <0.13- , mtl ==2.*- , stm ==2.*- , text >=1 && <3- , time ==1.*- 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 test-suite ollama-haskell-test+ import: warnings, lang type: exitcode-stdio-1.0+ hs-source-dirs: test main-is: Main.hs other-modules:- Test.Ollama.Blob- Test.Ollama.Chat- Test.Ollama.Common- Test.Ollama.Copy- Test.Ollama.Create- Test.Ollama.Delete- Test.Ollama.Embedding- Test.Ollama.Generate- Test.Ollama.List- Test.Ollama.Load- Test.Ollama.Show- 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.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 build-depends:- aeson- , base >=4.7 && <5- , base64-bytestring ==1.*- , bytestring >=0.10 && <0.13- , containers >=0.6 && <0.9- , directory >=1 && <1.4- , filepath >=1 && <1.6- , http-client >=0.6 && <0.8- , http-client-tls >=0.2 && <0.4- , http-types >=0.7 && <0.13- , mtl ==2.*+ base , ollama-haskell- , scientific- , silently- , stm ==2.*- , tasty >=1.5+ , aeson+ , bytestring+ , containers+ , QuickCheck >= 2.14+ , tasty >= 1.5+ , tasty-golden >= 2.3 , tasty-hunit+ , tasty-quickcheck >= 0.10 , text- , time ==1.*- default-language: Haskell2010+ , 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+ , aeson+ , tasty+ , tasty-hunit+ , text+ , time++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+ , text++executable ollama-example-structured-output+ import: warnings, lang+ main-is: StructuredOutput.hs+ hs-source-dirs: examples+ build-depends:+ base+ , ollama-haskell+ , text++executable ollama-example-embeddings+ import: warnings, lang+ main-is: Embeddings.hs+ hs-source-dirs: examples+ build-depends:+ base+ , ollama-haskell+ , text++executable ollama-example-model-management+ import: warnings, lang+ main-is: ModelManagement.hs+ hs-source-dirs: examples+ build-depends:+ base+ , ollama-haskell+ , text++executable ollama-example-all-features+ import: warnings, lang+ main-is: AllFeatures.hs+ hs-source-dirs: examples+ build-depends:+ base+ , ollama-haskell+ , aeson+ , text+ , time+
@@ -1,176 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RecordWildCards #-}--{- |-Module : Data.Ollama.Blob-Copyright : (c) 2025 Tushar Adhatrao-License : MIT-Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>-Stability : experimental-Description : Functions for managing binary large objects (blobs) in the Ollama API.--This module provides functionality for checking the existence of blobs and uploading-files as blobs to the Ollama server. Blobs are used when creating models from-GGUF files or safetensors directories.--The blob operations interact with the @\/api\/blobs@ endpoints:--- 'checkBlobExists' sends a HEAD request to check if a blob exists-- 'createBlob' uploads a file as a blob using streaming POST--Example usage:-->>> let digest = "sha256:29fdb92e57cf0827ded04ae6461b5931d01fa595843f55d36f5b275a52087dd2"->>> exists <- checkBlobExists digest Nothing->>> case exists of->>> Right True -> putStrLn "Blob exists"->>> Right False -> putStrLn "Blob not found"->>> Left err -> putStrLn $ "Error: " ++ show err-->>> result <- createBlob "model.gguf" digest Nothing->>> case result of->>> Right () -> putStrLn "Blob uploaded successfully"->>> Left err -> putStrLn $ "Upload failed: " ++ show err--}-module Data.Ollama.Blob- ( -- * Blob Operations- checkBlobExists- , createBlob- ) where--import Control.Exception (try)-import Data.ByteString qualified as BS-import Data.Maybe (fromMaybe)-import Data.Ollama.Common.Config (OllamaConfig (..), defaultOllamaConfig)-import Data.Ollama.Common.Error-import Data.Ollama.Common.Error qualified as Error-import Data.Text (Text)-import Data.Text qualified as T-import Data.Text.Encoding qualified as TE-import Network.HTTP.Client-import Network.HTTP.Client.TLS-import Network.HTTP.Types (Status (statusCode))-import System.Directory (doesFileExist)--{- | Check if a blob exists on the Ollama server.--Sends a HEAD request to @\/api\/blobs\/sha256:\<digest\>@ to check if a blob-with the given SHA256 digest exists on the server.--Returns:-- 'Right' 'True' if the blob exists (HTTP 200 OK)-- 'Right' 'False' if the blob does not exist (HTTP 404 Not Found)-- 'Left' 'OllamaError' for other errors (network issues, invalid digest, etc.)--The digest should be a SHA256 hash string without the "sha256:" prefix.--}-checkBlobExists ::- -- | SHA256 digest of the blob (without "sha256:" prefix)- Text ->- -- | Optional 'OllamaConfig' (defaults to 'defaultOllamaConfig' if 'Nothing')- Maybe OllamaConfig ->- IO (Either OllamaError Bool)-checkBlobExists digest mbOllamaConfig = do- let OllamaConfig {..} = fromMaybe defaultOllamaConfig mbOllamaConfig- endpoint = "/api/blobs/sha256:" <> digest- fullUrl = T.unpack $ hostUrl <> endpoint- timeoutMicros = timeout * 1000000-- manager <- case commonManager of- Nothing ->- newTlsManagerWith- tlsManagerSettings {managerResponseTimeout = responseTimeoutMicro timeoutMicros}- Just m -> pure m-- eRequest <- try $ parseRequest fullUrl- case eRequest of- Left ex -> return $ Left $ Error.HttpError ex- Right req -> do- let request = req {method = "HEAD"}- eResponse <- try $ httpNoBody request manager- case eResponse of- Left ex -> return $ Left $ Error.HttpError ex- Right response -> do- let status = statusCode $ responseStatus response- case status of- 200 -> return $ Right True- 404 -> return $ Right False- _ ->- return $- Left $- Error.ApiError $- "Unexpected status code: " <> T.pack (show status)--{- | Upload a file as a blob to the Ollama server.--Streams the contents of the file at the given 'FilePath' to the Ollama server-via a POST request to @\/api\/blobs\/sha256:\<digest\>@. The server will verify-that the uploaded file matches the expected SHA256 digest.--Returns:-- 'Right' @()@ if the blob was uploaded successfully (HTTP 201 Created)-- 'Left' 'OllamaError' if the upload failed--The digest should be the expected SHA256 hash of the file without the "sha256:" prefix.-If the file content doesn't match the digest, the server will return an error.--Note: This function streams the file content, so it can handle large files efficiently-without loading them entirely into memory.--}-createBlob ::- -- | Path to the file to upload- FilePath ->- -- | Expected SHA256 digest of the file (without "sha256:" prefix)- Text ->- -- | Optional 'OllamaConfig' (defaults to 'defaultOllamaConfig' if 'Nothing')- Maybe OllamaConfig ->- IO (Either OllamaError ())-createBlob filePath digest mbOllamaConfig = do- let OllamaConfig {..} = fromMaybe defaultOllamaConfig mbOllamaConfig- endpoint = "/api/blobs/sha256:" <> digest- fullUrl = T.unpack $ hostUrl <> endpoint- timeoutMicros = timeout * 1000000-- manager <- case commonManager of- Nothing ->- newTlsManagerWith- tlsManagerSettings {managerResponseTimeout = responseTimeoutMicro timeoutMicros}- Just m -> pure m-- eRequest <- try $ parseRequest fullUrl- case eRequest of- Left ex -> return $ Left $ Error.HttpError ex- Right req -> do- -- check if file exists- fileExists <- doesFileExist filePath- if not fileExists- then return $ Left $ Error.ApiError "File does not exist"- else do- -- Create a request body from the file- fileContent <- BS.readFile filePath- let request =- req- { method = "POST"- , requestBody = RequestBodyBS fileContent- }-- eResponse <- try $ withResponse request manager $ \response -> do- let status = statusCode $ responseStatus response- case status of- 201 -> return $ Right ()- 400 -> do- bodyReader <- brRead $ responseBody response- return $- Left $- Error.ApiError $- "Bad request - digest mismatch: " <> TE.decodeUtf8 bodyReader- _ -> do- bodyReader <- brRead $ responseBody response- return $- Left $- Error.ApiError $- "Unexpected status code " <> T.pack (show status) <> ": " <> TE.decodeUtf8 bodyReader-- case eResponse of- Left ex -> return $ Left $ Error.HttpError ex- Right result -> return result
@@ -1,295 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--{- |-Module : Data.Ollama.Chat-Copyright : (c) 2025 Tushar Adhatrao-License : MIT-Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>-Stability : experimental-Description : Chat functionality for interacting with the Ollama API.--This module provides functions and types for initiating and managing chat interactions with an Ollama model.-It includes APIs for sending chat requests, constructing messages with different roles, and configuring chat-operations. The module supports both streaming and non-streaming responses, as well as optional tools and-structured output formats.--The primary functions are 'chat' and 'chatM' for sending chat requests, and helper functions like-'systemMessage', 'userMessage', 'assistantMessage', and 'toolMessage' for constructing messages.-The 'ChatOps' type allows customization of chat parameters, and 'defaultChatOps' provides a convenient-starting point for configuration.--Example:-->>> let ops = defaultChatOps { chatModelName = "customModel", messages = userMessage "Hello!" :| [] }->>> chat ops Nothing-Either OllamaError ChatResponse--}-module Data.Ollama.Chat- ( -- * Chat APIs- chat- , chatM-- -- * Message Types- , Message (..)- , Role (..)- , systemMessage- , userMessage- , assistantMessage- , toolMessage- , genMessage-- -- * Chat Configuration- , defaultChatOps- , ChatOps (..)-- -- * Response Types- , ChatResponse (..)- , Format (..)-- -- * Configuration and Error Types- , OllamaConfig (..)- , defaultOllamaConfig- , OllamaError (..)- , ModelOptions (..)- , defaultModelOptions-- -- * Tool and Function Types- , InputTool (..)- , FunctionDef (..)- , FunctionParameters (..)- , OutputFunction (..)- , ToolCall (..)- ) where--import Control.Monad.IO.Class (MonadIO (liftIO))-import Data.Aeson-import Data.List.NonEmpty as NonEmpty-import Data.Maybe (isNothing)-import Data.Ollama.Common.Config-import Data.Ollama.Common.Error (OllamaError (..))-import Data.Ollama.Common.Types- ( ChatResponse (..)- , Format (..)- , FunctionDef (..)- , FunctionParameters (..)- , InputTool (..)- , Message (..)- , ModelOptions (..)- , OutputFunction (..)- , Role (..)- , ToolCall (..)- )-import Data.Ollama.Common.Utils as CU-import Data.Text (Text)-import Data.Text qualified as T--{- | Constructs a 'Message' with the specified role and content.--Creates a 'Message' with the given 'Role' and textual content, setting optional fields-('images', 'tool_calls', 'thinking') to 'Nothing'.--Example:-->>> genMessage User "What's the weather like?"-Message {role = User, content = "What's the weather like?", images = Nothing, tool_calls = Nothing, thinking = Nothing}--}-genMessage :: Role -> Text -> Message-genMessage r c =- Message- { role = r- , content = c- , images = Nothing- , tool_calls = Nothing- , thinking = Nothing- }--{- | Creates a 'Message' with the 'System' role.--Example:-->>> systemMessage "You are a helpful assistant."-Message {role = System, content = "You are a helpful assistant.", images = Nothing, tool_calls = Nothing, thinking = Nothing}--}-systemMessage :: Text -> Message-systemMessage = genMessage System--{- | Creates a 'Message' with the 'User' role.--Example:-->>> userMessage "What's 2+2?"-Message {role = User, content = "What's 2+2?", images = Nothing, tool_calls = Nothing, thinking = Nothing}--}-userMessage :: Text -> Message-userMessage = genMessage User--{- | Creates a 'Message' with the 'Assistant' role.--Example:-->>> assistantMessage "2+2 equals 4."-Message {role = Assistant, content = "2+2 equals 4.", images = Nothing, tool_calls = Nothing, thinking = Nothing}--}-assistantMessage :: Text -> Message-assistantMessage = genMessage Assistant--{- | Creates a 'Message' with the 'Tool' role.--Example:-->>> toolMessage "Tool output: success"-Message {role = Tool, content = "Tool output: success", images = Nothing, tool_calls = Nothing, thinking = Nothing}--}-toolMessage :: Text -> Message-toolMessage = genMessage Tool--{- | Validates 'ChatOps' to ensure required fields are non-empty.--Checks that the 'chatModelName' is not empty and that no 'Message' in 'messages' has empty content.-Returns 'Right' with the validated 'ChatOps' or 'Left' with an 'OllamaError' if validation fails.--@since 0.2.0.0--}-validateChatOps :: ChatOps -> Either OllamaError ChatOps-validateChatOps ops- | T.null (modelName ops) = Left $ InvalidRequest "Chat model name cannot be empty"- | any (T.null . content) (messages ops) =- Left $ InvalidRequest "Messages cannot have empty content"- | otherwise = Right ops--{- | Configuration for initiating a chat with an Ollama model.--Defines the parameters for a chat request, including the model name, messages, and optional settings-for tools, response format, streaming, timeout, and model options.--}-data ChatOps = ChatOps- { modelName :: !Text- -- ^ The name of the chat model to be used (e.g., "gemma3").- , messages :: !(NonEmpty Message)- -- ^ A non-empty list of messages forming the conversation context.- , tools :: !(Maybe [InputTool])- -- ^ Optional tools that may be used in the chat.- , format :: !(Maybe Format)- -- ^ Optional format for the chat response (e.g., JSON or JSON schema).- --- -- @since 0.1.3.0- , stream :: !(Maybe (ChatResponse -> IO (), IO ()))- -- ^ Optional callback function to be called with each incoming response.- , keepAlive :: !(Maybe Int)- -- ^ Optional override for the response timeout in minutes (default: 15 minutes).- , options :: !(Maybe ModelOptions)- -- ^ Optional model parameters (e.g., temperature) as specified in the Modelfile.- --- -- @since 0.1.3.0- , think :: !(Maybe Bool)- -- ^ Optional flag to enable thinking mode.- --- -- @since 0.2.0.0- }--instance Show ChatOps where- show- ( ChatOps- { modelName = m- , messages = ms- , tools = t- , format = f- , keepAlive = ka- , think = th- }- ) =- let messagesStr = show (toList ms)- toolsStr = show t- formatStr = show f- keepAliveStr = show ka- thinkStr = show th- in T.unpack m- ++ "\nMessages:\n"- ++ messagesStr- ++ "\n"- ++ toolsStr- ++ "\n"- ++ formatStr- ++ "\n"- ++ keepAliveStr- ++ "\n"- ++ thinkStr--instance Eq ChatOps where- (==) a b =- modelName a == modelName b- && messages a == messages b- && tools a == tools b- && format a == format b- && keepAlive a == keepAlive b--instance ToJSON ChatOps where- toJSON (ChatOps model_ messages_ tools_ format_ stream_ keepAlive_ options_ think_) =- object- [ "model" .= model_- , "messages" .= messages_- , "tools" .= tools_- , "format" .= format_- , "stream" .= if isNothing stream_ then Just False else Just True- , "keep_alive" .= keepAlive_- , "options" .= options_- , "think" .= think_- ]--{- | Default configuration for initiating a chat.--Provides a default 'ChatOps' with the "gemma3" model and a sample user message ("What is 2+2?").-Can be customized by modifying fields as needed.--Example:-->>> let ops = defaultChatOps { chatModelName = "customModel", messages = userMessage "Hello!" :| [] }->>> chat ops Nothing-Either OllamaError ChatResponse--}-defaultChatOps :: ChatOps-defaultChatOps =- ChatOps- { modelName = "gemma3"- , messages = userMessage "What is 2+2?" :| []- , tools = Nothing- , format = Nothing- , stream = Nothing- , keepAlive = Nothing- , options = Nothing- , think = Nothing- }--{- | Sends a chat request to the Ollama API.--Validates the 'ChatOps' configuration and sends a POST request to the @\/api\/chat@ endpoint.-Supports both streaming and non-streaming responses based on the 'stream' field in 'ChatOps'.-Returns an 'Either' containing an 'OllamaError' on failure or a 'ChatResponse' on success.--Example:-->>> let ops = defaultChatOps { chatModelName = "gemma3", messages = userMessage "What's the capital of France?" :| [] }->>> chat ops Nothing-Either OllamaError ChatResponse--}-chat :: ChatOps -> Maybe OllamaConfig -> IO (Either OllamaError ChatResponse)-chat ops mbConfig =- case validateChatOps ops of- Left err -> return $ Left err- Right _ -> withOllamaRequest "/api/chat" "POST" (Just ops) mbConfig handler- where- handler = maybe commonNonStreamingHandler commonStreamHandler (stream ops)--{- | MonadIO version of 'chat' for use in monadic contexts.--Lifts the 'chat' function into a 'MonadIO' context, allowing it to be used in monadic computations.--Example:-->>> import Control.Monad.IO.Class->>> let ops = defaultChatOps { chatModelName = "gemma3", messages = userMessage "Hello!" :| [] }->>> runReaderT (chatM ops Nothing) someContext-Either OllamaError ChatResponse--}-chatM :: MonadIO m => ChatOps -> Maybe OllamaConfig -> m (Either OllamaError ChatResponse)-chatM ops mbCfg = liftIO $ chat ops mbCfg
@@ -1,90 +0,0 @@-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE OverloadedStrings #-}--{- |-Module : Data.Ollama.Common.Config-Copyright : (c) 2025 Tushar Adhatrao-License : MIT-Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>-Stability : experimental-Description : A unified configuration type for controlling Ollama client behavior.--== Overview--This module defines the core configuration record used throughout the Ollama Haskell client.--Use 'defaultOllamaConfig' as a starting point and customize it with helper functions-like 'withOnModelStart', 'withOnModelFinish', or 'withOnModelError'.--Includes settings for base URL, timeout, retry logic, and custom HTTP managers.--}-module Data.Ollama.Common.Config- ( -- * Configuration Type- OllamaConfig (..)-- -- * Default Config- , defaultOllamaConfig-- -- * Hook Helpers- , withOnModelStart- , withOnModelFinish- , withOnModelError- ) where--import Data.Text (Text)-import GHC.Generics-import Network.HTTP.Client--{- | Configuration for the Ollama client.-Used across all requests to customize behavior such as timeouts, retries,-custom HTTP manager, and lifecycle hooks.--@since 0.2.0.0--}-data OllamaConfig = OllamaConfig- { hostUrl :: Text- -- ^ Base URL for the Ollama server (default: @http://127.0.0.1:11434@)- , timeout :: Int- -- ^ Timeout in seconds for API requests (ignored if 'commonManager' is set)- , onModelStart :: Maybe (IO ())- -- ^ Callback executed when a model starts- , onModelError :: Maybe (IO ())- -- ^ Callback executed if a model encounters an error- , onModelFinish :: Maybe (IO ())- -- ^ Callback executed when a model finishes (not called on error)- , retryCount :: Maybe Int- -- ^ Number of retries on failure (default: @0@ if 'Nothing')- , retryDelay :: Maybe Int- -- ^ Delay between retries in seconds (if applicable)- , commonManager :: Maybe Manager- -- ^ Shared HTTP manager; disables timeout and retry settings- }- deriving (Generic)--{- | A default configuration pointing to @localhost:11434@ with 90s timeout-and no hooks or retry logic.--}-defaultOllamaConfig :: OllamaConfig-defaultOllamaConfig =- OllamaConfig- { hostUrl = "http://127.0.0.1:11434"- , timeout = 90- , onModelStart = Nothing- , onModelError = Nothing- , onModelFinish = Nothing- , retryCount = Nothing- , retryDelay = Nothing- , commonManager = Nothing- }---- | Add a callback to be executed when a model starts.-withOnModelStart :: IO () -> OllamaConfig -> OllamaConfig-withOnModelStart f cfg = cfg {onModelStart = Just f}---- | Add a callback to be executed when a model errors.-withOnModelError :: IO () -> OllamaConfig -> OllamaConfig-withOnModelError f cfg = cfg {onModelError = Just f}---- | Add a callback to be executed when a model finishes successfully.-withOnModelFinish :: IO () -> OllamaConfig -> OllamaConfig-withOnModelFinish f cfg = cfg {onModelFinish = Just f}
@@ -1,65 +0,0 @@-{-# LANGUAGE DeriveGeneric #-}--{- |-Module : Data.Ollama.Common.Error-Copyright : (c) 2025 Tushar Adhatrao-License : MIT-Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>-Stability : experimental-Description : Unified error type for handling failures across the Ollama client.--== Overview--Defines the core 'OllamaError' type that wraps all potential errors-encountered while interacting with the Ollama API, including HTTP errors,-JSON decoding failures, API-specific errors, file I/O errors, and timeouts.--}-module Data.Ollama.Common.Error- ( -- * Error Types- OllamaError (..)-- -- * Decoding Utilities- , DecodingErrorMessage- , DecodingFailedValue- ) where--import Control.Exception (Exception, IOException)-import Data.Text (Text)-import GHC.Generics-import Network.HTTP.Client (HttpException)---- | Type alias for a decoding error message string.-type DecodingErrorMessage = String---- | Type alias for the value that failed to decode.-type DecodingFailedValue = String--{- | Represents all possible errors that may occur when using the Ollama client.--@since 0.2.0.0--}-data OllamaError- = -- | Low-level HTTP exception (connection failure, etc.)- HttpError HttpException- | -- | Failure to decode a JSON response, includes message and raw value- DecodeError DecodingErrorMessage DecodingFailedValue- | -- | Error returned from Ollama's HTTP API- ApiError Text- | -- | Error during file operations (e.g., loading an image)- FileError IOException- | -- | Mismatch in expected JSON schema or structure- JsonSchemaError String- | -- | Request timed out- TimeoutError String- | -- | Request is malformed or violates input constraints- InvalidRequest String- deriving (Show, Generic)--instance Eq OllamaError where- (HttpError _) == (HttpError _) = True- x == y = eqOllamaError x y- where- eqOllamaError :: OllamaError -> OllamaError -> Bool- eqOllamaError = (==)--instance Exception OllamaError
@@ -1,191 +0,0 @@-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE OverloadedStrings #-}--{- |-Module : Data.Ollama.Common.SchemaBuilder-Copyright : (c) 2025 Tushar Adhatrao-License : MIT-Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>-Stability : experimental-Description : DSL for constructing structured JSON Schemas for Ollama's structured output API.--== Overview--This module defines a simple schema builder DSL for programmatically constructing-JSON Schemas compatible with the structured output features in the Ollama API.--It supports nested objects, arrays, required fields, and custom types, and-provides infix operators for a fluent and expressive syntax.--== Example--@-import Data.Ollama.Common.SchemaBuilder--let schema =- emptyObject- |+ ("name", JString)- |+ ("age", JInteger)- |++ ("address", buildSchema $- emptyObject- |+ ("city", JString)- |+ ("zip", JInteger)- |! "city"- )- |!! ["name", "age"]- & buildSchema--printSchema schema-@--}-module Data.Ollama.Common.SchemaBuilder- ( -- * Core Types- JsonType (..)- , Property (..)- , Schema (..)-- -- * Schema Construction- , emptyObject- , addProperty- , addObjectProperty- , requireField- , requireFields- , buildSchema-- -- * Schema Utilities- , objectOf- , arrayOf- , toOllamaFormat- , printSchema-- -- * Infix Schema DSL- , (|+)- , (|++)- , (|!)- , (|!!)- ) 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 T-import GHC.Generics---- | Supported JSON types for schema generation.-data JsonType- = JString- | JNumber- | JInteger- | JBoolean- | JNull- | -- | Array of a specific type- JArray JsonType- | -- | Nested object schema- JObject Schema- deriving (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"---- | A named property with a given type (supports nested values).-newtype Property = Property JsonType- deriving (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]--{- | Complete schema representation.--@since 0.2.0.0--}-data Schema = Schema- { schemaProperties :: HM.Map Text Property- , schemaRequired :: [Text]- }- deriving (Show, Eq, Generic)--instance ToJSON Schema where- toJSON (Schema props req) =- object- [ "type" .= ("object" :: Text)- , "properties" .= props- , "required" .= req- ]---- | Internal builder for schema DSL.-newtype SchemaBuilder = SchemaBuilder Schema- deriving (Show, Eq)---- | Create an empty schema object.-emptyObject :: SchemaBuilder-emptyObject = SchemaBuilder $ Schema HM.empty []---- | Add a simple field with a given name and type.-addProperty :: Text -> JsonType -> SchemaBuilder -> SchemaBuilder-addProperty name typ (SchemaBuilder s) =- SchemaBuilder $ s {schemaProperties = HM.insert name (Property typ) (schemaProperties s)}---- | Add a nested object field with its own schema.-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.-requireField :: Text -> SchemaBuilder -> SchemaBuilder-requireField name (SchemaBuilder s) =- SchemaBuilder $ s {schemaRequired = name : schemaRequired s}---- | Mark multiple fields as required.-requireFields :: [Text] -> SchemaBuilder -> SchemaBuilder-requireFields names builder = foldr requireField builder names---- | Finalize the schema from a builder.-buildSchema :: SchemaBuilder -> Schema-buildSchema (SchemaBuilder s) = s---- | Wrap a 'SchemaBuilder' as a nested object type.-objectOf :: SchemaBuilder -> JsonType-objectOf builder = JObject (buildSchema builder)---- | Create an array of a given JSON type.-arrayOf :: JsonType -> JsonType-arrayOf = JArray---- | Convert schema into a JSON 'Value' suitable for API submission.-toOllamaFormat :: Schema -> Value-toOllamaFormat = toJSON---- | Pretty print a schema as formatted JSON.-printSchema :: Schema -> IO ()-printSchema = putStrLn . T.unpack . TL.toStrict . T.decodeUtf8 . encode---- | Infix alias for 'addProperty'.-(|+) :: SchemaBuilder -> (Text, JsonType) -> SchemaBuilder-builder |+ (name, typ) = addProperty name typ builder---- | Infix alias for 'addObjectProperty'.-(|++) :: SchemaBuilder -> (Text, Schema) -> SchemaBuilder-builder |++ (name, schema) = addObjectProperty name schema builder---- | Infix alias for 'requireField'.-(|!) :: SchemaBuilder -> Text -> SchemaBuilder-builder |! name = requireField name builder---- | Infix alias for 'requireFields'.-(|!!) :: SchemaBuilder -> [Text] -> SchemaBuilder-builder |!! names = requireFields names builder--infixl 7 |+, |++-infixl 6 |!, |!!
@@ -1,464 +0,0 @@-{-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RecordWildCards #-}--{- |-Module : Data.Ollama.Common.Types-Copyright : (c) 2025 Tushar Adhatrao-License : MIT-Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>-Stability : experimental-Description : Shared data types for request and response structures used throughout the Ollama client.--== 📋 Overview--This module defines common types for working with Ollama's API, including:--- Chat messages and roles-- Text generation responses-- Structured function/tool calling-- Model metadata-- Streaming handling-- Custom model parameters--These types are consumed and returned by higher-level modules-like `Data.Ollama.Chat`, `Data.Ollama.Generate`, and others.--== Includes--- Chat message structure and roles-- Generate and chat response records-- ModelOptions and advanced config-- Structured function/tool call interfaces-- JSON format hints and schema wrapping-- Helper class 'HasDone' for streaming termination--Most types implement `ToJSON`/`FromJSON` for direct API interaction.--}-module Data.Ollama.Common.Types- ( ModelDetails (..)- , Format (..)- , GenerateResponse (..)- , Message (..)- , Role (..)- , ChatResponse (..)- , HasDone (..)- , ModelOptions (..)- , InputTool (..)- , FunctionDef (..)- , FunctionParameters (..)- , ToolCall (..)- , OutputFunction (..)- , Version (..)- ) where--import Data.Aeson-import Data.Map qualified as HM-import Data.Maybe (catMaybes)-import Data.Ollama.Common.SchemaBuilder-import Data.Text (Text)-import Data.Time (UTCTime)-import GHC.Generics-import GHC.Int (Int64)---- | Metadata describing a specific model's identity and configuration.-data ModelDetails = ModelDetails- { parentModel :: !(Maybe Text)- -- ^ The parent model from which this model was derived, if any.- , format :: !Text- -- ^ The format used for the model (e.g., "gguf").- , family :: !Text- -- ^ The family name of the model (e.g., "llama", "mistral").- , families :: ![Text]- -- ^ Alternative or related family identifiers.- , parameterSize :: !Text- -- ^ The size of the model's parameters, typically expressed as a string (e.g., "7B").- , quantizationLevel :: Text- -- ^ The quantization level used (e.g., "Q4", "Q8").- }- 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"--{- | Format specification for the chat output.--@since 0.1.3.0--}-data Format = JsonFormat | SchemaFormat Schema- deriving (Show, Eq)--instance ToJSON Format where- toJSON JsonFormat = String "json"- toJSON (SchemaFormat schema) = toJSON schema--{- |-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.- , genResponse :: !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.- , thinking :: !(Maybe Text)- -- ^ Thinking of reasoning models; if think is set to true- --- -- @since 0.2.0.0- , doneReason :: !(Maybe Text)- -- ^ Reason why the generation process completed (e.g., "stop", "length", "cancel").- -- Available when the Ollama server provides completion reason information.- --- -- @since 0.2.1.0- }- deriving (Show, Eq)--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"- <*> v .:? "thinking"- <*> v .:? "done_reason"---- | 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 = withText "Role" $ \t ->- case t of- "system" -> pure System- "user" -> pure User- "assistant" -> pure Assistant- "tool" -> pure Tool- _ -> fail $ "Invalid Role value: " <> show t---- | 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 [ToolCall])- -- ^ a list of tools in JSON that the model wants to use- --- -- @since 0.1.3.0- , thinking :: !(Maybe Text)- --- -- @since 0.2.0.0- }- deriving (Show, Eq, Generic, ToJSON, FromJSON)--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.- , doneReason :: !(Maybe Text)- -- ^ Reason why the chat process completed (e.g., "stop", "length", "cancel").- -- Available when the Ollama server provides completion reason information.- --- -- @since 0.2.1.0- }- deriving (Show, Eq)--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"- <*> v .:? "done_reason"---- | A workaround to use done field within commonStreamHandler-class HasDone a where- getDone :: a -> Bool--instance HasDone GenerateResponse where- getDone GenerateResponse {..} = done--instance HasDone ChatResponse where- getDone ChatResponse {..} = done--{- | Optional model tuning parameters that influence generation behavior.--@since 0.2.0.0--}-data ModelOptions = ModelOptions- { numKeep :: Maybe Int- -- ^ Number of tokens to keep from the previous context.- , seed :: Maybe Int- -- ^ Random seed for reproducibility.- , numPredict :: Maybe Int- -- ^ Maximum number of tokens to predict.- , topK :: Maybe Int- -- ^ Top-K sampling parameter.- , topP :: Maybe Double- -- ^ Top-P (nucleus) sampling parameter.- , minP :: Maybe Double- -- ^ Minimum probability for nucleus sampling.- , typicalP :: Maybe Double- -- ^ Typical sampling probability.- , repeatLastN :: Maybe Int- -- ^ Number of tokens to consider for repetition penalty.- , temperature :: Maybe Double- -- ^ Sampling temperature. Higher = more randomness.- , repeatPenalty :: Maybe Double- -- ^ Penalty for repeating the same tokens.- , presencePenalty :: Maybe Double- -- ^ Penalty for introducing new tokens.- , frequencyPenalty :: Maybe Double- -- ^ Penalty for frequent tokens.- , penalizeNewline :: Maybe Bool- -- ^ Whether to penalize newline tokens.- , stop :: Maybe [Text]- -- ^ List of stop sequences to end generation.- , numa :: Maybe Bool- -- ^ Whether to enable NUMA-aware optimizations.- , numCtx :: Maybe Int- -- ^ Number of context tokens.- , numBatch :: Maybe Int- -- ^ Batch size used during generation.- , numGpu :: Maybe Int- -- ^ Number of GPUs to use.- , mainGpu :: Maybe Int- -- ^ Index of the primary GPU to use.- , useMmap :: Maybe Bool- -- ^ Whether to memory-map the model.- , numThread :: Maybe Int- -- ^ Number of threads to use for inference.- }- deriving (Show, Eq)---- | Custom ToJSON instance for Options-instance ToJSON ModelOptions where- toJSON opts =- object $- catMaybes- [ ("num_keep" .=) <$> numKeep opts- , ("seed" .=) <$> seed opts- , ("num_predict" .=) <$> numPredict opts- , ("top_k" .=) <$> topK opts- , ("top_p" .=) <$> topP opts- , ("min_p" .=) <$> minP opts- , ("typical_p" .=) <$> typicalP opts- , ("repeat_last_n" .=) <$> repeatLastN opts- , ("temperature" .=) <$> temperature opts- , ("repeat_penalty" .=) <$> repeatPenalty opts- , ("presence_penalty" .=) <$> presencePenalty opts- , ("frequency_penalty" .=) <$> frequencyPenalty opts- , ("penalize_newline" .=) <$> penalizeNewline opts- , ("stop" .=) <$> stop opts- , ("numa" .=) <$> numa opts- , ("num_ctx" .=) <$> numCtx opts- , ("num_batch" .=) <$> numBatch opts- , ("num_gpu" .=) <$> numGpu opts- , ("main_gpu" .=) <$> mainGpu opts- , ("use_mmap" .=) <$> useMmap opts- , ("num_thread" .=) <$> numThread opts- ]---- | A wrapper for the Ollama engine version string.-newtype Version = Version Text- deriving (Eq, Show)--instance FromJSON Version where- parseJSON = withObject "version" $ \v -> do- Version <$> v .: "version"--{- | Represents a tool that can be used in the conversation.--@since 0.2.0.0--}-data InputTool = InputTool- { toolType :: Text- -- ^ The type of the tool- , function :: FunctionDef- -- ^ The function associated with the tool- }- deriving (Show, Eq, Generic)--instance ToJSON InputTool where- toJSON InputTool {..} =- object- [ "type" .= toolType- , "function" .= function- ]--instance FromJSON InputTool where- parseJSON = withObject "Tool" $ \v ->- InputTool- <$> v .: "type"- <*> v .: "function"--{- | Represents a function that can be called by the model.--@since 0.2.0.0--}-data FunctionDef = FunctionDef- { functionName :: Text- -- ^ The name of the function- , functionDescription :: Maybe Text- -- ^ Optional description of the function- , functionParameters :: Maybe FunctionParameters- -- ^ Optional parameters for the function- , functionStrict :: Maybe Bool- -- ^ Optional strictness flag- }- deriving (Show, Eq, Generic)--instance ToJSON FunctionDef where- toJSON FunctionDef {..} =- object $- [ "name" .= functionName- ]- ++ maybe [] (\d -> ["description" .= d]) functionDescription- ++ maybe [] (\p -> ["parameters" .= p]) functionParameters- ++ maybe [] (\s -> ["strict" .= s]) functionStrict--instance FromJSON FunctionDef where- parseJSON = withObject "Function" $ \v ->- FunctionDef- <$> v .: "name"- <*> v .:? "description"- <*> v .:? "parameters"- <*> v .:? "strict"--{- | Parameters definition for a function call used in structured output or tool calls.--@since 0.2.0.0--}-data FunctionParameters = FunctionParameters- { parameterType :: Text- -- ^ Type of the parameter (usually "object").- , parameterProperties :: Maybe (HM.Map Text FunctionParameters)- -- ^ Optional nested parameters as a property map.- , requiredParams :: Maybe [Text]- -- ^ List of required parameter names.- , additionalProperties :: Maybe Bool- -- ^ Whether additional (unspecified) parameters are allowed.- }- deriving (Show, Eq)--instance ToJSON FunctionParameters where- toJSON FunctionParameters {..} =- object- [ "type" .= parameterType- , "properties" .= parameterProperties- , "required" .= requiredParams- , "additionalProperties" .= additionalProperties- ]--instance FromJSON FunctionParameters where- parseJSON = withObject "parameters" $ \v ->- FunctionParameters- <$> v .: "type"- <*> v .: "properties"- <*> v .: "required"- <*> v .: "additionalProperties"--{- | A single tool call returned from the model, containing the function to be invoked.--@since 0.2.0.0--}-newtype ToolCall = ToolCall- { outputFunction :: OutputFunction- -- ^ The function the model intends to call, with arguments.- }- deriving (Show, Eq)--{- | Output representation of a function to be called, including its name and arguments.--@since 0.2.0.0--}-data OutputFunction = OutputFunction- { outputFunctionName :: Text- -- ^ The name of the function to invoke.- , arguments :: HM.Map Text Value- -- ^ A key-value map of argument names to values (JSON values).- }- deriving (Eq, Show)--instance ToJSON OutputFunction where- toJSON OutputFunction {..} =- object- [ "name" .= outputFunctionName- , "arguments" .= arguments- ]--instance FromJSON OutputFunction where- parseJSON = withObject "function" $ \v ->- OutputFunction- <$> v .: "name"- <*> v .: "arguments"--instance ToJSON ToolCall where- toJSON ToolCall {..} = object ["function" .= outputFunction]--instance FromJSON ToolCall where- parseJSON = withObject "tool_calls" $ \v ->- ToolCall <$> v .: "function"
@@ -1,303 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RankNTypes #-}-{-# LANGUAGE RecordWildCards #-}--{- |-Module : Data.Ollama.Common.Utils-Copyright : (c) 2025 Tushar Adhatrao-License : MIT-Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>-Stability : experimental-Description : Utility functions for interacting with the Ollama API, including image encoding, HTTP request handling, and retry logic.--This module provides helper functions for common tasks in the Ollama client, such as encoding images to Base64,-sending HTTP requests to the Ollama API, handling streaming and non-streaming responses, and managing retries for failed requests.-It also includes a default model options configuration and a function to retrieve the Ollama server version.--The functions in this module are used internally by other modules like 'Data.Ollama.Chat' and 'Data.Ollama.Generate' but can also be used directly for custom API interactions.--}-module Data.Ollama.Common.Utils- ( -- * Image Encoding- encodeImage-- -- * HTTP Request Handling- , withOllamaRequest- , commonNonStreamingHandler- , commonStreamHandler- , nonJsonHandler-- -- * Model Options- , defaultModelOptions-- -- * Retry Logic- , withRetry-- -- * Version Retrieval- , getVersion- ) where--import Control.Concurrent (threadDelay)-import Control.Exception (IOException, try)-import Data.Aeson-import Data.ByteString qualified as BS-import Data.ByteString.Base64 qualified as Base64-import Data.ByteString.Lazy qualified as BSL-import Data.Char (toLower)-import Data.Maybe (fromMaybe)-import Data.Ollama.Common.Config-import Data.Ollama.Common.Error-import Data.Ollama.Common.Error qualified as Error-import Data.Ollama.Common.Types-import Data.Text (Text)-import Data.Text qualified as T-import Data.Text.Encoding qualified as TE-import Network.HTTP.Client-import Network.HTTP.Client.TLS-import Network.HTTP.Types (Status (statusCode))-import System.Directory-import System.FilePath---- | List of supported image file extensions for 'encodeImage'.-supportedExtensions :: [String]-supportedExtensions = [".jpg", ".jpeg", ".png"]---- | Safely read a file, returning an 'Either' with an 'IOException' on failure.-safeReadFile :: FilePath -> IO (Either IOException BS.ByteString)-safeReadFile = try . BS.readFile---- | Read a file if it exists, returning 'Nothing' if it does not.-asPath :: FilePath -> IO (Maybe BS.ByteString)-asPath filePath = do- exists <- doesFileExist filePath- if exists- then either (const Nothing) Just <$> safeReadFile filePath- else return Nothing---- | Check if a file has a supported image extension.-isSupportedExtension :: FilePath -> Bool-isSupportedExtension p = map toLower (takeExtension p) `elem` supportedExtensions--{- | Encodes an image file to Base64 format.--Takes a file path to an image (jpg, jpeg, or png) and returns its data encoded as a Base64 'Text'.-Returns 'Nothing' if the file extension is unsupported or the file cannot be read.-This is useful for including images in API requests that expect Base64-encoded data, such as 'GenerateOps' images field.--}-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--{- | Executes an action with retry logic for recoverable errors.--Retries the given action up to the specified number of times with a delay (in seconds) between attempts.-Only retries on recoverable errors such as HTTP errors, timeouts, JSON schema errors, or decoding errors.--}-withRetry ::- -- | Number of retries- Int ->- -- | Delay between retries in seconds- Int ->- -- | Action to execute, returning 'Either' 'OllamaError' or a result- IO (Either OllamaError a) ->- IO (Either OllamaError a)-withRetry 0 _ action = action-withRetry retries delaySeconds action = do- result <- action- case result of- Left err | isRetryableError err -> do- threadDelay (delaySeconds * 1000000) -- Convert to microseconds- withRetry (retries - 1) delaySeconds action- _ -> return result- where- isRetryableError (HttpError _) = True- isRetryableError (TimeoutError _) = True- isRetryableError (JsonSchemaError _) = True- isRetryableError (DecodeError _ _) = True- isRetryableError _ = False--{- | Sends an HTTP request to the Ollama API.--A unified function for making API requests to the Ollama server. Supports both GET and POST methods,-customizable payloads, and optional configuration. The response is processed by the provided handler.--}-withOllamaRequest ::- forall payload response.- (ToJSON payload) =>- -- | API endpoint- Text ->- -- | HTTP method ("GET" or "POST")- BS.ByteString ->- -- | Optional request payload (must implement 'ToJSON')- Maybe payload ->- -- | Optional 'OllamaConfig' (defaults to 'defaultOllamaConfig')- Maybe OllamaConfig ->- -- | Response handler to process the HTTP response- (Response BodyReader -> IO (Either OllamaError response)) ->- IO (Either OllamaError response)-withOllamaRequest endpoint reqMethod mbPayload mbOllamaConfig handler = do- let OllamaConfig {..} = fromMaybe defaultOllamaConfig mbOllamaConfig- fullUrl = T.unpack $ hostUrl <> endpoint- timeoutMicros = timeout * 1000000- manager <- case commonManager of- Nothing ->- newTlsManagerWith- tlsManagerSettings {managerResponseTimeout = responseTimeoutMicro timeoutMicros}- Just m -> pure m- eRequest <- try $ parseRequest fullUrl- case eRequest of- Left ex -> return $ Left $ Error.HttpError ex- Right req -> do- let request =- req- { method = reqMethod- , requestBody =- maybe mempty (RequestBodyLBS . encode) mbPayload- }- retryCnt = fromMaybe 0 retryCount- retryDelay_ = fromMaybe 1 retryDelay- withRetry retryCnt retryDelay_ $ do- fromMaybe (pure ()) onModelStart- eResponse <- try $ withResponse request manager handler- case eResponse of- Left ex -> do- fromMaybe (pure ()) onModelError- case ex of- (HttpExceptionRequest _ ResponseTimeout) ->- return $ Left $ Error.TimeoutError "No response from LLM yet"- _ -> return $ Left $ Error.HttpError ex- Right result -> do- fromMaybe (pure ()) onModelFinish- return result--{- | Handles non-streaming API responses.--Processes an HTTP response, accumulating all chunks until EOF and decoding the result as JSON.-Returns an 'Either' with an 'OllamaError' on failure or the decoded response on success.-Suitable for APIs that return a single JSON response.--}-commonNonStreamingHandler ::- FromJSON a =>- Response BodyReader ->- IO (Either OllamaError a)-commonNonStreamingHandler resp = do- let bodyReader = responseBody resp- respStatus = statusCode $ responseStatus resp- if respStatus >= 200 && respStatus < 300- then do- finalBs <- readFullBuff BS.empty bodyReader- case eitherDecode (BSL.fromStrict finalBs) of- Left err -> pure . Left $ Error.DecodeError err (show finalBs)- Right decoded -> pure . Right $ decoded- else Left . ApiError . TE.decodeUtf8 <$> brRead bodyReader--{- | Accumulates response chunks into a single ByteString.--Internal helper function to read all chunks from a 'BodyReader' until EOF.--}-readFullBuff :: BS.ByteString -> BodyReader -> IO BS.ByteString-readFullBuff acc reader = do- chunk <- brRead reader- if BS.null chunk- then pure acc- else readFullBuff (acc `BS.append` chunk) reader--{- | Handles streaming API responses.--Processes a streaming HTTP response, decoding each chunk as JSON and passing it to the provided-'sendChunk' function. The 'flush' function is called after each chunk. Stops when the response-indicates completion (via 'HasDone'). Returns the final decoded response or an error.--}-commonStreamHandler ::- (HasDone a, FromJSON a) =>- -- | Function to handle each decoded chunk- (a -> IO (), IO ()) ->- Response BodyReader ->- IO (Either OllamaError a)-commonStreamHandler (sendChunk, onComplete) resp = go mempty- where- go acc = do- bs <- brRead $ responseBody resp- if BS.null bs- then do- case eitherDecode (BSL.fromStrict acc) of- Left err -> pure $ Left $ Error.DecodeError err (show acc)- Right decoded -> pure $ Right decoded- else do- let chunk = BSL.fromStrict bs- case eitherDecode chunk of- Left err -> return $ Left $ Error.DecodeError err (show acc)- Right res -> do- sendChunk res- if getDone res then onComplete >> return (Right res) else go (acc <> bs)--{- | Handles non-JSON API responses.--Processes an HTTP response, accumulating all chunks into a 'ByteString'. Returns the accumulated-data on success (HTTP status 2xx) or an 'ApiError' on failure.--}-nonJsonHandler :: Response BodyReader -> IO (Either OllamaError BS.ByteString)-nonJsonHandler resp = do- let bodyReader = responseBody resp- respStatus = statusCode $ responseStatus resp- if respStatus >= 200 && respStatus < 300- then Right <$> readFullBuff BS.empty bodyReader- else Left . ApiError . TE.decodeUtf8 <$> brRead bodyReader--{- | Default model options for API requests.--Provides a default 'ModelOptions' configuration with all fields set to 'Nothing',-suitable as a starting point for customizing model parameters like temperature or token limits.--Example:-->>> let opts = defaultModelOptions { temperature = Just 0.7 }--}-defaultModelOptions :: ModelOptions-defaultModelOptions =- ModelOptions- { numKeep = Nothing- , seed = Nothing- , numPredict = Nothing- , topK = Nothing- , topP = Nothing- , minP = Nothing- , typicalP = Nothing- , repeatLastN = Nothing- , temperature = Nothing- , repeatPenalty = Nothing- , presencePenalty = Nothing- , frequencyPenalty = Nothing- , penalizeNewline = Nothing- , stop = Nothing- , numa = Nothing- , numCtx = Nothing- , numBatch = Nothing- , numGpu = Nothing- , mainGpu = Nothing- , useMmap = Nothing- , numThread = Nothing- }--{- | Retrieves the Ollama server version.--Sends a GET request to the @\/api\/version@ endpoint and returns the server version-as a 'Version' wrapped in an 'Either' 'OllamaError'.--Example:-->>> getVersion--@since 0.2.0.0--}-getVersion :: IO (Either OllamaError Version)-getVersion = do- withOllamaRequest- "/api/version"- "GET"- (Nothing :: Maybe Value)- Nothing- commonNonStreamingHandler
@@ -1,186 +0,0 @@-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE GeneralizedNewtypeDeriving #-}-{-# LANGUAGE OverloadedStrings #-}--{- |-Module : Data.Ollama.Conversation-Copyright : (c) 2025 Tushar Adhatrao-License : MIT-Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>-Stability : experimental-Description : Conversation management for the Ollama client, including storage and retrieval of chat sessions.--This module provides types and functions for managing conversations in the Ollama client. It defines-a 'Conversation' type to represent a chat session, a 'ConversationStore' typeclass for storage operations,-and an in-memory implementation using 'InMemoryStore' and 'ConvoM'. The module supports saving, loading,-listing, and deleting conversations, with thread-safe operations using STM (Software Transactional Memory).--The 'Conversation' type includes metadata such as a unique ID, messages, model name, and timestamps.-The 'ConversationStore' typeclass defines a generic interface for conversation storage, while 'InMemoryStore'-provides a concrete in-memory implementation. The 'ConvoM' monad integrates with 'InMemoryStore' for-monadic operations.--Example:-->>> store <- initInMemoryStore->>> let conv = Conversation "conv1" [userMessage "Hello!"] "gemma3" <$> getCurrentTime <*> getCurrentTime->>> runInMemoryConvo store $ saveConversation conv->>> runInMemoryConvo store $ loadConversation "conv1"-Just (Conversation ...)--}-module Data.Ollama.Conversation- ( -- * Conversation Types- Conversation (..)- , ConversationStore (..)-- -- * In-Memory Store- , InMemoryStore (..)- , ConvoM (..)- , initInMemoryStore- , runInMemoryConvo-- -- * Validation- , validateConversation- ) where--import Control.Concurrent.STM-import Control.Monad.Reader-import Data.Aeson (FromJSON, ToJSON)-import Data.Map.Strict (Map)-import Data.Map.Strict qualified as Map-import Data.Ollama.Common.Types-import Data.Text (Text)-import Data.Text qualified as T-import Data.Time (UTCTime, getCurrentTime)-import GHC.Generics (Generic)--{- | Represents a chat session with metadata and messages.--Stores a conversation's unique identifier, list of messages, model name, creation time, and last updated time.--}-data Conversation = Conversation- { conversationId :: !Text- -- ^ Unique identifier for the conversation.- , messages :: ![Message]- -- ^ List of messages in the conversation.- , model :: !Text- -- ^ Name of the model used in the conversation (e.g., "gemma3").- , createdAt :: !UTCTime- -- ^ Timestamp when the conversation was created.- , lastUpdated :: !UTCTime- -- ^ Timestamp when the conversation was last updated.- }- deriving (Show, Eq, Generic)--instance ToJSON Conversation-instance FromJSON Conversation--{- | Typeclass defining operations for storing and managing conversations.--Provides methods for saving, loading, listing, and deleting conversations in a monadic context.--@since 0.2.0.0--}-class Monad m => ConversationStore m where- -- | Saves a conversation to the store.- --- -- Validates the conversation and updates its 'lastUpdated' timestamp before saving.- saveConversation :: Conversation -> m ()-- -- | Loads a conversation by its ID.- --- -- Returns 'Just' the conversation if found, or 'Nothing' if not.- loadConversation :: Text -> m (Maybe Conversation)-- -- | Lists all conversations in the store.- listConversations :: m [Conversation]-- -- | Deletes a conversation by its ID.- --- -- Returns 'True' if the conversation was found and deleted, 'False' otherwise.- deleteConversation :: Text -> m Bool--{- | In-memory conversation store using a 'TVar' for thread-safe operations.--Stores conversations in a 'Map' keyed by conversation IDs, wrapped in a 'TVar' for concurrent access.--}-newtype InMemoryStore = InMemoryStore (TVar (Map Text Conversation))--{- | Monad for operations with 'InMemoryStore'.--A wrapper around 'ReaderT' that provides access to an 'InMemoryStore' in a monadic context.--}-newtype ConvoM a = ConvoM {runConvoM :: ReaderT InMemoryStore IO a}- deriving (Functor, Applicative, Monad, MonadIO, MonadReader InMemoryStore)--{- | Runs a 'ConvoM' action with the given 'InMemoryStore'.--Executes a monadic computation in the context of an in-memory store.--Example:-->>> store <- initInMemoryStore->>> runInMemoryConvo store $ saveConversation conv--}-runInMemoryConvo :: InMemoryStore -> ConvoM a -> IO a-runInMemoryConvo store = flip runReaderT store . runConvoM--instance ConversationStore ConvoM where- saveConversation conv = do- case validateConversation conv of- Left err -> liftIO $ putStrLn ("Validation error: " <> T.unpack err)- Right validConv -> do- now <- liftIO getCurrentTime- let updatedConv = validConv {lastUpdated = now}- InMemoryStore ref <- ask- liftIO . atomically $ modifyTVar' ref (Map.insert (conversationId updatedConv) updatedConv)-- loadConversation cid = do- InMemoryStore ref <- ask- convs <- liftIO $ readTVarIO ref- return $ Map.lookup cid convs-- listConversations = do- InMemoryStore ref <- ask- convs <- liftIO $ readTVarIO ref- return $ Map.elems convs-- deleteConversation cid = do- InMemoryStore ref <- ask- liftIO . atomically $ do- convs <- readTVar ref- if Map.member cid convs- then do- writeTVar ref (Map.delete cid convs)- return True- else return False--{- | Validates a 'Conversation' to ensure required fields are non-empty.--Checks that the 'conversationId' is not empty and that the 'messages' list contains at least one message.-Returns 'Right' with the validated conversation or 'Left' with an error message.--Example:-->>> let conv = Conversation "" [] "gemma3" time time->>> validateConversation conv-Left "Conversation ID cannot be empty"--}-validateConversation :: Conversation -> Either Text Conversation-validateConversation conv- | T.null (conversationId conv) = Left "Conversation ID cannot be empty"- | null (messages conv) = Left "Conversation must have at least one message"- | otherwise = Right conv--{- | Creates a new empty in-memory conversation store.--Initializes an 'InMemoryStore' with an empty 'Map' wrapped in a 'TVar' for thread-safe operations.--Example:-->>> store <- initInMemoryStore->>> runInMemoryConvo store $ listConversations-[]--}-initInMemoryStore :: IO InMemoryStore-initInMemoryStore = InMemoryStore <$> newTVarIO Map.empty
@@ -1,86 +0,0 @@-{-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-}--{- |-Module : Data.Ollama.Copy-Copyright : (c) 2025 Tushar Adhatrao-License : MIT-Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>-Stability : experimental-Description : Functionality for copying models in the Ollama client.--This module provides functions to copy a model from a source name to a destination name using the Ollama API.-It includes both an IO-based function ('copyModel') and a monadic version ('copyModelM') for use in-'MonadIO' contexts. The copy operation is performed via a POST request to the @\/api\/copy@ endpoint.--Example:-->>> copyModel "gemma3" "gemma3-copy" Nothing-Right ()--}-module Data.Ollama.Copy- ( -- * Copy Model API- copyModel- , copyModelM- ) where--import Control.Monad.IO.Class (MonadIO (liftIO))-import Data.Aeson-import Data.Ollama.Common.Config (OllamaConfig (..))-import Data.Ollama.Common.Error (OllamaError)-import Data.Ollama.Common.Utils (nonJsonHandler, withOllamaRequest)-import Data.Text (Text)-import GHC.Generics---- | Configuration for copying a model.-data CopyModelOps = CopyModelOps- { source :: !Text- -- ^ The name of the source model to copy.- , destination :: !Text- -- ^ The name of the destination model.- }- deriving (Show, Eq, Generic, ToJSON)--{- | Copies a model from a source name to a destination name.--Sends a POST request to the @\/api\/copy@ endpoint with the source and destination model names.-Returns 'Right ()' on success or 'Left' with an 'OllamaError' on failure.-Example:-->>> copyModel "gemma3" "gemma3-copy" Nothing-Right ()--}-copyModel ::- -- | Source model name- Text ->- -- | Destination model name- Text ->- -- | Optional 'OllamaConfig' (defaults to 'defaultOllamaConfig' if 'Nothing')- Maybe OllamaConfig ->- IO (Either OllamaError ())-copyModel- source_- destination_- mbConfig = do- let reqBody = CopyModelOps {source = source_, destination = destination_}- withOllamaRequest- "/api/copy"- "POST"- (Just reqBody)- mbConfig- (fmap (const () <$>) . nonJsonHandler)--{- | MonadIO version of 'copyModel' for use in monadic contexts.--Lifts the 'copyModel' function into a 'MonadIO' context, allowing it to be used in monadic computations.--Example:-->>> import Control.Monad.IO.Class->>> runReaderT (copyModelM "gemma3" "gemma3-copy" Nothing) someContext-Right ()--}-copyModelM :: MonadIO m => Text -> Text -> Maybe OllamaConfig -> m (Either OllamaError ())-copyModelM s d mbCfg = liftIO $ copyModel s d mbCfg
@@ -1,214 +0,0 @@-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RecordWildCards #-}--{- |-Module : Data.Ollama.Create-Copyright : (c) 2025 Tushar Adhatrao-License : MIT-Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>-Stability : experimental-Description : Functionality for creating new models in the Ollama client.--This module provides functions to create new models in the Ollama API. Models can be created from:--- Another existing model (using 'from' parameter)-- A safetensors directory (using 'files' parameter)-- A GGUF file (using 'files' parameter)--The create operation is performed via a POST request to the @\/api\/create@ endpoint, with streaming-support for progress updates. The module supports model quantization, custom templates, system prompts,-parameters, and LORA adapters.--Example creating from existing model:-->>> let ops = defaultCreateOps { modelName = "mario", fromModel = Just "llama3.2", systemPrompt = Just "You are Mario from Super Mario Bros." }->>> createModel ops Nothing-Creating model...-Success--Example quantizing a model:-->>> let ops = defaultCreateOps { modelName = "llama3.2:quantized", fromModel = Just "llama3.2:3b-instruct-fp16", quantizeType = Just Q4_K_M }->>> createModel ops Nothing-Quantizing model...-Success--}-module Data.Ollama.Create- ( -- * Create Model API- createModel- , createModelM-- -- * Configuration Types- , CreateOps (..)- , defaultCreateOps-- -- * Response Types- , CreateResp (..)-- -- * Quantization Types- , QuantizationType (..)- ) where--import Control.Monad (void)-import Control.Monad.IO.Class (MonadIO (liftIO))-import Data.Aeson-import Data.Map.Strict (Map)-import Data.Maybe (catMaybes)-import Data.Ollama.Common.Config (OllamaConfig)-import Data.Ollama.Common.Types (HasDone (getDone), Message, ModelOptions)-import Data.Ollama.Common.Utils as CU-import Data.Text (Text)-import GHC.Generics (Generic)-import GHC.Int (Int64)---- | Quantization types supported by Ollama.-data QuantizationType- = -- | Recommended quantization type- Q4_K_M- | -- | Alternative quantization type- Q4_K_S- | -- | Recommended quantization type- Q8_0- deriving (Show, Eq, 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" $ \t ->- case t of- "q4_K_M" -> pure Q4_K_M- "q4_K_S" -> pure Q4_K_S- "q8_0" -> pure Q8_0- _ -> fail $ "Invalid QuantizationType: " <> show t---- | Configuration for creating a new model.-data CreateOps = CreateOps- { modelName :: !Text- -- ^ The name of the model to create.- , fromModel :: !(Maybe Text)- -- ^ Optional name of an existing model to create the new model from.- , files :: !(Maybe (Map Text Text))- -- ^ Optional dictionary of file names to SHA256 digests of blobs to create the model from.- , adapters :: !(Maybe (Map Text Text))- -- ^ Optional dictionary of file names to SHA256 digests of blobs for LORA adapters.- , template :: !(Maybe Text)- -- ^ Optional prompt template for the model.- , license :: !(Maybe [Text])- -- ^ Optional list of strings containing the license(s) for the model.- , systemPrompt :: !(Maybe Text)- -- ^ Optional system prompt for the model.- , parameters :: !(Maybe ModelOptions)- -- ^ Optional parameters for the model.- , messages :: !(Maybe [Message])- -- ^ Optional list of message objects used to create a conversation.- , stream :: !(Maybe Bool)- -- ^ Optional flag to enable streaming progress updates.- , quantizeType :: !(Maybe QuantizationType)- -- ^ Optional quantization type for quantizing a non-quantized model.- }- deriving (Show, Eq, Generic)---- | Default configuration for creating a model.-defaultCreateOps :: Text -> CreateOps-defaultCreateOps name =- CreateOps- { modelName = name- , fromModel = Nothing- , files = Nothing- , adapters = Nothing- , template = Nothing- , license = Nothing- , systemPrompt = Nothing- , parameters = Nothing- , messages = Nothing- , stream = Nothing- , quantizeType = Nothing- }---- | Response type for model creation operations.-data CreateResp = CreateResp- { status :: !Text- -- ^ The status of the create operation (e.g., "success", "reading model metadata").- , digest :: !(Maybe Text)- -- ^ Optional digest (hash) of the model layer being processed.- , total :: !(Maybe Int64)- -- ^ Optional total size in bytes for quantization operations.- , completed :: !(Maybe Int64)- -- ^ Optional number of bytes completed for quantization operations.- }- deriving (Show, Eq, Generic)--instance HasDone CreateResp where- getDone CreateResp {..} = status == "success"--instance ToJSON CreateOps where- toJSON CreateOps {..} =- object $- catMaybes- [ Just ("model" .= modelName)- , ("from" .=) <$> fromModel- , ("files" .=) <$> files- , ("adapters" .=) <$> adapters- , ("template" .=) <$> template- , ("license" .=) <$> license- , ("system" .=) <$> systemPrompt- , ("parameters" .=) <$> parameters- , ("messages" .=) <$> messages- , ("stream" .=) <$> stream- , ("quantize" .=) <$> quantizeType- ]--instance FromJSON CreateResp where- parseJSON = withObject "CreateResp" $ \v ->- CreateResp- <$> v .: "status"- <*> v .:? "digest"- <*> v .:? "total"- <*> v .:? "completed"--{- | Creates a new model according to the provided configuration.--Sends a POST request to the @\/api\/create@ endpoint to create a model. The model can be created from:--- Another existing model (specify 'fromModel')-- A safetensors directory (specify 'files' with file name to SHA256 digest mappings)-- A GGUF file (specify 'files' with file name to SHA256 digest mapping)--Supports quantization, custom templates, system prompts, parameters, and LORA adapters.-Prints progress messages to the console during creation.--}-createModel ::- -- | Model creation configuration- CreateOps ->- -- | Optional 'OllamaConfig' (defaults to 'defaultOllamaConfig' if 'Nothing')- Maybe OllamaConfig ->- IO ()-createModel createOps mbConfig =- void $- withOllamaRequest- "/api/create"- "POST"- (Just createOps)- mbConfig- (commonStreamHandler (onToken, pure ()))- where- onToken :: CreateResp -> IO ()- onToken CreateResp {..} = case (total, completed) of- (Just t, Just c) -> putStrLn $ "Progress: " <> show c <> "/" <> show t <> " bytes"- _ -> putStrLn $ "Status: " <> show status--{- | MonadIO version of 'createModel' for use in monadic contexts.--Lifts the 'createModel' function into a 'MonadIO' context, allowing it to be used in monadic computations.--}-createModelM ::- MonadIO m =>- CreateOps ->- Maybe OllamaConfig ->- m ()-createModelM createOps mbCfg = liftIO $ createModel createOps mbCfg
@@ -1,71 +0,0 @@-{-# LANGUAGE DerivingStrategies #-}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE GeneralizedNewtypeDeriving #-}-{-# LANGUAGE OverloadedStrings #-}--{- |-Module : Data.Ollama.Delete-Copyright : (c) 2025 Tushar Adhatrao-License : MIT-Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>-Stability : experimental-Description : Functionality for deleting models in the Ollama client.--This module provides functions to delete a model from the Ollama server using its name. It includes-both an IO-based function ('deleteModel') and a monadic version ('deleteModelM') for use in-'MonadIO' contexts. The delete operation is performed via a DELETE request to the @\/api\/delete@ endpoint.--Example:-->>> deleteModel "gemma3" Nothing-Right ()--}-module Data.Ollama.Delete- ( -- * Delete Model API- deleteModel- , deleteModelM- ) where--import Control.Monad.IO.Class (MonadIO (liftIO))-import Data.Aeson-import Data.Ollama.Common.Config (OllamaConfig (..))-import Data.Ollama.Common.Error (OllamaError)-import Data.Ollama.Common.Utils (nonJsonHandler, withOllamaRequest)-import Data.Text (Text)---- | Request payload for deleting a model.-newtype DeleteModelReq- = -- | The name of the model to delete.- DeleteModelReq {name :: Text}- deriving newtype (Show, Eq)--instance ToJSON DeleteModelReq where- toJSON (DeleteModelReq name_) = object ["name" .= name_]--{- | Deletes a model from the Ollama server.--Sends a DELETE request to the "/api/delete" endpoint with the specified model name.-Returns 'Right ()' on success or 'Left' with an 'OllamaError' on failure.--}-deleteModel ::- -- | Model name to delete- Text ->- -- | Optional 'OllamaConfig' (defaults to 'defaultOllamaConfig' if 'Nothing')- Maybe OllamaConfig ->- IO (Either OllamaError ())-deleteModel modelName mbConfig = do- let reqBody = DeleteModelReq {name = modelName}- withOllamaRequest- "/api/delete"- "DELETE"- (Just reqBody)- mbConfig- (fmap (const () <$>) . nonJsonHandler)--{- | MonadIO version of 'deleteModel' for use in monadic contexts.--Lifts the 'deleteModel' function into a context,-allowing it to be used in monadic computations.--}-deleteModelM :: MonadIO m => Text -> Maybe OllamaConfig -> m (Either OllamaError ())-deleteModelM t mbCfg = liftIO $ deleteModel t mbCfg
@@ -1,189 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--{- |-Module : Data.Ollama.Embeddings-Copyright : (c) 2025 Tushar Adhatrao-License : MIT-Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>-Stability : experimental-Description : Functionality for generating text embeddings using the Ollama API.--This module provides functions to generate text embeddings from an Ollama model. It includes both-high-level ('embedding', 'embeddingM') and low-level ('embeddingOps', 'embeddingOpsM') APIs for-generating embeddings, with support for customizing model options, truncation, and keep-alive settings.-The embeddings are returned as a list of float vectors, suitable for tasks like semantic search or-text similarity analysis.--The 'EmbeddingOps' type configures the embedding request, and 'EmbeddingResp' represents the response-containing the model name and the generated embeddings. The 'defaultEmbeddingOps' provides a default-configuration for convenience.--Example:-->>> embedding "llama3.2" ["Hello, world!"]-Right (EmbeddingResp "llama3.2" [[0.1, 0.2, ...]])--}-module Data.Ollama.Embeddings- ( -- * Embedding API- embedding- , embeddingOps- , embeddingM- , embeddingOpsM-- -- * Configuration and Response Types- , defaultEmbeddingOps- , EmbeddingOps (..)- , EmbeddingResp (..)-- -- * Model Options- , ModelOptions (..)- , defaultModelOptions- ) where--import Control.Monad.IO.Class (MonadIO (liftIO))-import Data.Aeson-import Data.Ollama.Common.Config (OllamaConfig)-import Data.Ollama.Common.Error (OllamaError)-import Data.Ollama.Common.Types (ModelOptions (..))-import Data.Ollama.Common.Utils as CU-import Data.Text (Text)--{- | Default configuration for embedding requests.--Provides a default 'EmbeddingOps' with the "llama3.2" model, an empty input list, and no additional options.-Can be customized by modifying fields as needed.--}-defaultEmbeddingOps :: EmbeddingOps-defaultEmbeddingOps =- EmbeddingOps- { modelName = "nomic-embed-text"- , input = []- , truncateInput = Nothing- , keepAlive = Nothing- , modelOptions = Nothing- , dimensions = Nothing- }---- | Configuration for an embedding request.-data EmbeddingOps = EmbeddingOps- { modelName :: !Text- -- ^ The name of the model to use for generating embeddings (e.g., "llama3.2").- , input :: ![Text]- -- ^ List of input texts to generate embeddings for.- , truncateInput :: !(Maybe Bool)- -- ^ Optional flag to truncate input if it exceeds model limits.- , keepAlive :: !(Maybe Int)- -- ^ Optional override for the keep-alive timeout in minutes.- , modelOptions :: !(Maybe ModelOptions)- -- ^ Optional model parameters (e.g., temperature) as specified in the Modelfile.- --- -- @since 0.2.0.0- , dimensions :: !(Maybe Int)- -- ^ number of dimensions for the embedding- --- -- @since 0.2.1.0- }- deriving (Show, Eq)---- | Response type for an embedding request.-data EmbeddingResp = EmbeddingResp- { respondedModel :: !Text- -- ^ The name of the model that generated the embeddings.- , respondedEmbeddings :: ![[Float]]- -- ^ List of embedding vectors, one for each input text.- }- 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_ ops dimensions_) =- object- [ "model" .= model_- , "input" .= input_- , "truncate" .= truncate'- , "keep_alive" .= keepAlive_- , "options" .= ops- , "dimensions" .= dimensions_- ]--{- | Generates embeddings for a list of input texts with full configuration.--Sends a POST request to the @\/api\/embed@ endpoint to generate embeddings for the provided inputs.-Allows customization of truncation, keep-alive settings, model options, and Ollama configuration.-Returns 'Right' with an 'EmbeddingResp' on success or 'Left' with an 'OllamaError' on failure.--}-embeddingOps ::- -- | Model name- Text ->- -- | List of input texts- [Text] ->- -- | Optional truncation flag- Maybe Bool ->- -- | Optional keep-alive timeout in minutes- Maybe Int ->- -- | Optional model options- Maybe ModelOptions ->- -- | Optional 'OllamaConfig' (defaults to 'defaultOllamaConfig' if 'Nothing')- Maybe Int ->- Maybe OllamaConfig ->- IO (Either OllamaError EmbeddingResp)-embeddingOps modelName_ input_ mTruncate mKeepAlive mbOptions mbDimensions mbConfig = do- withOllamaRequest- "/api/embed"- "POST"- ( Just $- EmbeddingOps- { modelName = modelName_- , input = input_- , truncateInput = mTruncate- , keepAlive = mKeepAlive- , modelOptions = mbOptions- , dimensions = mbDimensions- }- )- mbConfig- commonNonStreamingHandler--{- | Simplified API for generating embeddings.--A higher-level function that generates embeddings using default settings for truncation, keep-alive,-model options, and Ollama configuration. Suitable for basic use cases.--}-embedding ::- -- | Model name- Text ->- -- | List of input texts- [Text] ->- IO (Either OllamaError EmbeddingResp)-embedding modelName_ input_ =- embeddingOps modelName_ input_ Nothing Nothing Nothing Nothing Nothing--{- | MonadIO version of 'embedding' for use in monadic contexts.--Lifts the 'embedding' function into a 'MonadIO' context, allowing it to be used in monadic computations.--}-embeddingM :: MonadIO m => Text -> [Text] -> m (Either OllamaError EmbeddingResp)-embeddingM m ip = liftIO $ embedding m ip--{- | MonadIO version of 'embeddingOps' for use in monadic contexts.--Lifts the 'embeddingOps' function into a 'MonadIO' context, allowing it to be used in monadic computations-with full configuration options.--}-embeddingOpsM ::- MonadIO m =>- Text ->- [Text] ->- Maybe Bool ->- Maybe Int ->- Maybe ModelOptions ->- Maybe Int ->- Maybe OllamaConfig ->- m (Either OllamaError EmbeddingResp)-embeddingOpsM m ip mbTruncate mbKeepAlive mbOptions mbDimensions mbCfg =- liftIO $ embeddingOps m ip mbTruncate mbKeepAlive mbOptions mbDimensions mbCfg
@@ -1,248 +0,0 @@-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RecordWildCards #-}--{- |-Module : Data.Ollama.Generate-Copyright : (c) 2025 Tushar Adhatrao-License : MIT-Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>-Stability : experimental-Description : Text generation functionality for the Ollama client.--This module provides functions and types for generating text using an Ollama model. It includes APIs-for sending generation requests, both in IO ('generate') and monadic ('generateM') contexts, with-support for streaming and non-streaming responses. The 'GenerateOps' type configures the generation-request, allowing customization of the model, prompt, images, format, and other parameters. The-'defaultGenerateOps' provides a convenient starting point for configuration.--The module supports advanced features like Base64-encoded images, custom templates, and model-specific-options (e.g., temperature). It also includes validation to ensure required fields are non-empty.--Example:-->>> let ops = defaultGenerateOps { modelName = "gemma3", prompt = "Write a poem." }->>> generate ops Nothing-Right (GenerateResponse ...)--}-module Data.Ollama.Generate- ( -- * Generate Texts- generate- , generateM-- -- * Configuration- , defaultGenerateOps- , GenerateOps (..)- , validateGenerateOps-- -- * Response and Configuration Types- , GenerateResponse (..)- , Format (..)- , OllamaConfig (..)- , defaultOllamaConfig- , ModelOptions (..)- , defaultModelOptions-- -- * Error Types- , OllamaError (..)- ) where--import Control.Monad.IO.Class (MonadIO (liftIO))-import Data.Aeson-import Data.Maybe-import Data.Ollama.Common.Config (OllamaConfig (..), defaultOllamaConfig)-import Data.Ollama.Common.Error (OllamaError (..))-import Data.Ollama.Common.Types (Format (..), GenerateResponse (..), ModelOptions (..))-import Data.Ollama.Common.Utils as CU-import Data.Text (Text)-import Data.Text qualified as T--{- | Validates 'GenerateOps' to ensure required fields are non-empty.--Checks that the 'modelName' and 'prompt' fields are not empty. Returns 'Right' with the validated-'GenerateOps' or 'Left' with an 'OllamaError' if validation fails.--Example:-->>> validateGenerateOps defaultGenerateOps-Left (InvalidRequest "Prompt cannot be empty")--@since 0.2.0.0--}-validateGenerateOps :: GenerateOps -> Either OllamaError GenerateOps-validateGenerateOps ops- | T.null (modelName ops) = Left $ InvalidRequest "Model name cannot be empty"- | T.null (prompt ops) = Left $ InvalidRequest "Prompt cannot be empty"- | otherwise = Right ops---- | Configuration for a text generation request.-data GenerateOps = GenerateOps- { modelName :: !Text- -- ^ The name of the model to use for generation (e.g., "gemma3").- , prompt :: !Text- -- ^ The prompt text to provide to the model for generating a response.- , suffix :: Maybe Text- -- ^ Optional suffix to append to the generated text (not supported by all models).- , images :: !(Maybe [Text])- -- ^ Optional list of Base64-encoded images to include with the request.- , format :: !(Maybe Format)- -- ^ Optional format specifier for the response (e.g., JSON).- --- -- @since 0.1.3.0- , system :: !(Maybe Text)- -- ^ Optional system text to include in the generation context.- , template :: !(Maybe Text)- -- ^ Optional template to format the response.- , stream :: !(Maybe (GenerateResponse -> IO (), IO ()))- -- ^ Optional callback function to be called with each incoming response.- , raw :: !(Maybe Bool)- -- ^ Optional flag to return the raw response.- , keepAlive :: !(Maybe Int)- -- ^ Optional override for how long (in minutes) the model stays loaded in memory (default: 5 minutes).- , options :: !(Maybe ModelOptions)- -- ^ Optional model parameters (e.g., temperature) as specified in the Modelfile.- --- -- @since 0.1.3.0- , think :: !(Maybe Bool)- -- ^ Optional flag to enable thinking mode.- --- -- @since 0.2.0.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- <> ", think: "- <> show think--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- && think a == think b--instance ToJSON GenerateOps where- toJSON- ( GenerateOps- model- prompt- suffix- images- format- system- template- stream- raw- keepAlive- options- think- ) =- 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- , "think" .= think- ]--{- | Default configuration for text generation.--Provides a default 'GenerateOps' with the "gemma3" model and an empty prompt. Other fields are set-to 'Nothing' or default values. Can be customized by modifying fields as needed.--Example:-->>> let ops = defaultGenerateOps { modelName = "customModel", prompt = "Hello!" }->>> generate ops Nothing--}-defaultGenerateOps :: GenerateOps-defaultGenerateOps =- GenerateOps- { modelName = "gemma3"- , prompt = ""- , suffix = Nothing- , images = Nothing- , format = Nothing- , system = Nothing- , template = Nothing- , stream = Nothing- , raw = Nothing- , keepAlive = Nothing- , options = Nothing- , think = Nothing- }--{- | Generates text using the specified model and configuration.--Validates the 'GenerateOps' configuration and sends a POST request to the @\/api\/generate@ endpoint.-Supports both streaming and non-streaming responses based on the 'stream' field in 'GenerateOps'.-Returns 'Right' with a 'GenerateResponse' on success or 'Left' with an 'OllamaError' on failure.--Example:-->>> let ops = defaultGenerateOps { modelName = "gemma3", prompt = "Write a short poem." }->>> generate ops Nothing-Right (GenerateResponse ...)--}-generate :: GenerateOps -> Maybe OllamaConfig -> IO (Either OllamaError GenerateResponse)-generate ops mbConfig =- case validateGenerateOps ops of- Left err -> pure $ Left err- Right _ -> withOllamaRequest "/api/generate" "POST" (Just ops) mbConfig handler- where- handler = maybe commonNonStreamingHandler commonStreamHandler (stream ops)--{- | MonadIO version of 'generate' for use in monadic contexts.--Lifts the 'generate' function into a 'MonadIO' context, allowing it to be used in monadic computations.--Example:-->>> import Control.Monad.IO.Class->>> let ops = defaultGenerateOps { modelName = "gemma3", prompt = "Hello!" }->>> runReaderT (generateM ops Nothing) someContext-Right (GenerateResponse ...)--}-generateM ::- MonadIO m =>- GenerateOps ->- Maybe OllamaConfig ->- m (Either OllamaError GenerateResponse)-generateM ops mbCfg = liftIO $ generate ops mbCfg
@@ -1,105 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--{- |-Module : Data.Ollama.List-Copyright : (c) 2025 Tushar Adhatrao-License : MIT-Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>-Stability : experimental-Description : Functionality for listing available models in the Ollama client.--This module provides functions to retrieve a list of models available on the Ollama server.-It includes both an IO-based function ('list') and a monadic version ('listM') for use in-'MonadIO' contexts. The list operation is performed via a GET request to the @\/api\/tags@ endpoint,-returning a 'Models' type containing a list of 'ModelInfo' records with details about each model.--Example:-->>> list Nothing-Right (Models [ModelInfo ...])--}-module Data.Ollama.List- ( -- * List Models API- list- , listM-- -- * Model Types- , Models (..)- , ModelInfo (..)- ) where--import Control.Monad.IO.Class (MonadIO (liftIO))-import Data.Aeson-import Data.Ollama.Common.Config (OllamaConfig)-import Data.Ollama.Common.Error (OllamaError)-import Data.Ollama.Common.Types as CT-import Data.Ollama.Common.Utils as CU-import Data.Text (Text)-import Data.Time-import GHC.Int (Int64)---- | A wrapper type containing a list of available models.-newtype Models- = -- | List of 'ModelInfo' records describing available models.- Models [ModelInfo]- deriving (Eq, Show)---- | Details about a specific model.-data ModelInfo = ModelInfo- { name :: !Text- -- ^ The name of the model.- , modifiedAt :: !UTCTime- -- ^ The timestamp when the model was last modified.- , size :: !Int64- -- ^ The size of the model in bytes.- , digest :: !Text- -- ^ The digest (hash) of the model.- , details :: !ModelDetails- -- ^ Additional details about the model (e.g., format, family, parameters).- }- deriving (Eq, Show)---- | JSON parsing instance for 'Models'.-instance FromJSON Models where- parseJSON = withObject "Models" $ \v -> Models <$> v .: "models"---- | JSON parsing instance for 'ModelInfo'.-instance FromJSON ModelInfo where- parseJSON = withObject "ModelInfo" $ \v ->- ModelInfo- <$> v .: "name"- <*> v .: "modified_at"- <*> v .: "size"- <*> v .: "digest"- <*> v .: "details"--{- | Retrieves a list of available models from the Ollama server.--Sends a GET request to the @\/api\/tags@ endpoint to fetch the list of models.-Returns 'Right' with a 'Models' containing the list of 'ModelInfo' on success,-or 'Left' with an 'OllamaError' on failure.--}-list ::- -- | Optional 'OllamaConfig' (defaults to 'defaultOllamaConfig' if 'Nothing')- Maybe OllamaConfig ->- IO (Either OllamaError Models)-list mbConfig = do- withOllamaRequest- "/api/tags"- "GET"- (Nothing :: Maybe Value)- mbConfig- commonNonStreamingHandler--{- | MonadIO version of 'list' for use in monadic contexts.--Lifts the 'list' function into a 'MonadIO' context, allowing it to be used in monadic computations.--Example:-->>> import Control.Monad.IO.Class->>> runReaderT (listM Nothing) someContext-Right (Models [ModelInfo ...])--}-listM :: MonadIO m => Maybe OllamaConfig -> m (Either OllamaError Models)-listM mbCfg = liftIO $ list mbCfg
@@ -1,103 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--{- |-Module : Data.Ollama.Load-Copyright : (c) 2025 Tushar Adhatrao-License : MIT-Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>-Stability : experimental-Description : High-level functions for loading and unloading models in the Ollama client.--This module provides functions to load and unload generative models in the Ollama server.-It includes both IO-based functions ('loadGenModel', 'unloadGenModel') and monadic versions-('loadGenModelM', 'unloadGenModelM') for use in 'MonadIO' contexts. The operations are-performed via POST requests to the @\/api\/generate@ endpoint, leveraging the 'GenerateOps'-configuration from the 'Data.Ollama.Generate' module.--Loading a model keeps it in memory for faster subsequent requests, while unloading frees-up memory by setting the keep-alive duration to zero.--Example:-->>> loadGenModel "gemma3"-Right ()->>> unloadGenModel "gemma3"-Right ()--}-module Data.Ollama.Load- ( -- * Load and Unload Model APIs- loadGenModel- , unloadGenModel- , loadGenModelM- , unloadGenModelM- ) where--import Control.Monad.IO.Class (MonadIO (liftIO))-import Data.Ollama.Common.Error-import Data.Ollama.Common.Utils (commonNonStreamingHandler, withOllamaRequest)-import Data.Ollama.Generate qualified as Gen-import Data.Text (Text)--{- | Loads a generative model into memory.--Sends a POST request to the @\/api\/generate@ endpoint to load the specified model into-memory, ensuring faster response times for subsequent requests. Returns 'Right ()' on-success or 'Left' with an 'OllamaError' on failure.--@since 0.2.0.0--}-loadGenModel ::- -- | Model name (e.g., "gemma3")- Text ->- IO (Either OllamaError ())-loadGenModel m = do- let ops = Gen.defaultGenerateOps {Gen.modelName = m}- withOllamaRequest "/api/generate" "POST" (Just ops) Nothing commonNonStreamingHandler--{- | Unloads a generative model from memory.--Sends a POST request to the @\/api\/generate@ endpoint with a keep-alive duration of zero-to unload the specified model from memory, freeing up resources. Returns 'Right ()' on-success or 'Left' with an 'OllamaError' on failure.--@since 0.2.0.0--}-unloadGenModel ::- -- | Model name (e.g., "gemma3")- Text ->- IO (Either OllamaError ())-unloadGenModel m = do- let ops = Gen.defaultGenerateOps {Gen.modelName = m, Gen.keepAlive = Just 0}- withOllamaRequest "/api/generate" "POST" (Just ops) Nothing commonNonStreamingHandler--{- | MonadIO version of 'loadGenModel' for use in monadic contexts.--Lifts the 'loadGenModel' function into a 'MonadIO' context, allowing it to be used in-monadic computations.--Example:-->>> import Control.Monad.IO.Class->>> runReaderT (loadGenModelM "gemma3") someContext-Right ()--@since 0.2.0.0--}-loadGenModelM :: MonadIO m => Text -> m (Either OllamaError ())-loadGenModelM t = liftIO $ loadGenModel t--{- | MonadIO version of 'unloadGenModel' for use in monadic contexts.--Lifts the 'unloadGenModel' function into a 'MonadIO' context, allowing it to be used in-monadic computations.--Example:-->>> import Control.Monad.IO.Class->>> runReaderT (unloadGenModelM "gemma3") someContext-Right ()--@since 0.2.0.0--}-unloadGenModelM :: MonadIO m => Text -> m (Either OllamaError ())-unloadGenModelM t = liftIO $ unloadGenModel t
@@ -1,109 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--{- |-Module : Data.Ollama.Ps-Copyright : (c) 2025 Tushar Adhatrao-License : MIT-Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>-Stability : experimental-Description : Functionality for listing running models in the Ollama client.--This module provides functions to retrieve a list of models currently running on the Ollama server.-It includes both an IO-based function ('ps') and a monadic version ('psM') for use in 'MonadIO'-contexts. The operation is performed via a GET request to the @\/api\/ps@ endpoint, returning a-'RunningModels' type containing a list of 'RunningModel' records with details about each running model.--Example:-->>> ps Nothing-Right (RunningModels [RunningModel ...])--}-module Data.Ollama.Ps- ( -- * List Running Models API- ps- , psM-- -- * Model Types- , RunningModels (..)- , RunningModel (..)- ) where--import Control.Monad.IO.Class (MonadIO (liftIO))-import Data.Aeson-import Data.Ollama.Common.Config (OllamaConfig)-import Data.Ollama.Common.Error (OllamaError)-import Data.Ollama.Common.Types as CT-import Data.Ollama.Common.Utils as CU-import Data.Text (Text)-import Data.Time-import GHC.Int (Int64)---- | A wrapper type containing a list of running models.-newtype RunningModels- = -- | List of 'RunningModel' records describing currently running models.- RunningModels [RunningModel]- deriving (Eq, Show)---- | Details about a specific running model.-data RunningModel = RunningModel- { name_ :: !Text- -- ^ The name of the running model instance.- , modelName :: !Text- -- ^ The base model name (e.g., "gemma3").- , size_ :: !Int64- -- ^ The size of the model in bytes.- , modelDigest :: !Text- -- ^ The digest (hash) of the model.- , modelDetails :: !ModelDetails- -- ^ Additional details about the model (e.g., format, family, parameters).- , expiresAt :: !UTCTime- -- ^ The timestamp when the model's memory allocation expires.- , sizeVRam :: !Int64- -- ^ The size of the model's VRAM usage in bytes.- }- deriving (Eq, Show)---- | JSON parsing instance for 'RunningModels'.-instance FromJSON RunningModels where- parseJSON = withObject "Models" $ \v -> RunningModels <$> v .: "models"---- | JSON parsing instance for 'RunningModel'.-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"--{- | Retrieves a list of currently running models from the Ollama server.--Sends a GET request to the @\/api\/ps@ endpoint to fetch the list of running models.-Returns 'Right' with a 'RunningModels' containing the list of 'RunningModel' on success,-or 'Left' with an 'OllamaError' on failure.-Example:-->>> ps Nothing-Right (RunningModels [RunningModel {name_ = "gemma3:instance1", modelName = "gemma3", ...}])--}-ps ::- -- | Optional 'OllamaConfig' (defaults to 'defaultOllamaConfig' if 'Nothing')- Maybe OllamaConfig ->- IO (Either OllamaError RunningModels)-ps mbConfig = do- withOllamaRequest- "/api/ps"- "GET"- (Nothing :: Maybe Value)- mbConfig- commonNonStreamingHandler--{- | MonadIO version of 'ps' for use in monadic contexts.--Lifts the 'ps' function into a 'MonadIO' context, allowing it to be used in monadic computations.--}-psM :: MonadIO m => Maybe OllamaConfig -> m (Either OllamaError RunningModels)-psM mbCfg = liftIO $ ps mbCfg
@@ -1,153 +0,0 @@-{-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RecordWildCards #-}--{- |-Module : Data.Ollama.Pull-Copyright : (c) 2025 Tushar Adhatrao-License : MIT-Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>-Stability : experimental-Description : Functionality for pulling models in the Ollama client.--This module provides functions to pull (download) models from the Ollama server. It includes both-high-level ('pull', 'pullM') and low-level ('pullOps', 'pullOpsM') APIs for pulling models, with-support for streaming progress updates and insecure connections. The 'PullOps' type configures the-pull request, and 'PullResp' represents the response containing the status and progress details.--The pull operation is performed via a POST request to the @\/api\/pull@ endpoint. Streaming mode,-when enabled, provides real-time progress updates by printing the remaining bytes to the console.--Example:-->>> pull "gemma3"-Remaining bytes: 123456789-...-Completed-Right (PullResp {status = "success", ...})--}-module Data.Ollama.Pull- ( -- * Pull Model API- pull- , pullOps- , pullM- , pullOpsM- ) where--import Control.Monad.IO.Class (MonadIO (liftIO))-import Data.Aeson-import Data.Maybe (fromMaybe)-import Data.Ollama.Common.Config (OllamaConfig)-import Data.Ollama.Common.Error (OllamaError)-import Data.Ollama.Common.Types (HasDone (..))-import Data.Ollama.Common.Utils as CU-import Data.Text (Text)-import GHC.Generics-import GHC.Int (Int64)---- | Configuration options for pulling a model.-data PullOps = PullOps- { name :: !Text- -- ^ The name of the model to pull (e.g., "gemma3").- , insecure :: !(Maybe Bool)- -- ^ Optional flag to allow insecure connections. If 'Just True', insecure connections are permitted.- , stream :: !(Maybe Bool)- -- ^ Optional flag to enable streaming of the download. If 'Just True', progress updates are 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 (hash) 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 downloaded, if available.- }- deriving (Show, Eq, Generic, FromJSON)--instance HasDone PullResp where- getDone PullResp {..} = status /= "success"--{- | Pulls a model with full configuration.--Sends a POST request to the @\/api\/pull@ endpoint to download the specified model. Supports-streaming progress updates (if 'stream' is 'Just True') and insecure connections (if 'insecure'-is 'Just True'). Prints remaining bytes during streaming and "Completed" when finished.-Returns 'Right' with a 'PullResp' on success or 'Left' with an 'OllamaError' on failure.--}-pullOps ::- -- | Model name- Text ->- -- | Optional insecure connection flag- Maybe Bool ->- -- | Optional streaming flag- Maybe Bool ->- -- | Optional 'OllamaConfig' (defaults to 'defaultOllamaConfig' if 'Nothing')- Maybe OllamaConfig ->- IO (Either OllamaError PullResp)-pullOps modelName mInsecure mStream mbConfig = do- withOllamaRequest- "/api/pull"- "POST"- (Just $ PullOps {name = modelName, insecure = mInsecure, stream = mStream})- mbConfig- (commonStreamHandler (onToken, pure ()))- where- onToken :: PullResp -> IO ()- onToken res = do- let completed' = fromMaybe 0 (completed res)- let total' = fromMaybe 0 (total res)- putStrLn $ "Remaining bytes: " <> show (total' - completed')--{- | Simplified API for pulling a model.--A higher-level function that pulls a model using default settings for insecure connections,-streaming, and Ollama configuration. Suitable for basic use cases.--}-pull ::- -- | Model name- Text ->- IO (Either OllamaError PullResp)-pull modelName = pullOps modelName Nothing Nothing Nothing--{- | MonadIO version of 'pull' for use in monadic contexts.--Lifts the 'pull' function into a 'MonadIO' context, allowing it to be used in monadic computations.--Example:-->>> import Control.Monad.IO.Class->>> runReaderT (pullM "gemma3") someContext-Right (PullResp {status = "success", ...})--}-pullM :: MonadIO m => Text -> m (Either OllamaError PullResp)-pullM t = liftIO $ pull t--{- | MonadIO version of 'pullOps' for use in monadic contexts.--Lifts the 'pullOps' function into a 'MonadIO' context, allowing it to be used in monadic computations-with full configuration options.--Example:-->>> import Control.Monad.IO.Class->>> runReaderT (pullOpsM "gemma3" Nothing (Just True) Nothing) someContext-Remaining bytes: 123456789-...-Completed-Right (PullResp {status = "success", ...})--}-pullOpsM ::- MonadIO m =>- Text ->- Maybe Bool ->- Maybe Bool ->- Maybe OllamaConfig ->- m (Either OllamaError PullResp)-pullOpsM t mbInsecure mbStream mbCfg = liftIO $ pullOps t mbInsecure mbStream mbCfg
@@ -1,115 +0,0 @@-{-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RecordWildCards #-}--{- |-Module : Data.Ollama.Push-Copyright : (c) 2025 Tushar Adhatrao-License : MIT-Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>-Stability : experimental-Description : Functionality for pushing models to the Ollama server.--This module provides functions to push (upload) a model to the Ollama server. It includes-both an IO-based function ('push') and a monadic version ('pushM') for use in 'MonadIO'-contexts. The push operation is performed via a POST request to the @\/api\/pull@ endpoint,-with support for streaming progress updates and insecure connections.--The 'PushOps' type configures the push request, and 'PushResp' represents the response-containing the status and progress details. Streaming mode, when enabled, provides-real-time progress updates by printing to the console.--Example:-->>> push "gemma3" Nothing (Just True) Nothing-Pushing...-Completed--}-module Data.Ollama.Push- ( -- * Push Model API- push- , pushM- ) where--import Control.Monad (void)-import Control.Monad.IO.Class (MonadIO (liftIO))-import Data.Aeson-import Data.Ollama.Common.Config (OllamaConfig)-import Data.Ollama.Common.Types (HasDone (getDone))-import Data.Ollama.Common.Utils as CU-import Data.Text (Text)-import GHC.Generics-import GHC.Int (Int64)---- | Configuration options for pushing a model.-data PushOps = PushOps- { name :: !Text- -- ^ The name of the model to push (e.g., "gemma3").- , insecure :: !(Maybe Bool)- -- ^ Optional flag to allow insecure connections.- -- If 'Just True', insecure connections are permitted.- , stream :: !(Maybe Bool)- -- ^ Optional flag to enable streaming of the upload.- -- If 'Just True', progress updates are streamed.- }- deriving (Show, Eq, Generic, ToJSON)---- | Response data from a push operation.-data PushResp = PushResp- { status :: !Text- -- ^ The status of the push operation (e.g., "success" or "failure").- , digest :: !(Maybe Text)- -- ^ The digest (hash) of the model, if available.- , total :: !(Maybe Int64)- -- ^ The total size of the model in bytes, if available.- }- deriving (Show, Eq, Generic, FromJSON)--instance HasDone PushResp where- getDone PushResp {..} = status /= "success"--{- | Pushes a model to the Ollama server with specified options.--Sends a POST request to the @\/api\/pull@ endpoint to upload the specified model. Supports-streaming progress updates (if 'stream' is 'Just True') and insecure connections (if-'insecure' is 'Just True'). Prints "Pushing..." during streaming and "Completed" when-finished. Returns '()' on completion.--}-push ::- -- | Model name- Text ->- -- | Optional insecure connection flag- Maybe Bool ->- -- | Optional streaming flag- Maybe Bool ->- -- | Optional 'OllamaConfig' (defaults to 'defaultOllamaConfig' if 'Nothing')- Maybe OllamaConfig ->- IO ()-push modelName mInsecure mStream mbConfig = do- void $- withOllamaRequest- "/api/push"- "POST"- (Just $ PushOps {name = modelName, insecure = mInsecure, stream = mStream})- mbConfig- (commonStreamHandler (onToken, pure ()))- where- onToken :: PushResp -> IO ()- onToken _ = putStrLn "Pushing... "--{- | MonadIO version of 'push' for use in monadic contexts.--Lifts the 'push' function into a 'MonadIO' context, allowing it to be used in monadic-computations.--Example:-->>> import Control.Monad.IO.Class->>> runReaderT (pushM "gemma3" Nothing (Just True) Nothing) someContext-Pushing...-Completed--}-pushM :: MonadIO m => Text -> Maybe Bool -> Maybe Bool -> Maybe OllamaConfig -> m ()-pushM t insec s mbCfg = liftIO $ push t insec s mbCfg
@@ -1,230 +0,0 @@-{-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-}--{- |-Module : Data.Ollama.Show-Copyright : (c) 2025 Tushar Adhatrao-License : MIT-Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>-Stability : experimental-Description : Functionality for retrieving detailed information about models in the Ollama client.--This module provides functions to fetch detailed information about a specific model on the Ollama server.-It includes both high-level ('showModel', 'showModelM') and low-level ('showModelOps', 'showModelOpsM') APIs-for retrieving model details, with support for verbose output. The operation is performed via a POST request-to the @\/api\/show@ endpoint, returning a 'ShowModelResponse' containing comprehensive model metadata.--The 'ShowModelOps' type configures the request, and 'ShowModelResponse' and 'ShowModelInfo' represent the-response structure. The module also re-exports 'CT.ModelDetails' for completeness.--Note: Verbose mode parsing is currently not fully supported.--Example:-->>> showModel "gemma3"-Right (ShowModelResponse {modelFile = "...", ...})--@since 1.0.0.0--}-module Data.Ollama.Show- ( -- * Show Model Info API- showModel- , showModelM- , showModelOps- , showModelOpsM-- -- * Response Types- , ShowModelResponse (..)- , ShowModelInfo (..)- , CT.ModelDetails (..)- ) where--import Control.Monad.IO.Class (MonadIO (liftIO))-import Data.Aeson-import Data.Ollama.Common.Config (OllamaConfig)-import Data.Ollama.Common.Error (OllamaError)-import Data.Ollama.Common.Types qualified as CT-import Data.Ollama.Common.Utils (commonNonStreamingHandler, withOllamaRequest)-import Data.Text (Text)-import GHC.Generics-import GHC.Int (Int64)---- | Configuration options for requesting model information.-data ShowModelOps = ShowModelOps- { name :: !Text- -- ^ The name of the model to query (e.g., "gemma3").- , verbose :: !(Maybe Bool)- -- ^ Optional flag to request verbose output. Note: Verbose mode parsing is currently incomplete.- }- deriving (Show, Eq, Generic, ToJSON)---- | Response structure for model information.-data ShowModelResponse = ShowModelResponse- { modelFile :: !Text- -- ^ The content of the model's Modelfile.- , parameters :: !(Maybe Text)- -- ^ Optional model parameters (e.g., temperature settings).- , template :: !(Maybe Text)- -- ^ Optional template used for the model.- , details :: !CT.ModelDetails- -- ^ General details about the model (e.g., format, family).- , modelInfo :: !ShowModelInfo- -- ^ Detailed technical information about the model.- , license :: !(Maybe Text)- -- ^ Optional license information for the model.- --- -- @since 0.2.0.0- , capabilities :: Maybe [Text]- -- ^ Optional list of model capabilities.- --- -- @since 0.2.0.0- }- deriving (Show, Eq)---- | Detailed technical information about a model.-data ShowModelInfo = ShowModelInfo- { generalArchitecture :: !(Maybe Text)- -- ^ The architecture of the model (e.g., "llama").- , generalFileType :: !(Maybe Int)- -- ^ The file type identifier for the model.- , generalParameterCount :: !(Maybe Int64)- -- ^ The number of parameters in the model.- , generalQuantizationVersion :: !(Maybe Int)- -- ^ The quantization version used by the model.- , llamaAttentionHeadCount :: !(Maybe Int)- -- ^ Number of attention heads in the LLaMA model.- , llamaAttentionHeadCountKV :: !(Maybe Int)- -- ^ Number of key-value attention heads in the LLaMA model.- , llamaAttentionLayerNormRMSEpsilon :: !(Maybe Float)- -- ^ RMS epsilon for layer normalization in the LLaMA model.- , llamaBlockCount :: !(Maybe Int)- -- ^ Number of blocks in the LLaMA model.- , llamaContextLength :: !(Maybe Int)- -- ^ Context length supported by the LLaMA model.- , llamaEmbeddingLength :: !(Maybe Int)- -- ^ Embedding length used by the LLaMA model.- , llamaFeedForwardLength :: !(Maybe Int)- -- ^ Feed-forward layer length in the LLaMA model.- , llamaRopeDimensionCount :: !(Maybe Int)- -- ^ RoPE dimension count in the LLaMA model.- , llamaRopeFreqBase :: !(Maybe Int64)- -- ^ Base frequency for RoPE in the LLaMA model.- , llamaVocabSize :: !(Maybe Int64)- -- ^ Vocabulary size of the LLaMA model.- , tokenizerGgmlBosToken_id :: !(Maybe Int)- -- ^ BOS (beginning of sequence) token ID for the GGML tokenizer.- , tokenizerGgmlEosToken_id :: !(Maybe Int)- -- ^ EOS (end of sequence) token ID for the GGML tokenizer.- , tokenizerGgmlMerges :: !(Maybe [Text])- -- ^ List of merges for the GGML tokenizer.- , tokenizerGgmlMode :: !(Maybe Text)- -- ^ Mode of the GGML tokenizer.- , tokenizerGgmlPre :: !(Maybe Text)- -- ^ Pre-tokenization configuration for the GGML tokenizer.- , tokenizerGgmlTokenType :: !(Maybe [Text])- -- ^ Token type information for the GGML tokenizer.- , tokenizerGgmlTokens :: !(Maybe [Text])- -- ^ List of tokens for the GGML tokenizer.- }- deriving (Show, Eq)---- | JSON parsing instance for 'ShowModelResponse'.-instance FromJSON ShowModelResponse where- parseJSON = withObject "ShowModelResponse" $ \v ->- ShowModelResponse- <$> v .: "modelfile"- <*> v .:? "parameters"- <*> v .:? "template"- <*> v .: "details"- <*> v .: "model_info"- <*> v .:? "license"- <*> v .:? "capabilities"---- | JSON parsing instance for 'ShowModelInfo'.-instance FromJSON ShowModelInfo where- parseJSON = withObject "ModelInfo" $ \v ->- ShowModelInfo- <$> 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"--{- | Retrieves model information with configuration options.--Sends a POST request to the @\/api\/show@ endpoint to fetch detailed information about-the specified model. Supports verbose output if 'verbose' is 'Just True' (though verbose-mode parsing is currently incomplete). Returns 'Right' with a 'ShowModelResponse' on-success or 'Left' with an 'OllamaError' on failure.--}-showModelOps ::- -- | Model name- Text ->- -- | Optional verbose flag- Maybe Bool ->- -- | Optional 'OllamaConfig' (defaults to 'defaultOllamaConfig' if 'Nothing')- Maybe OllamaConfig ->- IO (Either OllamaError ShowModelResponse)-showModelOps modelName verbose_ mbConfig = do- withOllamaRequest- "/api/show"- "POST"- ( Just $- ShowModelOps- { name = modelName- , verbose = verbose_- }- )- mbConfig- commonNonStreamingHandler--{- | Simplified API for retrieving model information.--A higher-level function that fetches model information using default settings for-verbose output and Ollama configuration. Suitable for basic use cases.--}-showModel ::- -- | Model name- Text ->- IO (Either OllamaError ShowModelResponse)-showModel modelName =- showModelOps modelName Nothing Nothing--{- | MonadIO version of 'showModel' for use in monadic contexts.--Lifts the 'showModel' function into a 'MonadIO' context, allowing it to be used in-monadic computations.--}-showModelM :: MonadIO m => Text -> m (Either OllamaError ShowModelResponse)-showModelM t = liftIO $ showModel t--{- | MonadIO version of 'showModelOps' for use in monadic contexts.--Lifts the 'showModelOps' function into a 'MonadIO' context, allowing it to be used in-monadic computations with full configuration options.--}-showModelOpsM ::- MonadIO m =>- Text ->- Maybe Bool ->- Maybe OllamaConfig ->- m (Either OllamaError ShowModelResponse)-showModelOpsM t v mbCfg = liftIO $ showModelOps t v mbCfg
@@ -1,199 +1,189 @@-{-# LANGUAGE DuplicateRecordFields #-}- {- |-Module : Data.Ollama-Copyright : (c) 2025 Tushar Adhatrao+Module : Ollama+Copyright : (c) 2024-2026 Tushar Adhatrao License : MIT-Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>-Stability : experimental+Maintainer : tusharadhatrao@gmail.com+Stability : stable Portability : portable -== Ollama Haskell--This module provides a high-level Haskell interface to the [Ollama](https://ollama.com) API- for interacting with local LLMs. It includes support for:--- Text generation (sync/streaming)-- Conversational chat (with tools and images)-- Embeddings-- Model management (pull, push, delete, list, show)-- Structured outputs-- Custom configuration and model options--Inspired by @ollama-python@, this library is built to offer idiomatic Haskell bindings-over Ollama’s HTTP API.--== 🔧 Usage+Top-level umbrella re-export module for the Ollama Haskell client library. -Import this module as a top-level interface:+== Quick Example @ import Ollama-@ -All functions return @Either OllamaError a@ or can be used in a Monad stack using-their @\*M@ variants.--== 🔑 Main APIs--=== ✍️ Generate Text--- 'generate', 'generateM' – Generate text from a model-- 'defaultGenerateOps' – Default generation parameters-- 'GenerateOps', 'GenerateResponse' – Request and response types--=== 💬 Chat with LLMs--- 'chat', 'chatM' – Send chat messages to a model-- 'ChatOps', 'ChatResponse', 'Role', 'Message' – Chat input/output types-- Supports tools via 'InputTool', 'FunctionDef', 'OutputFunction', etc.--=== 🧠 Embeddings--- 'embedding', 'embeddingM' – Generate vector embeddings-- 'EmbeddingOps', 'EmbeddingResp' – Request/response types--=== 📦 Model Management--- 'copyModel', 'createModel', 'deleteModel'-- 'list' – List all installed models-- 'ps', 'psM' – Show running models-- 'showModel', 'showModelM' – Show model info-- 'pull', 'push' – Pull/push models (with progress support)--=== ⚙️ Configuration--- 'defaultOllamaConfig' – Modify host, retries, streaming, etc.-- 'withOnModelStart', 'withOnModelFinish', 'withOnModelError' – Hook support+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"+@ -=== 🧰 Utilities+@since 1.0.0.0+-}+module Ollama (+ -- * Client+ OllamaClient,+ newClient,+ defaultClient,+ clientFromEnv,+ closeClient,+ withClient, -- 'defaultModelOptions', 'encodeImage', 'withOllamaRequest'-- 'loadGenModel', 'unloadGenModel' – Load/unload generation models-- 'getVersion' – Ollama server version+ -- * Config & Retry+ OllamaClientConfig (..),+ defaultConfig,+ RetryPolicy (..),+ LogLevel (..), -== 🧾 Types+ -- * API Endpoints -All request/response payloads and enums are exposed, including:+ -- ** Chat+ chat,+ chatStream,+ ChatRequest (..),+ ChatResponse (..),+ chatRequest,+ chatEvalTokensPerSecond,+ chatPromptEvalTokensPerSecond, -- 'ModelOptions', 'OllamaConfig', 'OllamaError', 'Format'-- 'Models', 'ModelInfo', 'ModelDetails', 'ShowModelResponse'-- 'RunningModels', 'RunningModel', 'Version'--}-module Ollama- ( -- * Main APIs+ -- ** Generate+ generate,+ generateStream,+ GenerateRequest (..),+ GenerateResponse (..),+ generateRequest,+ evalTokensPerSecond,+ promptEvalTokensPerSecond, - -- ** Generate Texts- generate- , generateM- , defaultGenerateOps- , GenerateOps (..)- , GenerateResponse (..)+ -- ** Embeddings+ embed,+ EmbedRequest (..),+ EmbedResponse (..),+ embedRequest,+ embeddings,+ EmbeddingsRequest (..),+ EmbeddingsResponse (..), - -- ** Chat with LLMs- , chat- , chatM- , Role (..)- , defaultChatOps- , ChatResponse (..)- , ChatOps (..)- , InputTool (..)- , FunctionDef (..)- , FunctionParameters (..)- , ToolCall (..)- , OutputFunction (..)+ -- ** Model Management+ listModels,+ showModel,+ copyModel,+ deleteModel,+ ListResponse (..),+ ModelInfo (..),+ RunningModel (..),+ ShowResponse (..), - -- ** Embeddings- , embedding- , embeddingOps- , embeddingM- , embeddingOpsM- , EmbeddingOps (..)- , EmbeddingResp (..)+ -- ** Create+ createModel,+ createModelStream,+ defaultCreateRequest,+ CreateRequest (..),+ CreateResponse (..),+ QuantizationType (..), - -- ** Copy Models- , copyModel- , copyModelM+ -- ** Pull & Push+ pull,+ pullStream,+ push,+ pushStream,+ PullResponse (..),+ PushResponse (..), - -- ** Create Models- , createModel- , createModelM+ -- ** Blobs+ checkBlob,+ pushBlob, - -- ** Delete Models- , deleteModel- , deleteModelM+ -- ** System+ getVersion,+ listRunning,+ RunningModelsResponse (..), - -- ** List Models- , list+ -- * Types & Primitives+ ModelName (..),+ mkModelName,+ Digest (..),+ Base64Image (..),+ Duration (..),+ durationToSeconds,+ durationToMillis,+ tokensPerSecond,+ Version (..),+ Think (..),+ ThinkingLevel (..), - -- ** List currently running models- , ps- , psM+ -- ** Messages+ Role (..),+ Message (..),+ userMessage,+ systemMessage,+ assistantMessage,+ toolMessage,+ toolResultMessage,+ imageMessage, - -- ** Push and Pull- , push- , pushM- , pull- , pullM- , pullOps- , pullOpsM+ -- ** Tools & Functions+ Tool (..),+ FunctionDef (..),+ FunctionParameters (..),+ ToolCall (..),+ ToolCallFunction (..), - -- ** Show Model Info- , showModel- , showModelOps- , showModelM- , showModelOpsM+ -- ** Options & Format+ ModelOptions (..),+ defaultOptions,+ Format (..), - -- ** Blob Operations- , checkBlobExists- , createBlob+ -- * Error Handling+ OllamaError (..),+ isRetryable,+ throwOllama, - -- * Ollama config- , defaultOllamaConfig- , withOnModelStart- , withOnModelFinish- , withOnModelError+ -- * Streaming+ HasDone (..),+ collectStream,+ foldStream, - -- * Utils- , defaultModelOptions- , ModelOptions (..)- , encodeImage- , withOllamaRequest- , getVersion- , loadGenModel- , unloadGenModel- , loadGenModelM- , unloadGenModelM+ -- * Testing Infrastructure+ newMockClient,+ withMockClient,+ mockGenerateResponse,+ mockChatResponse,+ mockEmbedResponse,+ mockListModelsResponse, - -- * Types- , ShowModelResponse (..)- , Models (..)- , ModelInfo (..)- , ModelDetails (..)- , ShowModelInfo (..)- , RunningModels (..)- , RunningModel (..)- , Message (..)- , Format (..)- , OllamaError (..)- , OllamaConfig (..)- , Version (..)- )-where+ -- * Conversation Store+ Conversation (..),+ ConversationStore (..),+ InMemoryStore (..),+ initInMemoryStore,+ saveConversationInMemory,+ loadConversationInMemory,+ listConversationsInMemory,+ deleteConversationInMemory,+) where -import Data.Ollama.Blob-import Data.Ollama.Chat-import Data.Ollama.Common.Config-import Data.Ollama.Common.Types-import Data.Ollama.Common.Utils-import Data.Ollama.Copy-import Data.Ollama.Create-import Data.Ollama.Delete-import Data.Ollama.Embeddings hiding (keepAlive, modelName)-import Data.Ollama.Generate-import Data.Ollama.List-import Data.Ollama.Load-import Data.Ollama.Ps hiding (modelName)-import Data.Ollama.Pull-import Data.Ollama.Push-import Data.Ollama.Show+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.Streaming+import Ollama.Testing+import Ollama.Types
@@ -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
@@ -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
@@ -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" #-}
@@ -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
@@ -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)
@@ -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})
@@ -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))
@@ -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))
@@ -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)
@@ -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)
@@ -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
@@ -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 3.0.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 3.0.0.0+-}+data LogLevel = Debug | Info | Warn | Error+ deriving stock (Eq, Ord, Show, Bounded, Enum)++{- | Configurable retry strategy for recoverable network errors.++@since 3.0.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 3.0.0.0+-}+noRetry :: RetryPolicy+noRetry = NoRetry++{- | Helper constructor for 'ConstantRetry'.++@since 3.0.0.0+-}+constantRetry :: Int -> Int -> RetryPolicy+constantRetry = ConstantRetry++{- | Helper constructor for 'ExponentialRetry'.++@since 3.0.0.0+-}+exponentialRetry :: Int -> Int -> RetryPolicy+exponentialRetry = ExponentialRetry++{- | Configuration settings for an 'OllamaClient'.++@since 3.0.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 90s timeout and 'NoRetry'.++@since 3.0.0.0+-}+defaultConfig :: OllamaClientConfig+defaultConfig =+ OllamaClientConfig+ { configBaseUrl = "http://127.0.0.1:11434"+ , configTimeout = 90+ , configRetry = NoRetry+ , configManager = Nothing+ , configHeaders = []+ , configApiKey = Nothing+ , configLogger = Nothing+ , configOnStart = Nothing+ , configOnSuccess = Nothing+ , configOnError = Nothing+ }
@@ -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 ()
@@ -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
@@ -0,0 +1,65 @@+{- |+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 _ = False++{- | Helper to throw an 'OllamaError' as an exception.++@since 3.0.0.0+-}+throwOllama :: OllamaError -> IO a+throwOllama = throwIO
@@ -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
@@ -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"+ }+ }
@@ -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
@@ -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
@@ -0,0 +1,38 @@+{- |+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 (..),+ module Ollama.Types.Format.SchemaBuilder,+) where++import Data.Aeson+import Ollama.Types.Format.SchemaBuilder++{- | 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
@@ -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 |!, |!!
@@ -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
@@ -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]
@@ -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
@@ -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"
@@ -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
@@ -1,47 +1,32 @@-{-# LANGUAGE OverloadedStrings #-}- module Main (main) where -import Data.Text (unpack)-import Ollama (Version (..), getVersion)-import Test.Ollama.Blob qualified as Blob-import Test.Ollama.Chat qualified as Chat-import Test.Ollama.Common qualified as Common-import Test.Ollama.Copy qualified as Copy-import Test.Ollama.Create qualified as Create-import Test.Ollama.Delete qualified as Delete-import Test.Ollama.Embedding qualified as Embeddings-import Test.Ollama.Generate qualified as Generate-import Test.Ollama.List qualified as List-import Test.Ollama.Load qualified as Load-import Test.Ollama.Show qualified as Show+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.SchemaBuilder qualified as UnitSchemaBuilder+import Test.Ollama.Unit.Testing qualified as UnitTesting+import Test.Ollama.Unit.Types qualified as UnitTypes import Test.Tasty tests :: TestTree tests = testGroup- "Tests"- [ -- Core functionality tests- Generate.tests- , Chat.tests- , Embeddings.tests- , -- Model management tests- Show.tests- , List.tests- , Copy.tests- , Create.tests- , Delete.tests- , Load.tests- , -- Utility and blob tests- Blob.tests- , Common.tests+ "ollama-haskell Pure Test Suite"+ [ UnitTypes.tests+ , UnitError.tests+ , UnitConfig.tests+ , UnitSchemaBuilder.tests+ , UnitTesting.testingTests+ , PropertyRoundtrip.tests+ , GoldenChat.tests+ , GoldenGenerate.tests+ , GoldenEmbed.tests+ , GoldenModels.tests ] main :: IO ()-main = do- eRes <- getVersion- case eRes of- Left err -> print err- Right (Version r) -> do- putStrLn $ "Ollama client version: " <> unpack r- defaultMain tests+main = defaultMain tests
@@ -1,50 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--module Test.Ollama.Blob (tests) where--import Data.Ollama.Blob-import Data.Ollama.Common.Config (OllamaConfig (..), defaultOllamaConfig)-import Test.Tasty-import Test.Tasty.HUnit--testCheckBlobExistsInvalid :: TestTree-testCheckBlobExistsInvalid = testCase "Check blob exists: invalid digest should fail" $ do- res <- checkBlobExists "invalid-digest" Nothing- case res of- Left _ -> assertBool "Should fail with invalid digest" True- Right _ -> assertBool "Or return False for invalid digest" True--testCheckBlobExistsNonExistent :: TestTree-testCheckBlobExistsNonExistent = testCase "Check blob exists: non-existent blob" $ do- let fakeDigest = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"- res <- checkBlobExists fakeDigest Nothing- case res of- Left _ -> return () -- Network error is acceptable- Right exists -> assertBool "Non-existent blob should return False" (not exists)--testCheckBlobExistsWithConfig :: TestTree-testCheckBlobExistsWithConfig = testCase "Check blob exists: with custom config" $ do- let config = Just $ defaultOllamaConfig {timeout = 5}- fakeDigest = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"- res <- checkBlobExists fakeDigest config- case res of- Left _ -> return () -- Network error is acceptable- Right _ -> assertBool "Should handle custom config" True--testCreateBlobInvalidFile :: TestTree-testCreateBlobInvalidFile = testCase "Create blob: invalid file path should fail" $ do- let fakeDigest = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"- res <- createBlob "/nonexistent/file/path" fakeDigest Nothing- case res of- Left _ -> assertBool "Should fail with invalid file path" True- Right () -> assertFailure "Should not succeed with invalid file path"--tests :: TestTree-tests =- testGroup- "Blob tests"- [ testCheckBlobExistsInvalid- , testCheckBlobExistsNonExistent- , testCheckBlobExistsWithConfig- , testCreateBlobInvalidFile- ]
@@ -1,307 +0,0 @@-{-# LANGUAGE CPP #-}-{-# LANGUAGE OverloadedStrings #-}--module Test.Ollama.Chat (tests) where--import Control.Monad (void)-import Data.Aeson qualified as Aeson-import Data.ByteString.Lazy.Char8 qualified as BSL-import Data.IORef (modifyIORef, newIORef, readIORef, writeIORef)-import Data.List.NonEmpty (fromList)-import Data.List.NonEmpty qualified as NE-import Data.Map qualified as HM-import Data.Maybe (isJust)-import Data.Ollama.Chat-import Data.Scientific-import Data.Text qualified as T-import Data.Time (diffUTCTime, getCurrentTime)-import Network.HTTP.Client-import Network.HTTP.Client.TLS-import Test.Tasty-import Test.Tasty.HUnit---- | Basic chat test with default options-basicChatTest :: TestTree-basicChatTest = testCase "Basic chat should contain 4 for 2+2" $ do- let ops = defaultChatOps- eRes <- chat ops Nothing- case eRes of- Left err -> assertFailure $ "Expected success, got error: " ++ show err- Right r -> case message r of- Nothing -> assertFailure "Expected a message in response"- Just msg -> assertBool "Should contain '4'" (T.isInfixOf "4" (content msg))---- | Test timeout configuration-timeoutTest :: TestTree-timeoutTest = testCase "Setting timeout" $ do- let config = Just $ defaultOllamaConfig {timeout = 1}- eRes <- chat defaultChatOps config- case eRes of- Right _ -> assertFailure "The model responded before timeout"- Left (TimeoutError _) -> pure ()- Left other -> assertFailure $ "Expected timeout error, got " ++ show other---- | Test model lifecycle hooks on failure-hooksFailTest :: TestTree-hooksFailTest = testCase "Model lifecycle hooks should trigger on failure" $ do- refStart <- newIORef False- refError <- newIORef False- refFinish <- newIORef True- let config =- defaultOllamaConfig- { hostUrl = "http://localhost:12345" -- Guaranteed to fail- , onModelStart = Just $ writeIORef refStart True- , onModelError = Just $ writeIORef refError True- , onModelFinish = Just $ writeIORef refFinish False- }- void $ chat defaultChatOps (Just config)- wasStarted <- readIORef refStart- wasErrored <- readIORef refError- wasFinished <- readIORef refFinish- assertBool "onModelStart should be called" wasStarted- assertBool "onModelError should be called" wasErrored- assertBool "onModelFinish should be called" wasFinished---- | Test model lifecycle hooks on success-hooksSuccessTest :: TestTree-hooksSuccessTest = testCase "Model lifecycle hooks should trigger on success" $ do- refStart <- newIORef False- refError <- newIORef True- refFinish <- newIORef False- let config =- defaultOllamaConfig- { onModelStart = Just $ writeIORef refStart True- , onModelError = Just $ writeIORef refError False- , onModelFinish = Just $ writeIORef refFinish True- }- void $ chat defaultChatOps (Just config)- wasStarted <- readIORef refStart- wasErrored <- readIORef refError- wasFinished <- readIORef refFinish- assertBool "onModelStart should be called" wasStarted- assertBool "onModelError should not be called" wasErrored- assertBool "onModelFinish should be called" wasFinished---- | Test retry count-retryCountTest :: TestTree-retryCountTest = testCase "Should retry chat call retryCount times" $ do- counter <- newIORef (0 :: Int)- let config =- defaultOllamaConfig- { hostUrl = "http://localhost:12345" -- Fails- , retryCount = Just 2- , retryDelay = Just 1- , onModelStart = Just $ modifyIORef counter (+ 1)- , onModelError = Just $ pure ()- , onModelFinish = Just $ pure ()- }- _ <- chat defaultChatOps (Just config)- calls <- readIORef counter- assertEqual "Expected 3 attempts (1 initial + 2 retries)" 3 calls---- | Test retry delay-retryDelayTest :: TestTree-retryDelayTest = testCase "Should delay between retries" $ do- counter <- newIORef (0 :: Int)- let delaySecs = 2- start <- getCurrentTime- let config =- defaultOllamaConfig- { hostUrl = "http://localhost:12345" -- Fails- , retryCount = Just 1- , retryDelay = Just delaySecs- , onModelStart = Just $ modifyIORef counter (+ 1)- , onModelError = Just $ pure ()- , onModelFinish = Just $ pure ()- }- _ <- chat defaultChatOps (Just config)- end <- getCurrentTime- let elapsed = realToFrac (diffUTCTime end start) :: Double- expectedMin = fromIntegral delaySecs- assertBool- ("Elapsed time should be at least " ++ show expectedMin ++ "s, but was " ++ show elapsed)- (elapsed >= expectedMin)---- | Test common manager usage-commonManagerTest :: TestTree-commonManagerTest = testCase "Should reuse provided commonManager" $ do- refStart <- newIORef (0 :: Int)- mgr <-- newTlsManagerWith- tlsManagerSettings {managerResponseTimeout = responseTimeoutMicro 1000000}- let config =- defaultOllamaConfig- { hostUrl = "http://localhost:12345" -- Will fail fast- , commonManager = Just mgr- , timeout = 999 -- Shouldn’t matter, manager timeout takes precedence- , onModelStart = Just $ modifyIORef refStart (+ 1)- , onModelError = Just $ pure ()- , onModelFinish = Just $ pure ()- }- _ <- chat defaultChatOps (Just config)- _ <- chat defaultChatOps (Just config)- startCount <- readIORef refStart- assertEqual "Both requests should start (reuse manager)" 2 startCount---- | Test JSON format response-jsonFormatTest :: TestTree-jsonFormatTest = testCase "Should return response in JSON format" $ do- let ops =- defaultChatOps- { messages =- fromList- [userMessage "Return a JSON with keys 'name' and 'age' for John, 25 years old."]- , format = Just JsonFormat- }- eRes <- chat ops Nothing- case eRes of- Left err -> assertFailure $ "Expected success, got error: " ++ show err- Right r -> case message r of- Nothing -> assertFailure "Expected a message in response"- Just msg -> do- let responseText = content msg- let decoded = Aeson.decode (BSL.pack $ T.unpack responseText) :: Maybe Aeson.Value- assertBool "Expected valid JSON object in response" (isJust decoded)---- | Test streaming response-streamingTest :: TestTree-streamingTest = testCase "Should handle streaming response" $ do- chunksRef <- newIORef []- let streamHandler chunk = modifyIORef chunksRef (++ [message chunk])- ops = defaultChatOps {stream = Just (streamHandler, pure ())}- eRes <- chat ops Nothing- chunks <- readIORef chunksRef- let fullOutput = T.concat (map (maybe "" content) chunks)- case eRes of- Left err -> assertFailure $ "Expected streaming success, got error: " ++ show err- Right _ -> assertBool "Expected some streamed content" (not $ T.null fullOutput)---- | Test custom model options-modelOptionsTest :: TestTree-modelOptionsTest = testCase "Should use custom model options" $ do- let opts =- Just $- defaultModelOptions- { temperature = Just 0.9- , topP = Just 0.8- , topK = Nothing- , numPredict = Just 20- }- ops = defaultChatOps {options = opts}- eRes <- chat ops Nothing- case eRes of- Left err -> assertFailure $ "Expected success, got error: " ++ show err- Right r -> assertBool "Expected a response message" (isJust (message r))--testToolCallAddTwoNumbers :: TestTree-testToolCallAddTwoNumbers = testCase "Tool call: addTwoNumbers(23, 46)" $ do- let messageList = NE.singleton $ userMessage "What is 23 + 46? (Use tool)"- paramProps =- HM.fromList- [ ("a", FunctionParameters "number" Nothing Nothing Nothing)- , ("b", FunctionParameters "number" Nothing Nothing Nothing)- ]- functionParams =- FunctionParameters- { parameterType = "object"- , requiredParams = Just ["a", "b"]- , parameterProperties = Just paramProps- , additionalProperties = Just False- }- functionDef =- FunctionDef- { functionName = "addTwoNumbers"- , functionDescription = Just "Add two numbers"- , functionParameters = Just functionParams- , functionStrict = Nothing- }- tool =- InputTool- { toolType = "function"- , function = functionDef- }- ops =- defaultChatOps- { modelName = "qwen3:0.6b"- , messages = messageList- , tools = Just [tool]- }-- res <- chat ops Nothing- case res of- Left err -> assertFailure $ "Chat failed: " ++ show err- Right ChatResponse {message = Nothing} -> assertFailure "No message in response"- Right ChatResponse {message = Just msg} ->- case tool_calls msg of- Nothing -> assertFailure "No tool calls received"- Just [toolCall] -> do- result <- captureAddToolCall toolCall- assertEqual "Expected result of 23 + 46" 69 result- Just other -> assertFailure $ "Unexpected number of tool calls: " ++ show other---- Helper to evaluate the tool call-captureAddToolCall :: ToolCall -> IO Int-captureAddToolCall (ToolCall func)- | outputFunctionName func == "addTwoNumbers" =- case ( HM.lookup "a" (arguments func) >>= convertToNumber- , HM.lookup "b" (arguments func) >>= convertToNumber- ) of- (Just a, Just b) -> return $ addTwoNumbers a b- _ -> assertFailure "Missing parameters a or b" >> return 0- | otherwise = assertFailure "Unexpected function name" >> return 0--addTwoNumbers :: Int -> Int -> Int-addTwoNumbers = (+)---- Convert Aeson value to Int-convertToNumber :: Aeson.Value -> Maybe Int-convertToNumber (Aeson.Number n) = toBoundedInteger n-convertToNumber _ = Nothing---- | Test conversation with multiple messages-multiMessageConversationTest :: TestTree-multiMessageConversationTest = testCase "Multi-message conversation should work" $ do- let msgs =- fromList- [ systemMessage "You are a helpful assistant."- , userMessage "What is 2+2?"- , assistantMessage "2+2 equals 4."- , userMessage "What about 3+3?"- ]- ops = defaultChatOps {messages = msgs}- eRes <- chat ops Nothing- case eRes of- Left _ -> assertFailure "Expected success, got error"- Right r -> case message r of- Nothing -> assertFailure "Expected a message in response"- Just msg -> assertBool "Should contain '6'" (T.isInfixOf "6" (content msg))---- | Test invalid model name-invalidModelChatTest :: TestTree-invalidModelChatTest = testCase "Invalid model name should fail" $ do- let ops = defaultChatOps {modelName = "nonexistent-chat-model"}- eRes <- chat ops Nothing- case eRes of- Left _ -> assertBool "Should fail with invalid model" True- Right _ -> assertFailure "Should not succeed with invalid model"---- | Group all tests-tests :: TestTree-tests =- sequentialTestGroup- "Chat tests"- AllFinish- [ basicChatTest- , timeoutTest- , hooksFailTest- , hooksSuccessTest- , retryCountTest- , retryDelayTest- , commonManagerTest- , jsonFormatTest- , streamingTest- , modelOptionsTest- , testToolCallAddTwoNumbers- , multiMessageConversationTest- , invalidModelChatTest- ]
@@ -1,91 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--module Test.Ollama.Common (tests) where--import Data.Ollama.Common.SchemaBuilder-import Data.Ollama.Common.Utils (encodeImage)-import Data.Text qualified as T-import Test.Tasty-import Test.Tasty.HUnit---- Test SchemaBuilder functionality-testSchemaBuilderBasic :: TestTree-testSchemaBuilderBasic = testCase "SchemaBuilder: basic object construction" $ do- let schema =- buildSchema $- emptyObject- |+ ("name", JString)- |+ ("age", JNumber)- |! "name"- |! "age"-- -- Check that schema is not null/empty- let schemaText = T.pack $ show schema- assertBool "Schema should not be empty" (not $ T.null schemaText)- assertBool "Schema should contain 'name'" ("name" `T.isInfixOf` schemaText)- assertBool "Schema should contain 'age'" ("age" `T.isInfixOf` schemaText)--testSchemaBuilderArray :: TestTree-testSchemaBuilderArray = testCase "SchemaBuilder: array types" $ do- let arraySchema =- buildSchema $- emptyObject- |+ ("items", JArray JString)- |+ ("numbers", JArray JNumber)- |+ ("flags", JArray JBoolean)- |! "items"-- let schemaText = T.pack $ show arraySchema- assertBool "Array schema should not be empty" (not $ T.null schemaText)- assertBool "Should contain items field" ("items" `T.isInfixOf` schemaText)--testSchemaBuilderOptionalFields :: TestTree-testSchemaBuilderOptionalFields = testCase "SchemaBuilder: optional vs required fields" $ do- let schema =- buildSchema $- emptyObject- |+ ("required_field", JString)- |+ ("optional_field", JString)- |! "required_field"-- let schemaText = T.pack $ show schema- assertBool "Schema should contain both fields" $- "required_field" `T.isInfixOf` schemaText- && "optional_field" `T.isInfixOf` schemaText---- Test image encoding utility-testEncodeImageValidFile :: TestTree-testEncodeImageValidFile = testCase "EncodeImage: should handle existing image file" $ do- -- Test with the sample image from the examples- result <- encodeImage "./examples/sample.png"- case result of- Nothing -> return () -- File might not exist in test environment- Just encoded -> do- assertBool "Encoded image should not be empty" (not $ T.null encoded)- assertBool "Should be base64-like content" (T.length encoded > 10)--testEncodeImageNonExistentFile :: TestTree-testEncodeImageNonExistentFile = testCase "EncodeImage: should return Nothing for non-existent file" $ do- result <- encodeImage "./nonexistent/file.png"- case result of- Nothing -> assertBool "Should return Nothing for non-existent file" True- Just _ -> assertFailure "Should not encode non-existent file"--testEncodeImageInvalidFormat :: TestTree-testEncodeImageInvalidFormat = testCase "EncodeImage: should handle invalid file format" $ do- result <- encodeImage "./test/Main.hs" -- Text file, not an image- case result of- Nothing -> assertBool "Should return Nothing for invalid format" True- Just _ -> assertFailure "Should not encode invalid format"--tests :: TestTree-tests =- testGroup- "Common utilities tests"- [ testSchemaBuilderBasic- , testSchemaBuilderArray- , testSchemaBuilderOptionalFields- , testEncodeImageValidFile- , testEncodeImageNonExistentFile- , testEncodeImageInvalidFormat- ]
@@ -1,45 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--module Test.Ollama.Copy (tests) where--import Control.Monad (void)-import Data.Ollama.Common.Config (OllamaConfig (..), defaultOllamaConfig)-import Data.Ollama.Copy-import Data.Ollama.Delete (deleteModel)-import Test.Tasty-import Test.Tasty.HUnit--testCopyModelBasic :: TestTree-testCopyModelBasic = testCase "Copy model: basic functionality" $ do- res <- copyModel "gemma3" "gemma3-test-copy" Nothing- case res of- Left _ -> return () -- Allow failure if source model doesn't exist- Right () -> do- assertBool "Copy should succeed" True- void $ deleteModel "gemma3-test-copy" Nothing--testCopyModelWithConfig :: TestTree-testCopyModelWithConfig = testCase "Copy model: with custom config" $ do- let config = Just $ defaultOllamaConfig {timeout = 30}- res <- copyModel "gemma3" "gemma3-test-copy-config" config- case res of- Left _ -> return () -- Allow failure if source doesn't exist- Right () -> do- assertBool "Copy with config should work" True- void $ deleteModel "gemma3-test-copy-config" Nothing--testCopyModelInvalidSource :: TestTree-testCopyModelInvalidSource = testCase "Copy model: invalid source should fail" $ do- res <- copyModel "nonexistent-model-12345" "some-destination" Nothing- case res of- Left _ -> assertBool "Should fail with invalid source" True- Right () -> assertFailure "Should not succeed with invalid source"--tests :: TestTree-tests =- testGroup- "Copy tests"- [ testCopyModelBasic- , testCopyModelWithConfig- , testCopyModelInvalidSource- ]
@@ -1,79 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--module Test.Ollama.Create (tests) where--import Control.Monad (void)-import Data.Maybe (isNothing)-import Data.Ollama.Common.Types (ModelOptions (..))-import Data.Ollama.Common.Utils (defaultModelOptions)-import Data.Ollama.Create-import Data.Ollama.Delete (deleteModel)-import Test.Tasty-import Test.Tasty.HUnit--testCreateModelBasic :: TestTree-testCreateModelBasic = testCase "Create model: basic from existing model" $ do- let ops =- (defaultCreateOps "test-model-basic")- { fromModel = Just "gemma3"- , systemPrompt = Just "You are a helpful assistant."- }- createModel ops Nothing- assertBool "Create should complete without error" True- void $ deleteModel "test-model-basic" Nothing--testCreateModelWithQuantization :: TestTree-testCreateModelWithQuantization = testCase "Create model: with quantization" $ do- let ops =- (defaultCreateOps "test-model-quantized")- { fromModel = Just "gemma3"- , quantizeType = Just Q4_K_M- }- createModel ops Nothing- assertBool "Quantization request should complete" True- void $ deleteModel "test-model-quantized" Nothing--testCreateModelWithParameters :: TestTree-testCreateModelWithParameters = testCase "Create model: with custom parameters" $ do- let modelOpts =- defaultModelOptions- { temperature = Just 0.7- , topP = Just 0.9- , topK = Just 40- }- ops =- (defaultCreateOps "test-model-params")- { fromModel = Just "gemma3"- , parameters = Just modelOpts- , template = Just "Custom template: {{.Prompt}}"- }- createModel ops Nothing- assertBool "Custom parameters should be handled" True- void $ deleteModel "test-model-params" Nothing--testQuantizationTypeValues :: TestTree-testQuantizationTypeValues = testCase "Quantization types: should have valid values" $ do- let q1 = Q4_K_M- q2 = Q4_K_S- q3 = Q8_0- assertBool "Q4_K_M should be valid" (show q1 == "Q4_K_M")- assertBool "Q4_K_S should be valid" (show q2 == "Q4_K_S")- assertBool "Q8_0 should be valid" (show q3 == "Q8_0")--testCreateModelFieldAccess :: TestTree-testCreateModelFieldAccess = testCase "CreateOps: field access should work" $ do- let ops = defaultCreateOps "test-model"- assertBool "modelName should be accessible" (modelName ops == "test-model")- assertBool "fromModel should be Nothing by default" (isNothing (fromModel ops))- assertBool "files should be Nothing by default" (isNothing (files ops))--tests :: TestTree-tests =- testGroup- "Create tests"- [ testCreateModelBasic- , testCreateModelWithQuantization- , testCreateModelWithParameters- , testQuantizationTypeValues- , testCreateModelFieldAccess- ]
@@ -1,42 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--module Test.Ollama.Delete (tests) where--import Data.Ollama.Common.Config (OllamaConfig (..), defaultOllamaConfig)-import Data.Ollama.Delete-import Test.Tasty-import Test.Tasty.HUnit--testDeleteModelNonExistent :: TestTree-testDeleteModelNonExistent = testCase "Delete non-existent model should fail gracefully" $ do- res <- deleteModel "nonexistent-model-12345" Nothing- case res of- Left _ -> assertBool "Should fail when deleting non-existent model" True- Right () -> assertBool "Or succeed if server handles gracefully" True--testDeleteModelWithConfig :: TestTree-testDeleteModelWithConfig = testCase "Delete model: with custom config" $ do- let config = Just $ defaultOllamaConfig {timeout = 30}- res <- deleteModel "nonexistent-test-model" config- case res of- Left _ -> assertBool "Should handle custom config" True- Right () -> assertBool "Or succeed if server handles gracefully" True--testDeleteModelEmptyName :: TestTree-testDeleteModelEmptyName = testCase "Delete model: empty name should fail" $ do- res <- deleteModel "" Nothing- case res of- Left _ -> assertBool "Should fail with empty name" True- Right () -> assertFailure "Should not succeed with empty name"---- Note: We avoid testing actual deletion of existing models in unit tests--- as it would be destructive. Integration tests could cover this.--tests :: TestTree-tests =- testGroup- "Delete tests"- [ testDeleteModelNonExistent- , testDeleteModelWithConfig- , testDeleteModelEmptyName- ]
@@ -1,53 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--module Test.Ollama.Embedding (tests) where--import Data.Ollama.Embeddings-import Test.Tasty-import Test.Tasty.HUnit---- Qwen doesn't support embeddings yet--- TODO: Add embeddings supporting model.---- testEmbeddingBasic :: TestTree--- testEmbeddingBasic = testCase "Basic embedding with qwen3" $ do--- res <- embedding "qwen3:0.6b" ["The sky is blue.", "Cats are independent."]--- case res of--- Left err -> assertFailure $ "Expected success, got error: " ++ show err--- Right EmbeddingResp {..} -> do--- assertEqual "Should return two embeddings" 2 (length respondedEmbeddings)--- assertBool "Embeddings should not be empty" (not (any null respondedEmbeddings))---- testEmbeddingWithOptions :: TestTree--- testEmbeddingWithOptions = testCase "Embedding with truncate and keepAlive" $ do--- let opts = defaultModelOptions {numKeep = Just 5, seed = Just 42}--- res <---- embeddingOps--- "qwen3:0.6b"--- ["Hello world"]--- (Just True)--- (Just 30)--- (Just opts)--- Nothing--- Nothing--- case res of--- Left err -> assertFailure $ "Unexpected error: " ++ show err--- Right EmbeddingResp {..} -> do--- assertEqual "Should return one embedding" 1 (length respondedEmbeddings)--- assertBool--- "Embedding vector should not be empty"--- (not . null $ listToMaybe respondedEmbeddings)--testEmbeddingInvalidModel :: TestTree-testEmbeddingInvalidModel = testCase "Embedding with invalid model name" $ do- res <- embedding "nonexistent-model" ["This should fail."]- case res of- Left _ -> return () -- Expected failure- Right _ -> assertFailure "Expected failure with invalid model name"--tests :: TestTree-tests =- testGroup- "Embeddings tests"- [ testEmbeddingInvalidModel- ]
@@ -1,360 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--{-- Tests related to Generate module--}-module Test.Ollama.Generate (tests) where--import Control.Monad (void)-import Data.Aeson qualified as Aeson-import Data.ByteString.Lazy.Char8 qualified as BSL-import Data.IORef (modifyIORef, newIORef, readIORef, writeIORef)-import Data.Maybe (isJust)-import Data.Ollama.Common.SchemaBuilder-import Data.Ollama.Common.Utils (encodeImage)-import Data.Ollama.Generate-import Data.Text qualified as T-import Data.Time (diffUTCTime, getCurrentTime)-import Network.HTTP.Client-import Network.HTTP.Client.TLS-import Test.Tasty-import Test.Tasty.HUnit--generateTests :: TestTree-generateTests =- testGroup- "Generation with various options"- [ testCase "Should contain 4 in 2+2" $ do- eRes <-- generate- defaultGenerateOps {modelName = "gemma3", prompt = "What is 2+2?"}- Nothing- case eRes of- Left err -> assertFailure $ "Expected success, got error: " ++ show err- Right r -> assertBool "Should contain 4" (T.isInfixOf "4" (genResponse r))- , testCase "Setting timeout" $ do- eRes <-- generate- defaultGenerateOps- { modelName = "gemma3"- , prompt = "Write a poem about French revolution"- }- (Just $ defaultOllamaConfig {timeout = 1})- case eRes of- Left (TimeoutError _) -> pure ()- _ -> assertFailure "Expected timeout error"- ]--testOnModelHooksFail :: TestTree-testOnModelHooksFail = testCase "Model lifecycle hooks should be triggered" $ do- refStart <- newIORef False- refError <- newIORef False- refFinish <- newIORef True- let config =- defaultOllamaConfig- { hostUrl = "http://localhost:12345" -- guaranteed to fail- , onModelStart = Just $ writeIORef refStart True- , onModelError = Just $ writeIORef refError True- , onModelFinish = Just $ writeIORef refFinish True- }-- void $- generate- defaultGenerateOps {modelName = "gemma3", prompt = "what is 23+41?"}- (Just config)-- wasStarted <- readIORef refStart- wasErrored <- readIORef refError- wasFinished <- readIORef refFinish-- assertBool "onModelStart should be called" wasStarted- assertBool "onModelError should be called" wasErrored- assertBool "onModelFinish should be called" wasFinished--testOnModelHooksSucc :: TestTree-testOnModelHooksSucc = testCase "Model lifecycle hooks should be triggered 2" $ do- refStart <- newIORef False- refError <- newIORef True- refFinish <- newIORef False- let config =- defaultOllamaConfig- { onModelStart = Just $ writeIORef refStart True- , onModelError = Just $ writeIORef refError False- , onModelFinish = Just $ writeIORef refFinish True- }-- void $- generate- defaultGenerateOps {modelName = "gemma3", prompt = "what is 23+41?"}- (Just config)-- wasStarted <- readIORef refStart- wasErrored <- readIORef refError- wasFinished <- readIORef refFinish-- assertBool "onModelStart should be called" wasStarted- assertBool "onModelError should be called" wasErrored- assertBool "onModelFinish should be called" wasFinished--testRetryCount :: TestTree-testRetryCount = testCase "Should retry generate call retryCount times" $ do- counter <- newIORef (0 :: Int)- let config =- defaultOllamaConfig- { hostUrl = "http://localhost:12345" -- fails- , retryCount = Just 2- , retryDelay = Just 1- , onModelStart = Just $ modifyIORef counter (+ 1)- , onModelError = Just $ pure ()- , onModelFinish = Just $ pure ()- }-- _ <- generate defaultGenerateOps {prompt = "Retry test"} (Just config)- calls <- readIORef counter- -- Should be retryCount + 1 (initial + retries)- assertEqual "Expected 3 attempts (1 initial + 2 retries)" 3 calls--testRetryDelay :: TestTree-testRetryDelay = testCase "Should delay between retries" $ do- counter <- newIORef (0 :: Int)- let delaySecs = 2- start <- getCurrentTime-- let config =- defaultOllamaConfig- { hostUrl = "http://localhost:12345" -- fails- , retryCount = Just 1- , retryDelay = Just delaySecs- , onModelStart = Just $ modifyIORef counter (+ 1)- , onModelError = Just $ pure ()- , onModelFinish = Just $ pure ()- }-- _ <- generate defaultGenerateOps {prompt = "Retry delay test"} (Just config)- end <- getCurrentTime- let elapsed = realToFrac (diffUTCTime end start) :: Double- let expectedMin = fromIntegral delaySecs-- assertBool- ("Elapsed time should be at least " ++ show expectedMin ++ "s, but was " ++ show elapsed)- (elapsed >= expectedMin)--testCommonManagerUsage :: TestTree-testCommonManagerUsage = testCase "Should reuse provided commonManager" $ do- refStart <- newIORef (0 :: Int)- mgr <-- newTlsManagerWith tlsManagerSettings {managerResponseTimeout = responseTimeoutMicro 1000000}- let config =- defaultOllamaConfig- { hostUrl = "http://localhost:12345" -- will fail fast- , commonManager = Just mgr- , timeout = 999 -- shouldn't matter, manager timeout will be used- , onModelStart = Just $ modifyIORef refStart (+ 1)- , onModelError = Just $ pure ()- , onModelFinish = Just $ pure ()- }-- _ <- generate defaultGenerateOps {prompt = "1"} (Just config)- _ <- generate defaultGenerateOps {prompt = "2"} (Just config)- startCount <- readIORef refStart- assertEqual "Both requests should start (reuse manager)" 2 startCount--{-- Suffix is not supported for few gemma3 and qwen3.--testSuffixOption :: TestTree-testSuffixOption = testCase "Should respect suffix in generation" $ do- let ops = defaultGenerateOps- { modelName = "qwen3:0.6b"- , prompt = "Complete this sentence: The Eiffel Tower is in"- , suffix = Just " [End]"- }- eRes <- generate ops Nothing- case eRes of- Left err -> assertFailure $ "Expected success, got error: " ++ show err- Right r -> assertBool "Expected suffix in response" $- T.isSuffixOf "[End]" (genResponse r)- -}--testThinkOption :: TestTree-testThinkOption = testCase "Should activate thinking mode when think=True" $ do- let ops =- defaultGenerateOps- { modelName = "qwen3:0.6b"- , prompt = "What is 2+2?"- , think = Just True- }- eRes <- generate ops Nothing- case eRes of- Left err -> assertFailure $ "Expected success, got error: " ++ show err- Right _ -> pure () -- TODO: Need to find a way to know if model is thinking--testFormatJsonFormat :: TestTree-testFormatJsonFormat = testCase "Should return response in JsonFormat" $ do- let ops =- defaultGenerateOps- { modelName = "gemma3"- , prompt =- "John was 23 year old in 2023, this is year 2025."- <> "How old is John assuming he celebrated this year's birthday; "- <> "Return an object with keys 'name' and 'age'."- , format = Just JsonFormat- }- eRes <- generate ops Nothing- case eRes of- Left err -> assertFailure $ "Expected success, got error: " ++ show err- Right r -> do- let responseText = genResponse r- let decoded =- Aeson.decode (BSL.pack $ T.unpack responseText) ::- Maybe Aeson.Value- assertBool "Expected valid JSON object in response" (isJust decoded)--testFormatSchemaFormat :: TestTree-testFormatSchemaFormat = testCase "Should include SchemaFormat in the request" $ do- let schema =- buildSchema $- emptyObject- |+ ("fruit", JString)- |+ ("quantity", JNumber)- |! "fruit"- |! "quantity"-- ops =- defaultGenerateOps- { modelName = "gemma3"- , prompt = "I had 3 apples, 1 gave one away. How many left?"- , format = Just (SchemaFormat schema)- }-- eRes <- generate ops Nothing- case eRes of- Left err -> assertFailure $ "Expected success, got error: " ++ show err- Right r -> do- let response = T.toLower (genResponse r)- assertBool "Expected fruit information in response" $- "apple" `T.isInfixOf` response || "fruit" `T.isInfixOf` response--testImageInput :: TestTree-testImageInput = testCase "Should accept and process base64 image input" $ do- maybeImg <- encodeImage "./examples/sample.png"- case maybeImg of- Nothing -> assertFailure "Image encoding failed (unsupported format or missing file)"- Just imgData -> do- -- Validate the encoded image data- assertBool "Encoded image should not be empty" (not $ T.null imgData)- assertBool "Encoded image should be reasonable length" (T.length imgData > 100)-- let ops =- defaultGenerateOps- { modelName = "gemma3"- , prompt = "Describe this image."- , images = Just [imgData]- }- cfg = Just defaultOllamaConfig {timeout = 300}-- eRes <- generate ops cfg- case eRes of- Left err -> assertFailure $ "Expected success, got error: " ++ show err- Right r -> do- let response = T.toLower (genResponse r)- assertBool "Response should not be empty" (not $ T.null response)- assertBool "Expected image-related description in response" $- T.isInfixOf "i love haskell" response--testStreamingHandler :: TestTree-testStreamingHandler = testCase "Should handle streaming response" $ do- -- IORef to collect streamed chunks- chunksRef <- newIORef []- -- Define the stream handler: accumulate responses- let streamHandler chunk = modifyIORef chunksRef (++ [genResponse chunk])- ops =- defaultGenerateOps- { modelName = "gemma3"- , prompt = "Write few words about Haskell."- , stream = Just (streamHandler, pure ())- }- eRes <- generate ops Nothing- -- Collect streamed chunks from IORef- chunks <- readIORef chunksRef- let fullOutput = T.concat chunks- case eRes of- Left err -> assertFailure $ "Expected streaming success, got error: " ++ show err- Right _ -> do- assertBool "Expected streamed text to include 'haskell'" $- "haskell" `T.isInfixOf` T.toLower fullOutput- assertBool "Expected some streamed content" $ not (T.null fullOutput)--testModelOptionsBasic :: TestTree-testModelOptionsBasic = testCase "ModelOptions: temperature and topP" $ do- let opts =- Just $- defaultModelOptions- { temperature = Just 0.9- , topP = Just 0.8- , topK = Nothing- , numPredict = Just 20- }-- eRes <-- generate- defaultGenerateOps- { modelName = "gemma3"- , prompt = "Generate a random list of 3 animals"- , options = opts- }- Nothing-- case eRes of- Left err -> assertFailure $ "Expected success, got: " ++ show err- Right r -> assertBool "Response should not be empty" (not . T.null $ genResponse r)--testModelOptionsEdgeCases :: TestTree-testModelOptionsEdgeCases = testCase "ModelOptions: edge case values" $ do- let opts =- Just $- defaultModelOptions- { temperature = Just 0.0 -- Minimum temperature- , topP = Just 1.0 -- Maximum topP- , topK = Just 1 -- Minimum topK- , numPredict = Just 1 -- Minimum prediction- }- eRes <-- generate- defaultGenerateOps- { modelName = "gemma3"- , prompt = "Hi"- , options = opts- }- Nothing- case eRes of- Left _ -> assertFailure "Expected success, got error"- Right _ -> assertBool "Should handle edge case model options" True--testInvalidModelName :: TestTree-testInvalidModelName = testCase "Invalid model name should fail gracefully" $ do- eRes <- generate defaultGenerateOps {modelName = "invalid-model-xyz"} Nothing- case eRes of- Left _ -> assertBool "Should fail with invalid model name" True- Right _ -> assertFailure "Should not succeed with invalid model name"--tests :: TestTree-tests =- sequentialTestGroup- "Generate tests"- AllFinish- [ generateTests- , testOnModelHooksFail- , testOnModelHooksSucc- , testRetryCount- , testRetryDelay- , testCommonManagerUsage- , testThinkOption- , testFormatJsonFormat- , testFormatSchemaFormat- , testImageInput- , testStreamingHandler- , testModelOptionsBasic- , testModelOptionsEdgeCases- , testInvalidModelName- ]
@@ -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"])+ ]
@@ -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"])+ ]
@@ -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?")+ ]
@@ -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")+ ]
@@ -1,41 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--module Test.Ollama.List (tests) where--import Data.Ollama.Common.Config (OllamaConfig (..), defaultOllamaConfig)-import Data.Ollama.List-import Data.Text qualified as T-import Test.Tasty-import Test.Tasty.HUnit--testListBasic :: TestTree-testListBasic = testCase "List models: basic call should return Models" $ do- res <- list Nothing- case res of- Left err -> assertFailure $ "Expected success, got error: " ++ show err- Right (Models modelList) -> do- assertBool "Should return a list (possibly empty)" True- -- Additional checks if models are present- case modelList of- [] -> return () -- Empty list is acceptable- (model : _) -> do- assertBool "Model name should not be empty" (not $ T.null $ name model)- assertBool "Model digest should not be empty" (not $ T.null $ digest model)- assertBool "Model size should be positive" (size model > 0)- assertBool "details should be present" True -- ModelDetails is always present--testListWithConfig :: TestTree-testListWithConfig = testCase "List with custom config should work" $ do- let config = Just $ defaultOllamaConfig {timeout = 10}- res <- list config- case res of- Left _ -> assertFailure "Expected success, got error"- Right (Models _) -> assertBool "Should return Models type" True--tests :: TestTree-tests =- testGroup- "List tests"- [ testListBasic- , testListWithConfig- ]
@@ -1,53 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--module Test.Ollama.Load (tests) where--import Data.Ollama.Load-import Test.Tasty-import Test.Tasty.HUnit--testLoadGenModelBasic :: TestTree-testLoadGenModelBasic = testCase "Load generation model: basic functionality" $ do- res <- loadGenModel "gemma3"- case res of- Left _ -> assertFailure "Expected success, got error"- Right () -> assertBool "Load should succeed" True--testUnloadGenModelBasic :: TestTree-testUnloadGenModelBasic = testCase "Unload generation model: basic functionality" $ do- res <- unloadGenModel "gemma3"- case res of- Left _ -> assertFailure "Expected success, got error"- Right () -> assertBool "Unload should succeed" True--testLoadInvalidModel :: TestTree-testLoadInvalidModel = testCase "Load invalid model should fail" $ do- res <- loadGenModel "nonexistent-model-12345"- case res of- Left _ -> assertBool "Should fail with invalid model" True- Right () -> assertFailure "Should not succeed with invalid model"--testUnloadInvalidModel :: TestTree-testUnloadInvalidModel = testCase "Unload invalid model should handle gracefully" $ do- res <- unloadGenModel "nonexistent-model-12345"- case res of- Left _ -> assertBool "Should fail or handle gracefully" True- Right () -> assertBool "Or succeed if server handles gracefully" True--testLoadEmptyModelName :: TestTree-testLoadEmptyModelName = testCase "Load model: empty name should fail" $ do- res <- loadGenModel ""- case res of- Left _ -> assertBool "Should fail with empty name" True- Right () -> assertFailure "Should not succeed with empty name"--tests :: TestTree-tests =- testGroup- "Load tests"- [ testLoadGenModelBasic- , testUnloadGenModelBasic- , testLoadInvalidModel- , testUnloadInvalidModel- , testLoadEmptyModelName- ]
@@ -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
@@ -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+ ]
@@ -1,61 +0,0 @@-{-# LANGUAGE NamedFieldPuns #-}-{-# LANGUAGE OverloadedStrings #-}--module Test.Ollama.Show (tests) where--import Data.Maybe (isJust)-import Data.Ollama.Ps-import Data.Ollama.Show-import Data.Text qualified as T-import Test.Tasty-import Test.Tasty.HUnit--testShowModelBasic :: TestTree-testShowModelBasic = testCase "Show model info: basic call" $ do- res <- showModel "gemma3"- case res of- Left err -> assertFailure $ "Expected success, got error: " ++ show err- Right ShowModelResponse {modelFile, modelInfo = ShowModelInfo {generalArchitecture}} -> do- assertBool "modelFile should not be empty" (not $ T.null modelFile)- assertBool "Architecture should be present" (isJust generalArchitecture)--testShowModelVerbose :: TestTree-testShowModelVerbose = testCase "Show model info: verbose enabled" $ do- res <- showModelOps "qwen3:0.6b" (Just True) Nothing- case res of- Left _ -> pure () -- assertFailure $ "Expected success, got error: " ++ show err- Right ShowModelResponse {template, parameters} -> do- -- Verbose should yield more details like parameters/template- assertBool "Should have a template if verbose" (isJust template)- assertBool "Should have parameters if verbose" (isJust parameters)--testPsBasic :: TestTree-testPsBasic = testCase "List running models: basic success" $ do- res <- ps Nothing- case res of- Left err -> assertFailure $ "Expected success, got error: " ++ show err- Right (RunningModels _) -> do- assertBool "Should return a list (possibly empty)" True--testPsModelFields :: TestTree-testPsModelFields = testCase "Running model fields are populated" $ do- res <- ps Nothing- case res of- Left _ -> return () -- Allow failure if no models are running- Right (RunningModels (m : _)) -> do- assertBool "name_ should not be empty" (not $ T.null $ name_ m)- assertBool "modelName should not be empty" (not $ T.null $ modelName m)- assertBool "modelDigest should not be empty" (not $ T.null $ modelDigest m)- assertBool "size_ should be positive" (size_ m > 0)- assertBool "sizeVRam should be non-negative" (sizeVRam m >= 0)- Right _ -> return () -- If empty, that's acceptable--tests :: TestTree-tests =- testGroup- "show model tests"- [ testShowModelBasic- , testShowModelVerbose- , testPsBasic- , testPsModelFields- ]
@@ -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" 90 (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)+ ]
@@ -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+ ]
@@ -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)+ ]
@@ -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))+ ]
@@ -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+ ]