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