packages feed

llm-simple-0.4.2.0: test/LLM/HttpConfigSpec.hs

module LLM.HttpConfigSpec (spec) where

import Control.Concurrent (forkIO, killThread, threadDelay)
import Control.Exception (SomeException, bracket, finally, try)
import Control.Monad (forever, void)
import Data.Aeson (Value, object)
import Data.ByteString qualified as BS
import Data.IORef (atomicModifyIORef', newIORef, readIORef)
import Data.Text qualified as T
import System.Timeout (timeout)
import LLM.Core.LLMProvider (genericGenerateText, genericStreamText)
import LLM.Core.ProviderUtils (mkLenientConfig)
import LLM.Core.Types (ChatRequest (..), LLMError (..), pattern UserTurn)
import LLM.Generate.GenerateUtils (llmHooks)
import LLM.Generate.Logger (noHooks)
import LLM.Providers.Ollama (ollamaProviderWith)
import Network.HTTP.Client qualified as HC
import Network.HTTP.Req (POST (..), ReqBodyJson (..), http, ignoreResponse, port, req, runReq, (/:))
import Network.Socket qualified as N
import Network.Socket.ByteString qualified as NB
import Test.Hspec (Spec, describe, expectationFailure, it, shouldBe, shouldSatisfy)

spec :: Spec
spec = describe "HTTP config" $ do
  it "sends a POST exactly once when the response times out" $ do
    posts <- newIORef (0 :: Int)
    -- Answer each request after 2 s; the client gives up after 100 ms.
    withSlowServer 2_000_000 (atomicModifyIORef' posts (\n -> (n + 1, ()))) $ \p -> do
      mgr <- HC.newManager HC.defaultManagerSettings {HC.managerResponseTimeout = HC.responseTimeoutMicro 100_000}
      r <-
        try $
          runReq (mkLenientConfig mgr) $
            req POST (http "127.0.0.1" /: "v1") (ReqBodyJson (object [] :: Value)) ignoreResponse (port p)
      case r of
        Left (_ :: SomeException) -> pure ()
        Right _ -> fail "expected the request to time out"
      readIORef posts >>= (`shouldBe` 1)

  describe "response body limits" $ do
    it "cuts an endless error body and returns HttpError" $
      withEndlessServer "500 Internal Server Error" "" $ \p -> do
        r <- within $ genericGenerateText (ollamaProviderWith (http "127.0.0.1") (port p)) (llmHooks noHooks) request
        case r of
          Left (HttpError 500 body) -> do
            T.length body `shouldSatisfy` (<= 64 * 1024 + 3)
            T.takeEnd 3 body `shouldBe` "..."
          other -> expectationFailure ("expected HttpError 500, got " <> take 200 (show other))

    it "cuts an endless error body of a stream and returns HttpError" $
      withEndlessServer "503 Service Unavailable" "" $ \p -> do
        r <- within $ genericStreamText (ollamaProviderWith (http "127.0.0.1") (port p)) (llmHooks noHooks) request (const (pure ()))
        case r of
          Left (HttpError 503 body) -> T.length body `shouldSatisfy` (<= 64 * 1024 + 3)
          other -> expectationFailure ("expected HttpError 503, got " <> take 200 (show other))

    it "turns an endless 200 body into a ParseError" $
      withEndlessServer "200 OK" "" $ \p -> do
        r <- within $ genericGenerateText (ollamaProviderWith (http "127.0.0.1") (port p)) (llmHooks noHooks) request
        case r of
          Left (ParseError msg) -> T.unpack msg `shouldBe` "response body exceeds 33554432 bytes"
          other -> expectationFailure ("expected ParseError, got " <> take 200 (show other))

    it "turns an endless stream event into a ParseError" $
      withEndlessServer "200 OK" "data: " $ \p -> do
        r <- within $ genericStreamText (ollamaProviderWith (http "127.0.0.1") (port p)) (llmHooks noHooks) request (const (pure ()))
        case r of
          Left (ParseError msg) -> T.unpack msg `shouldBe` "response body exceeds 8388608 bytes"
          other -> expectationFailure ("expected ParseError, got " <> take 200 (show other))

-- | Fail instead of hanging when a body is read without a bound.
within :: IO a -> IO a
within act = timeout 20_000_000 act >>= maybe (fail "timed out: the body was read without a bound") pure

request :: ChatRequest
request =
  ChatRequest
    { reqModel = "test",
      reqConversation = [UserTurn "hi"],
      reqSystem = Nothing,
      reqTools = [],
      reqMaxTokens = 10,
      reqTemperature = Nothing,
      reqThinking = Nothing
    }

-- | A TCP server on a free local port that answers every POST with the
-- given status line, then the prefix, then @x@ bytes until the client
-- hangs up.
withEndlessServer :: BS.ByteString -> BS.ByteString -> (Int -> IO a) -> IO a
withEndlessServer status prefix action =
  bracket open N.close $ \sock -> do
    p <- fromIntegral <$> N.socketPort sock
    tid <- forkIO $ forever $ do
      (conn, _) <- N.accept sock
      void $ forkIO $ flip finally (N.close conn) $ do
        chunk <- NB.recv conn 4096
        if "POST" `BS.isPrefixOf` chunk
          then do
            NB.sendAll conn ("HTTP/1.1 " <> status <> "\r\nContent-Length: 1000000000000\r\n\r\n" <> prefix)
            -- Stops when the client closes the connection and a send fails.
            void (try @SomeException (forever (NB.sendAll conn (BS.replicate 65536 0x78))))
          else pure ()
    action p `finally` killThread tid
  where
    open = do
      sock <- N.socket N.AF_INET N.Stream N.defaultProtocol
      N.bind sock (N.SockAddrInet 0 (N.tupleToHostAddress (127, 0, 0, 1)))
      N.listen sock 16
      pure sock

-- | A TCP server on a free local port that counts each request it receives
-- and answers it only after the given delay.
withSlowServer :: Int -> IO () -> (Int -> IO a) -> IO a
withSlowServer delay onRequest action =
  bracket open N.close $ \sock -> do
    p <- fromIntegral <$> N.socketPort sock
    tid <- forkIO $ forever $ do
      (conn, _) <- N.accept sock
      void $ forkIO $ flip finally (N.close conn) $ do
        chunk <- NB.recv conn 4096
        if "POST" `BS.isPrefixOf` chunk
          then do
            onRequest
            threadDelay delay
            NB.sendAll conn "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"
          else pure ()
    action p `finally` killThread tid
  where
    open = do
      sock <- N.socket N.AF_INET N.Stream N.defaultProtocol
      N.bind sock (N.SockAddrInet 0 (N.tupleToHostAddress (127, 0, 0, 1)))
      N.listen sock 16
      pure sock