packages feed

llm-simple-0.4.2.0: src/LLM/Core/Internal/Body.hs

-- | Bounded reading of provider response bodies.
--
-- A 200 body larger than 'maxResponseBodyBytes' (or a stream event larger
-- than 'maxStreamEventBytes') is signalled with 'ResponseTooLarge', which
-- is not exported from any public module. The @generic*@ functions in
-- 'LLM.Core.LLMProvider' catch it and return a 'ParseError'; the type of
-- 'LLM.Core.LLMProvider.sendRequest' stays unchanged.
module LLM.Core.Internal.Body
  ( ResponseTooLarge (..),
    responseTooLargeMessage,
    maxErrorBodyBytes,
    maxResponseBodyBytes,
    maxStreamEventBytes,
    readBodyPrefix,
    readErrorBody,
  )
where

import Control.Exception (Exception)
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.Text (Text)
import Data.Text qualified as T

-- | A response body or stream event exceeded the given number of bytes.
newtype ResponseTooLarge = ResponseTooLarge Int
  deriving (Show)

instance Exception ResponseTooLarge

-- | The 'ParseError' text for 'ResponseTooLarge'.
responseTooLargeMessage :: ResponseTooLarge -> Text
responseTooLargeMessage (ResponseTooLarge n) =
  "response body exceeds " <> T.pack (show n) <> " bytes"

-- | Bytes of a non-200 body kept for the 'HttpError' message.
maxErrorBodyBytes :: Int
maxErrorBodyBytes = 64 * 1024

-- | Largest 200 body a non-streaming call accepts.
maxResponseBodyBytes :: Int
maxResponseBodyBytes = 32 * 1024 * 1024

-- | Largest single stream event (one SSE event, or one line of one).
maxStreamEventBytes :: Int
maxStreamEventBytes = 8 * 1024 * 1024

-- | Read at most @cap@ bytes from a body reader (empty chunk = end). Returns
-- the bytes read and whether the body went on past @cap@. Stops reading at
-- the cap, so an endless body costs at most @cap@ plus one chunk.
readBodyPrefix :: Int -> IO ByteString -> IO (ByteString, Bool)
readBodyPrefix cap readChunk = go 0 []
  where
    go n acc = do
      chunk <- readChunk
      if BS.null chunk
        then pure (BS.concat (reverse acc), False)
        else do
          let n' = n + BS.length chunk
          if n' > cap
            then pure (BS.concat (reverse (BS.take (cap - n) chunk : acc)), True)
            else go n' (chunk : acc)

-- | Read an error body up to 'maxErrorBodyBytes', with @...@ appended when
-- it was cut.
readErrorBody :: IO ByteString -> IO ByteString
readErrorBody readChunk = do
  (bs, cut) <- readBodyPrefix maxErrorBodyBytes readChunk
  pure (if cut then bs <> "..." else bs)