packages feed

ollama-haskell-0.3.0.0: src/Ollama/Client/Internal.hs

{- |
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 ()