never executed always true always false
1 module PureClaw.Providers.Ollama
2 ( -- * Provider type
3 OllamaProvider
4 , mkOllamaProvider
5 , mkOllamaProviderWithUrl
6 -- * Errors
7 , OllamaError (..)
8 -- * Request/response encoding (exported for testing)
9 , encodeRequest
10 , decodeResponse
11 ) where
12
13 import Control.Exception
14 import Data.Aeson
15 import Data.Aeson.Types
16 import Data.ByteString (ByteString)
17 import Data.ByteString.Lazy qualified as BL
18 import Data.Text (Text)
19 import Data.Text qualified as T
20 import Network.HTTP.Client qualified as HTTP
21 import Network.HTTP.Types.Status qualified as Status
22
23 import PureClaw.Core.Errors
24 import PureClaw.Core.Types
25 import PureClaw.Providers.Class
26
27 -- | Ollama provider for local model inference.
28 data OllamaProvider = OllamaProvider
29 { _ol_manager :: HTTP.Manager
30 , _ol_baseUrl :: String
31 }
32
33 -- | Create an Ollama provider. Defaults to localhost:11434.
34 mkOllamaProvider :: HTTP.Manager -> OllamaProvider
35 mkOllamaProvider mgr = OllamaProvider mgr "http://localhost:11434/api/chat"
36
37 -- | Create an Ollama provider with a custom base URL.
38 -- The URL should be the base (e.g. @http://myhost:11434@); @/api/chat@
39 -- is appended automatically.
40 mkOllamaProviderWithUrl :: HTTP.Manager -> String -> OllamaProvider
41 mkOllamaProviderWithUrl mgr url =
42 let trimmed = reverse (dropWhile (== '/') (reverse url))
43 in OllamaProvider mgr (trimmed ++ "/api/chat")
44
45 instance Provider OllamaProvider where
46 complete = ollamaComplete
47
48 -- | Errors from the Ollama API.
49 data OllamaError
50 = OllamaAPIError Int ByteString
51 | OllamaParseError Text
52 deriving stock (Show)
53
54 instance Exception OllamaError
55
56 instance ToPublicError OllamaError where
57 toPublicError _ = TemporaryError "Provider error"
58
59 ollamaComplete :: OllamaProvider -> CompletionRequest -> IO CompletionResponse
60 ollamaComplete provider req = do
61 initReq <- HTTP.parseRequest (_ol_baseUrl provider)
62 let httpReq = initReq
63 { HTTP.method = "POST"
64 , HTTP.requestBody = HTTP.RequestBodyLBS (encodeRequest req)
65 , HTTP.requestHeaders = [("content-type", "application/json")]
66 }
67 resp <- HTTP.httpLbs httpReq (_ol_manager provider)
68 let status = Status.statusCode (HTTP.responseStatus resp)
69 if status /= 200
70 then throwIO (OllamaAPIError status (BL.toStrict (HTTP.responseBody resp)))
71 else case decodeResponse (HTTP.responseBody resp) of
72 Left err -> throwIO (OllamaParseError (T.pack err))
73 Right response -> pure response
74
75 -- | Encode a request for the Ollama /api/chat endpoint.
76 -- Ollama uses system messages in the messages array and a simpler
77 -- tool format than OpenAI.
78 encodeRequest :: CompletionRequest -> BL.ByteString
79 encodeRequest req = encode $ object $
80 [ "model" .= unModelId (_cr_model req)
81 , "messages" .= encodeMessages req
82 , "stream" .= False
83 ]
84 ++ ["tools" .= map encodeTool (_cr_tools req) | not (null (_cr_tools req))]
85
86 encodeMessages :: CompletionRequest -> [Value]
87 encodeMessages req =
88 maybe [] (\s -> [object ["role" .= ("system" :: Text), "content" .= s]]) (_cr_systemPrompt req)
89 ++ map encodeMsg (_cr_messages req)
90
91 encodeMsg :: Message -> Value
92 encodeMsg msg = case _msg_content msg of
93 [TextBlock t] ->
94 object ["role" .= roleToText (_msg_role msg), "content" .= t]
95 blocks ->
96 -- Ollama supports content as string only; concatenate text blocks
97 let textParts = concatMap blockText blocks
98 in object ["role" .= roleToText (_msg_role msg), "content" .= T.intercalate "\n" textParts]
99
100 blockText :: ContentBlock -> [Text]
101 blockText (TextBlock t) = [t]
102 blockText (ImageBlock _ _) = ["[image]"]
103 blockText (ToolUseBlock _ name _) = ["[tool:" <> name <> "]"]
104 blockText (ToolResultBlock _ parts _) = [t | TRPText t <- parts]
105
106 encodeTool :: ToolDefinition -> Value
107 encodeTool td = object
108 [ "type" .= ("function" :: Text)
109 , "function" .= object
110 [ "name" .= _td_name td
111 , "description" .= _td_description td
112 , "parameters" .= _td_inputSchema td
113 ]
114 ]
115
116 -- | Decode an Ollama /api/chat response.
117 decodeResponse :: BL.ByteString -> Either String CompletionResponse
118 decodeResponse bs = eitherDecode bs >>= parseEither parseResp
119 where
120 parseResp :: Value -> Parser CompletionResponse
121 parseResp = withObject "OllamaResponse" $ \o -> do
122 msg <- o .: "message"
123 content <- msg .: "content"
124 modelText <- o .: "model"
125 -- Ollama tool calls come as tool_calls array in the message
126 toolCalls <- msg .:? "tool_calls" .!= ([] :: [Value])
127 toolBlocks <- mapM parseToolCall toolCalls
128 let textBlocks = [TextBlock content | not (T.null content)]
129 pure CompletionResponse
130 { _crsp_content = textBlocks ++ toolBlocks
131 , _crsp_model = ModelId modelText
132 , _crsp_usage = Nothing -- Ollama doesn't report usage in chat endpoint
133 }
134
135 parseToolCall :: Value -> Parser ContentBlock
136 parseToolCall = withObject "ToolCall" $ \tc -> do
137 fn <- tc .: "function"
138 name <- fn .: "name"
139 args <- fn .: "arguments"
140 -- Ollama doesn't return a call ID, so generate a placeholder
141 pure (ToolUseBlock (ToolCallId ("ollama-" <> name)) name args)