packages feed

llm-simple-0.4.0.0: src/LLM/Core/LLMProvider.hs

module LLM.Core.LLMProvider
  ( LLMProvider (..),
    toGateway,
    genericGenerateText,
    genericStreamText,
  )
where

import Control.Exception (try)
import Data.Aeson (Value, eitherDecodeStrict')
import Data.ByteString (ByteString)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding (decodeUtf8With)
import Data.Text.Encoding.Error (lenientDecode)
import LLM.Core.Types
  ( ChatRequest (reqTools),
    LLMError (HttpError, NetworkError, ParseError),
    LLMGateway (..),
    LLMHooks (..),
    LLMObjectResult,
    LLMResult,
    LLMTextResult,
    StreamEvent,
  )
import LLM.Core.Utils (streamResponseJson)
import Network.HTTP.Req (HttpException)

data LLMProvider = LLMProvider
  { -- | Provider name for logging/hooks (e.g. "claude", "gemini", "openai")
    providerName :: Text,
    -- | MIME types of inline images the provider accepts (see 'gwImageMediaTypes').
    providerImageMediaTypes :: [Text],
    -- | Whether the provider accepts URL images (see 'gwImageUrlSupport').
    providerImageUrlSupport :: Bool,
    -- | Build the JSON request body. Bool indicates whether streaming is requested.
    buildBody :: Bool -> ChatRequest -> Value,
    -- | Make a non-streaming HTTP call, returning (status code, raw response
    -- body). Must not throw on a non-2xx status.
    sendRequest :: Value -> IO (Int, ByteString),
    -- | Make a streaming HTTP call. On a 200 response the continuation receives
    -- a reader over the raw body (empty chunk = EOF); other statuses become an
    -- 'HttpError' without calling it.
    openStream :: Value -> (IO ByteString -> IO LLMTextResult) -> IO LLMTextResult,
    -- | Parse a raw streaming body. Receives the request body (e.g. for the
    -- model name), the body reader and the stream event callback.
    parseStream :: Value -> IO ByteString -> (StreamEvent -> IO ()) -> IO LLMTextResult,
    -- | Parse a complete (non-streaming) JSON response body.
    parseResponse :: Value -> IO LLMTextResult,
    -- | Build the JSON request body for object generation.
    buildObjectBody :: ChatRequest -> Value -> Value,
    -- | Make a non-streaming HTTP call for object generation, returning
    -- (status code, raw response body).
    sendObjectRequest :: Value -> IO (Int, ByteString),
    -- | Parse a complete JSON response body for object generation.
    parseObjectResponse :: Value -> IO LLMObjectResult
  }

-- | Generic streaming chat via the LLMProvider.
genericStreamText :: LLMProvider -> LLMHooks -> ChatRequest -> (StreamEvent -> IO ()) -> IO LLMTextResult
genericStreamText p hooks r callback = do
  let body = p.buildBody True r
  hooks.onLLMRequest p.providerName body
  result <- try (p.openStream body (\readChunk -> p.parseStream body readChunk callback))
  case result of
    Left e -> do
      hooks.onLLMResponseError p.providerName (T.pack (show (e :: HttpException)))
      pure $ Left $ NetworkError (T.pack (show (e :: HttpException)))
    Right r' -> do
      case r' of
        Right resp -> do
          hooks.onLLMResponse p.providerName (streamResponseJson resp)
          pure r'
        Left e -> do
          hooks.onLLMResponseError p.providerName (T.pack (show e))
          pure $ Left e

-- | Generic non-streaming chat via the LLMProvider.
genericGenerateText :: LLMProvider -> LLMHooks -> ChatRequest -> IO LLMTextResult
genericGenerateText p hooks r = do
  let body = p.buildBody False r
  hooks.onLLMRequest p.providerName body
  result <- try (p.sendRequest body)
  case result of
    Left e -> do
      hooks.onLLMResponseError p.providerName (T.pack (show (e :: HttpException)))
      pure $ Left $ NetworkError (T.pack (show (e :: HttpException)))
    Right resp -> handleResponse p hooks p.parseResponse resp

-- | Generic object generation via the LLMProvider.
genericGenerateObject :: LLMProvider -> LLMHooks -> Value -> ChatRequest -> IO LLMObjectResult
genericGenerateObject p hooks schema r = do
  let body = p.buildObjectBody r {reqTools = []} schema
  hooks.onLLMRequest p.providerName body
  result <- try (p.sendObjectRequest body)
  case result of
    Left e -> do
      hooks.onLLMResponseError p.providerName (T.pack (show (e :: HttpException)))
      pure $ Left $ NetworkError (T.pack (show (e :: HttpException)))
    Right resp -> handleResponse p hooks p.parseObjectResponse resp

-- | Turn a non-streaming (status, raw body) into a result. A non-200 status
-- is an 'HttpError' carrying the body as text, whether or not it is JSON
-- (an HTML 502 from a proxy keeps its status). A 200 body that is not JSON
-- is a 'ParseError'. 'onLLMResponse' sees every body that decodes as JSON.
handleResponse :: LLMProvider -> LLMHooks -> (Value -> IO (LLMResult a)) -> (Int, ByteString) -> IO (LLMResult a)
handleResponse p hooks parse (status, raw) = do
  let decoded = eitherDecodeStrict' raw
  either (const (pure ())) (hooks.onLLMResponse p.providerName) decoded
  if status /= 200
    then do
      let bodyText = decodeUtf8With lenientDecode raw
      hooks.onLLMResponseError p.providerName ("HTTP error: " <> T.pack (show status) <> " " <> bodyText)
      pure $ Left $ HttpError status bodyText
    else case decoded of
      Left err -> do
        let msg = "response is not valid JSON: " <> T.pack err
        hooks.onLLMResponseError p.providerName msg
        pure $ Left $ ParseError msg
      Right v -> parse v

-- | Convert any LLMProvider instance into a LLMGateway.
-- LLMHooks are not baked in — they are passed at call time.
toGateway :: LLMProvider -> LLMGateway
toGateway p =
  LLMGateway
    { gwName = p.providerName,
      gwImageMediaTypes = p.providerImageMediaTypes,
      gwImageUrlSupport = p.providerImageUrlSupport,
      gwGenerateText = genericGenerateText p,
      gwStreamText = genericStreamText p,
      gwGenerateObject = genericGenerateObject p
    }