llm-simple-0.4.2.0: src/LLM/Core/SSE.hs
module LLM.Core.SSE
( SSEEvent (..),
readSSEEvents,
malformedEvent,
streamFailure,
)
where
import Control.Applicative ((<|>))
import Control.Exception (throwIO)
import Control.Monad (unless, when)
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Char8 qualified as BC
import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import LLM.Core.Internal.Body (ResponseTooLarge (..), maxStreamEventBytes)
import LLM.Core.Types (LLMError (..))
data SSEEvent = SSEEvent
{ sseEvent :: Maybe Text,
sseData :: Text
}
deriving (Show)
-- | Read Server-Sent Events from a streaming body reader.
-- Calls the callback for each complete event. Returns when the stream ends.
--
-- An event whose name or data is not valid UTF-8 is skipped, and reading
-- continues so the caller can still tell whether the stream ended properly.
-- The result describes the first such event, if any.
--
-- An event (or a line) larger than 'maxStreamEventBytes' throws
-- 'ResponseTooLarge', which the @generic*@ functions turn into a
-- 'ParseError'.
readSSEEvents :: IO ByteString -> (SSEEvent -> IO ()) -> IO (Maybe Text)
readSSEEvents readChunk callback = do
bufRef <- newIORef BS.empty
badRef <- newIORef Nothing
let readLine :: IO (Maybe ByteString)
readLine = do
buf <- readIORef bufRef
case BC.elemIndex '\n' buf of
Just i -> do
let (line, rest) = BS.splitAt i buf
line' =
if not (BS.null line) && BC.last line == '\r'
then BS.init line
else line
writeIORef bufRef (BS.drop 1 rest)
pure (Just line')
Nothing -> do
-- Read until a chunk holds a newline (or the stream ends), then
-- concatenate once, so a line split into many chunks is copied
-- once rather than once per chunk.
(pending, ended) <- readUntilNewline [buf] (BS.length buf)
let buf' = BS.concat (reverse pending)
if ended
then
if BS.null buf'
then pure Nothing
else do
writeIORef bufRef BS.empty
pure (Just buf')
else do
writeIORef bufRef buf'
readLine
-- Chunks read so far (newest first) and whether the stream ended.
readUntilNewline :: [ByteString] -> Int -> IO ([ByteString], Bool)
readUntilNewline acc size = do
chunk <- readChunk
if BS.null chunk
then pure (acc, True)
else do
let size' = size + BS.length chunk
when (size' > maxStreamEventBytes) $
throwIO (ResponseTooLarge maxStreamEventBytes)
if BC.elem '\n' chunk
then pure (chunk : acc, False)
else readUntilNewline (chunk : acc) size'
-- dataSize: bytes of data collected for the current event.
loop :: Maybe ByteString -> [ByteString] -> Int -> IO ()
loop eventType dataLines dataSize = do
mLine <- readLine
case mLine of
Nothing ->
unless (null dataLines) $
fireEvent eventType dataLines
Just line
| BS.null line -> do
unless (null dataLines) $
fireEvent eventType dataLines
loop Nothing [] 0
| "data:" `BS.isPrefixOf` line -> do
let d = stripField 5 line
dataSize' = dataSize + BS.length d + 1
when (dataSize' > maxStreamEventBytes) $
throwIO (ResponseTooLarge maxStreamEventBytes)
loop eventType (d : dataLines) dataSize'
| "event:" `BS.isPrefixOf` line ->
loop (Just (stripField 6 line)) dataLines dataSize
| ":" `BS.isPrefixOf` line ->
loop eventType dataLines dataSize -- comment, skip
| otherwise ->
loop eventType dataLines dataSize
stripField :: Int -> ByteString -> ByteString
stripField n bs =
let rest = BS.drop n bs
in if not (BS.null rest) && BC.head rest == ' '
then BS.tail rest
else rest
fireEvent :: Maybe ByteString -> [ByteString] -> IO ()
fireEvent eventType dataLines =
case (traverse TE.decodeUtf8' eventType, TE.decodeUtf8' (BS.intercalate "\n" (reverse dataLines))) of
(Right name, Right dat) -> callback SSEEvent {sseEvent = name, sseData = dat}
(Left err, _) -> invalid err
(_, Left err) -> invalid err
where
invalid err =
modifyIORef' badRef (<|> Just ("stream event is not valid UTF-8: " <> T.pack (show err)))
loop Nothing [] 0
readIORef badRef
-- | Message for a data event that should be JSON but is not, with a short
-- excerpt of the data.
malformedEvent :: Text -> Text
malformedEvent raw =
"stream event is not valid JSON: " <> excerpt
where
excerpt
| T.length raw > 200 = T.take 200 raw <> "..."
| otherwise = raw
-- | Stream-level failure of a parsed stream, given the name of the
-- protocol's end-of-stream marker, whether it was seen, and the first
-- malformed event (if any).
--
-- Parsers check this after in-band provider errors and before their own
-- content checks. A missing marker is a retryable 'NetworkError': the
-- connection closed early, and a malformed final event is usually just the
-- cut-off tail of that.
streamFailure :: Text -> Bool -> Maybe Text -> Maybe LLMError
streamFailure marker complete malformed
| not complete = Just (NetworkError ("stream ended before " <> marker))
| Just msg <- malformed = Just (ParseError msg)
| otherwise = Nothing