llm-simple-0.4.2.0: test/LLM/ScaleSpec.hs
-- | Large inputs that used to take quadratic time (C12, C20). Each check,
-- assertions included (results may be lazy), runs under a timeout that the
-- linear code meets with a wide margin and the quadratic code does not.
module LLM.ScaleSpec (spec) where
import Control.Monad (forM_, replicateM)
import Data.Aeson (object, (.=))
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Char8 qualified as BC
import Data.IORef (newIORef, readIORef, writeIORef)
import Data.Text qualified as T
import LLM.Agent.ToolUtils (windowOffset)
import LLM.Agent.Tools.HistoryTool (chunkBackward, findNthUserBack)
import LLM.Core.LLMProvider (LLMProvider (..))
import LLM.Core.Types
( ChatResponse (..),
LLMTextResult,
ToolCall (..),
Turn (..),
projectText,
textPart,
pattern UserTurn,
)
import LLM.Core.Utils (getToolCalls)
import LLM.Providers.Claude (claudeProvider)
import LLM.Providers.Gemini (geminiProvider)
import LLM.Providers.OpenAI (openAIProvider)
import System.Timeout (timeout)
import Test.Hspec (Spec, describe, expectationFailure, it, shouldBe)
-- | Seconds a scaled-up check may take.
limitSeconds :: Int
limitSeconds = 10
within :: IO a -> IO a
within act = do
r <- timeout (limitSeconds * 1_000_000) act
case r of
Just a -> pure a
Nothing -> expectationFailure ("took longer than " <> show limitSeconds <> " s") >> fail "timeout"
-- | A reader that returns @bytes@ in pieces of @n@ bytes.
chunkedReader :: Int -> ByteString -> IO (IO ByteString)
chunkedReader n bytes = do
ref <- newIORef bytes
pure $ do
rest <- readIORef ref
let (chunk, rest') = BS.splitAt n rest
writeIORef ref rest'
pure chunk
parseWith :: LLMProvider -> ByteString -> IO LLMTextResult
parseWith = parseInChunksOf 4096
parseInChunksOf :: Int -> LLMProvider -> ByteString -> IO LLMTextResult
parseInChunksOf n p bytes = do
reader <- chunkedReader n bytes
p.parseStream (object []) reader (\_ -> pure ())
-- | How many fragments the stream tests send, and their size.
fragments :: Int
fragments = 40_000
fragment :: ByteString
fragment = BC.replicate 100 'a'
expectedArg :: T.Text
expectedArg = T.replicate (fragments * 100) "a"
claudeEvent :: ByteString -> ByteString -> ByteString
claudeEvent name dat = "event: " <> name <> "\ndata: " <> dat <> "\n\n"
claudeStart :: ByteString
claudeStart = claudeEvent "message_start" "{\"type\":\"message_start\",\"message\":{\"model\":\"claude-test\",\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}"
claudeEnd :: ByteString
claudeEnd =
claudeEvent "message_delta" "{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}"
<> claudeEvent "message_stop" "{\"type\":\"message_stop\"}"
-- | The old paging code, kept as the reference for the rewrite.
oldChunkBackward :: Int -> [Turn] -> [[Turn]]
oldChunkBackward _ [] = []
oldChunkBackward n conv
| n <= 0 = [conv]
| otherwise = reverse (go (length conv) [])
where
go 0 acc = acc
go end acc =
let start = oldFindNthUserBack n (take end conv)
page = take (end - start) (drop start conv)
in if start >= end then page : acc else go start (page : acc)
oldFindNthUserBack :: Int -> [Turn] -> Int
oldFindNthUserBack n conv = go (length conv - 1) n
where
go idx remaining
| idx < 0 = 0
| remaining <= 0 = idx + 1
| otherwise = case conv !! idx of
UserMessage _ -> go (idx - 1) (remaining - 1)
_ -> go (idx - 1) remaining
-- | Every conversation of up to @len@ turns over user, assistant and tool
-- turns, each turn numbered so pages can be told apart.
allConversations :: Int -> [[Turn]]
allConversations len = concatMap shapes [0 .. len]
where
shapes k = map (zipWith mk [0 :: Int ..]) (replicateM k "uat")
mk i 'u' = UserTurn (T.pack (show i))
mk i 'a' = AssistantMessage [textPart (T.pack (show i))]
mk _ _ = ToolTurn []
-- | Alternating user and assistant turns.
longConversation :: Int -> [Turn]
longConversation pairs = concat [[UserTurn "q", AssistantMessage [textPart "a"]] | _ <- [1 .. pairs]]
spec :: Spec
spec = describe "Scale (linear time)" $ do
describe "SSE" $
it "reads a long line that arrives one byte at a time" $ do
let size = 1024 * 1024
body =
"data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"" <> BC.replicate size 'x' <> "\"},\"finish_reason\":null}]}\n\n"
<> "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"
within $ do
result <- parseInChunksOf 1 (openAIProvider "key") body
fmap (T.length . (.respText)) result `shouldBe` Right size
describe "stream assembly" $ do
it "Claude: joins many tool-argument and thinking fragments" $ do
let toolStart = claudeEvent "content_block_start" "{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"t1\",\"name\":\"f\",\"input\":{}}}"
jsonDelta s = claudeEvent "content_block_delta" ("{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"" <> s <> "\"}}")
stop i = claudeEvent "content_block_stop" ("{\"type\":\"content_block_stop\",\"index\":" <> i <> "}")
thinkStart = claudeEvent "content_block_start" "{\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}"
thinkDelta = claudeEvent "content_block_delta" ("{\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"" <> fragment <> "\"}}")
body =
claudeStart
<> toolStart
<> jsonDelta "{\\\"k\\\":\\\""
<> BS.concat (replicate fragments (jsonDelta fragment))
<> jsonDelta "\\\"}"
<> stop "0"
<> thinkStart
<> BS.concat (replicate fragments thinkDelta)
<> stop "1"
<> claudeEnd
within $ do
result <- parseWith (claudeProvider "key") body
case result of
Right resp -> do
map (.tcArguments) (getToolCalls resp) `shouldBe` [object ["k" .= expectedArg]]
resp.respReasoning `shouldBe` Just expectedArg
Left err -> expectationFailure (show err)
it "OpenAI: joins many tool-argument and reasoning fragments" $ do
let chunk delta = "data: {\"choices\":[{\"index\":0,\"delta\":" <> delta <> ",\"finish_reason\":null}]}\n\n"
toolDelta s = chunk ("{\"tool_calls\":[{\"index\":0,\"id\":\"t1\",\"function\":{\"name\":\"f\",\"arguments\":\"" <> s <> "\"}}]}")
reasoning = chunk ("{\"reasoning_content\":\"" <> fragment <> "\"}")
body =
BS.concat (replicate fragments reasoning)
<> toolDelta "{\\\"k\\\":\\\""
<> BS.concat (replicate fragments (toolDelta fragment))
<> toolDelta "\\\"}"
<> "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n"
within $ do
result <- parseWith (openAIProvider "key") body
case result of
Right resp -> do
map (.tcArguments) (getToolCalls resp) `shouldBe` [object ["k" .= expectedArg]]
resp.respReasoning `shouldBe` Just expectedArg
Left err -> expectationFailure (show err)
it "Gemini: collects the parts of many chunks in order" $ do
let chunk i = "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"" <> BC.pack (show i) <> ",\"}]}}]}\n\n"
body = BS.concat (map chunk [1 .. fragments]) <> "data: {\"candidates\":[{\"finishReason\":\"STOP\"}]}\n\n"
within $ do
result <- parseWith (geminiProvider "key") body
case result of
Right resp -> resp.respText `shouldBe` T.concat [T.pack (show i) <> "," | i <- [1 .. fragments]]
Left err -> expectationFailure (show err)
describe "history paging and windowing" $ do
it "chunkBackward and findNthUserBack match the old code on every short conversation" $
forM_ (allConversations 8) $ \conv ->
forM_ [0 .. 4] $ \n -> do
chunkBackward n conv `shouldBe` oldChunkBackward n conv
findNthUserBack n conv `shouldBe` oldFindNthUserBack n conv
it "pages a long hidden history" $ do
let conv = longConversation 50_000
within $ do
let pages = chunkBackward 1 conv
length pages `shouldBe` 50_000
map projectText [p | UserMessage p <- concat (take 1 pages)] `shouldBe` ["q"]
it "finds a large window in a long conversation" $ do
let conv = longConversation 50_000
within $ windowOffset (Just 25_000) conv `shouldBe` 50_000