packages feed

llm-simple-0.3.0.0: test/LLM/StreamingSpec.hs

{-# OPTIONS_GHC -Wno-missing-fields #-}

module LLM.StreamingSpec (spec) where

import Control.Concurrent (threadDelay)
import Control.Monad (when)
import Data.Aeson (object, (.=))
import Data.IORef (atomicModifyIORef', modifyIORef', newIORef, readIORef)
import Data.Text (Text)
import LLM.Core.Types
  ( ChatResponse (..),
    LLMError (..),
    LLMGateway (..),
    supportedImageMediaTypes,
    StreamEvent (..),
    Turn (..),
    assistantTurn,
    mkToolCall,
    pattern UserTurn,
    textPart,
    toolCallPart,
  )
import LLM.Core.Usage (PricingInfo (..), defaultPricingInfo, mkUsage)
import LLM.Generate.Generate (generateTextLLM, streamTextLLM, streamTextWithFallbacks)
import LLM.Generate.GenerateUtils (llmHooks)
import LLM.Generate.Logger (noHooks)
import LLM.Generate.ModelConfig (ModelConfig (..), ModelWithFallbacks (..), defaultModelCapabilities)
import LLM.Generate.Types
  ( GenRequest (..),
    RoundTextRole (..),
    StreamChunk (..),
  )
import Test.Hspec (Spec, describe, it, shouldBe)

spec :: Spec
spec = describe "Streaming" $ do
  describe "streamTextLLM phase routing" $ do
    it "classifies plain text as answer on finalize" $ do
      let gw =
            streamGateway
              [StreamDelta "hello"]
              (ChatResponse "hello" [textPart "hello"] (Just (mkUsage 1 1)) Nothing)
      chunks <- runStream gw []
      reverse chunks
        `shouldBe` [ TextDelta "hello",
                     RoundTextRoleCommitted AnswerRole
                   ]

    it "routes text to answer after a tool turn" $ do
      let prior = [UserTurn "q", assistantTurn "" Nothing [mkToolCall "1" "t" (object [])], ToolTurn []]
          gw =
            streamGateway
              [StreamDelta "done"]
              (ChatResponse "done" [textPart "done"] (Just (mkUsage 1 1)) Nothing)
      chunks <- runStream gw prior
      reverse chunks `shouldBe` [AnswerDelta "done"]

    it "routes preamble text and tool calls before answer" $ do
      let tc = mkToolCall "c1" "grep" (object ["pattern" .= ("x" :: Text)])
          gw =
            streamGateway
              [StreamDelta "searching", StreamToolCall tc]
              (ChatResponse "" [toolCallPart tc] (Just (mkUsage 2 0)) Nothing)
      chunks <- runStream gw [UserTurn "find x"]
      reverse chunks
        `shouldBe` [ TextDelta "searching",
                     RoundTextRoleCommitted PreambleRole,
                     StreamToolCallChunk tc
                   ]

    it "forwards reasoning deltas unchanged" $ do
      let gw =
            streamGateway
              [StreamReasoningDelta "think"]
              (ChatResponse "ok" [textPart "ok"] Nothing Nothing)
      chunks <- runStream gw []
      reverse chunks `shouldBe` [ReasoningDelta "think", RoundTextRoleCommitted AnswerRole]

  describe "request timeout" $ do
    it "applies the timeout per attempt, so a retry can succeed" $ do
      attempts <- newIORef (0 :: Int)
      let resp = ChatResponse "ok" [textPart "ok"] Nothing Nothing
          gw =
            (streamGateway [] resp)
              { gwGenerateText = \_ _ -> do
                  n <- atomicModifyIORef' attempts (\k -> (k + 1, k))
                  when (n == 0) $ threadDelay 1_000_000
                  pure (Right resp)
              }
          mc = (testModelConfig gw) {mcRequestTimeout = Just 100, mcRetryCount = 1}
      r <- generateTextLLM (testGenRequest []) mc
      fmap (.respText) r `shouldBe` Right "ok"
      readIORef attempts >>= (`shouldBe` 2)

    it "streaming: does not cut off a stream that started within the timeout" $ do
      let resp = ChatResponse "abc" [textPart "abc"] Nothing Nothing
          gw =
            (streamGateway [] resp)
              { gwStreamText = \_ _ onEvent -> do
                  onEvent (StreamDelta "a")
                  threadDelay 150_000
                  onEvent (StreamDelta "b")
                  threadDelay 150_000
                  onEvent (StreamDelta "c")
                  pure (Right resp)
              }
          mc = (testModelConfig gw) {mcRequestTimeout = Just 100}
      ref <- newIORef []
      r <- streamTextLLM (\c -> modifyIORef' ref (c :)) (testGenRequest []) mc
      fmap (.respText) r `shouldBe` Right "abc"
      chunks <- reverse <$> readIORef ref
      take 3 chunks `shouldBe` [TextDelta "a", TextDelta "b", TextDelta "c"]

    it "streaming: times out when no event arrives in time" $ do
      let resp = ChatResponse "late" [textPart "late"] Nothing Nothing
          gw =
            (streamGateway [] resp)
              { gwStreamText = \_ _ onEvent -> do
                  threadDelay 1_000_000
                  onEvent (StreamDelta "late")
                  pure (Right resp)
              }
          mc = (testModelConfig gw) {mcRequestTimeout = Just 100}
      r <- streamTextLLM (\_ -> pure ()) (testGenRequest []) mc
      fmap (.respText) r `shouldBe` Left TimeoutError

  describe "stream reset between attempts" $ do
    it "emits StreamAttemptReset before a retry when the failed attempt streamed" $ do
      gw <- flakyGateway "abc" "xyz"
      let mc = (testModelConfig gw) {mcRetryCount = 1}
      ref <- newIORef []
      r <- streamTextLLM (\c -> modifyIORef' ref (c :)) (testGenRequest []) mc
      fmap (.respText) r `shouldBe` Right "xyz"
      chunks <- reverse <$> readIORef ref
      chunks
        `shouldBe` [ TextDelta "abc",
                     StreamAttemptReset,
                     TextDelta "xyz",
                     RoundTextRoleCommitted AnswerRole
                   ]

    it "does not emit StreamAttemptReset when the failed attempt streamed nothing" $ do
      gw <- flakyGateway "" "xyz"
      let mc = (testModelConfig gw) {mcRetryCount = 1}
      ref <- newIORef []
      _ <- streamTextLLM (\c -> modifyIORef' ref (c :)) (testGenRequest []) mc
      chunks <- reverse <$> readIORef ref
      chunks `shouldBe` [TextDelta "xyz", RoundTextRoleCommitted AnswerRole]

    it "emits StreamAttemptReset before falling back to the next model" $ do
      let failing =
            (streamGateway [] (ChatResponse "" [] Nothing Nothing))
              { gwStreamText = \_ _ onEvent -> do
                  onEvent (StreamDelta "abc")
                  pure (Left (HttpError 400 "bad"))
              }
          ok = streamGateway [StreamDelta "xyz"] (ChatResponse "xyz" [textPart "xyz"] Nothing Nothing)
          models = ModelWithFallbacks (testModelConfig failing) [testModelConfig ok]
      ref <- newIORef []
      r <- streamTextWithFallbacks (\c -> modifyIORef' ref (c :)) (testGenRequest []) models
      fmap (.respText) r `shouldBe` Right "xyz"
      chunks <- reverse <$> readIORef ref
      chunks
        `shouldBe` [ TextDelta "abc",
                     StreamAttemptReset,
                     TextDelta "xyz",
                     RoundTextRoleCommitted AnswerRole
                   ]

-- | First call streams @first@ (if non-empty) then fails with a retryable
-- 503; later calls stream @second@ and succeed.
flakyGateway :: Text -> Text -> IO LLMGateway
flakyGateway first second = do
  attempts <- newIORef (0 :: Int)
  let resp = ChatResponse second [textPart second] Nothing Nothing
  pure
    (streamGateway [] resp)
      { gwStreamText = \_ _ onEvent -> do
          n <- atomicModifyIORef' attempts (\k -> (k + 1, k))
          if n == 0
            then do
              when (first /= "") $ onEvent (StreamDelta first)
              pure (Left (HttpError 503 "overloaded"))
            else do
              onEvent (StreamDelta second)
              pure (Right resp)
      }

streamGateway :: [StreamEvent] -> ChatResponse -> LLMGateway
streamGateway events resp =
  LLMGateway
    { gwName = "stream-mock",
      gwImageMediaTypes = supportedImageMediaTypes,
      gwImageUrlSupport = True,
      gwGenerateText = \_ _ -> pure (Right resp),
      gwStreamText = \_ _ onEvent -> mapM_ onEvent events >> pure (Right resp),
      gwGenerateObject = \_ _ _ -> pure (Right (object [], Nothing))
    }

runStream :: LLMGateway -> [Turn] -> IO [StreamChunk]
runStream gw turns = do
  ref <- newIORef []
  let onChunk c = modifyIORef' ref (c :)
  _ <- streamTextLLM onChunk (testGenRequest turns) (testModelConfig gw)
  readIORef ref

testGenRequest :: [Turn] -> GenRequest
testGenRequest turns =
  GenRequest
    { grSystemPrompt = Nothing,
      grMessages = turns,
      grTools = [],
      grAbortSignal = Nothing,
      grLLMHooks = llmHooks noHooks,
      grHooks = noHooks
    }

testModelConfig :: LLMGateway -> ModelConfig
testModelConfig gw =
  ModelConfig
    { mcGateway = gw,
      mcModel = "test",
      mcPricing = zeroPricing,
      mcMaxTokens = 256,
      mcTemperature = Nothing,
      mcThinking = Nothing,
      mcCapabilities = defaultModelCapabilities,
      mcRequestTimeout = Nothing,
      mcThrottleDelay = Nothing,
      mcRetryCount = 0,
      mcJitterBackoff = 0
    }

zeroPricing :: PricingInfo
zeroPricing = defaultPricingInfo 0 0