llm-simple-0.3.0.0: test/LLM/TestKit.hs
module LLM.TestKit
( MockRequestResponse (..),
MockConversation,
MockConversationMap,
recordedConversationPrompts,
recordedConversationSystemPrompt,
readMockRequestResponse,
loadRecordedConversation,
mockProvider,
streamChatLoop,
streamChatLoopMain,
recordConversation,
writeRecordedConversation,
mkRecordedConversationRuntime,
recordedDeepSeekThinking,
)
where
import Data.Aeson (FromJSON, ToJSON, Value (Array, Object, String), decodeFileStrict)
import Data.Aeson.KeyMap qualified as KM
import Data.Aeson.Encode.Pretty (encodePretty)
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BSL
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Map qualified as M
import Data.Maybe (fromMaybe, isJust)
import Data.Text (Text)
import Data.Text.Encoding qualified as TE
import Data.Text.IO qualified as TIO
import GHC.Generics (Generic)
import Heptapod (generate)
import LLM.Agent.Events (noEventObserver)
import LLM.Agent.Generate (generateText, streamText)
import LLM.Agent.ToolUtils (toTool)
import LLM.Agent.Types (Agent (..), RuntimeArgs (..), ToolMap)
import LLM.Core.LLMProvider (LLMProvider (..), toGateway)
import LLM.Core.Types (LLMGateway, ThinkingMode (..), Turn (..), pattern UserTurn)
import LLM.Core.Usage (addUsage, defaultPricingInfo, emptyUsage)
import LLM.Core.Utils (streamResponseJson)
import LLM.Generate.GenerateUtils (llmHooks)
import LLM.Generate.Logger (Hooks (..), noHooks)
import LLM.Generate.ModelConfig
( ModelCapabilities (..),
ModelConfig (..),
ModelWithFallbacks (..),
defaultModelCapabilities,
)
import LLM.Generate.Types (GenerateErrorResult (..), GenerateTextResult (..))
import LLM.WeatherTool (weatherToolTyped)
import System.Directory (createDirectoryIfMissing)
import System.FilePath (takeDirectory)
data MockRequestResponse = MockRequestResponse
{ prompt :: Maybe Text,
request :: Value,
-- | Raw provider JSON for non-streaming calls; for streaming calls the
-- library's own encoding of the parsed response ('streamResponseJson').
response :: Value,
-- | Raw streaming body as received from the provider (streaming calls only).
sse :: Maybe Text
}
deriving (Show, Generic)
deriving anyclass (FromJSON, ToJSON)
type MockConversation = [MockRequestResponse]
type MockConversationMap = M.Map Value MockRequestResponse
recordedConversationPrompts :: [Text]
recordedConversationPrompts =
[ "how's the weather in london?",
"and in paris?"
]
recordedConversationSystemPrompt :: Text
recordedConversationSystemPrompt =
"You are a helpful assistant who answers questions and executes tools for the user. Always use tools when asked to, but use only the tools that are available."
recordedDeepSeekThinking :: ThinkingMode
recordedDeepSeekThinking = ThinkingMode {tmEnabled = True, tmEffort = Nothing}
readMockRequestResponse :: FilePath -> IO (Maybe MockConversation)
readMockRequestResponse = decodeFileStrict
loadRecordedConversation :: FilePath -> IO (MockConversationMap, [Text])
loadRecordedConversation filePath = do
s <- readMockRequestResponse filePath
case s of
Nothing -> error "can't read conversation"
Just rrs ->
let pairs = map (\rr -> (rr.request, rr)) rrs
rrMap = M.fromList pairs
prompts = rrs >>= \rsp -> case rsp.prompt of Nothing -> []; Just p -> [p]
in pure (rrMap, prompts)
writeRecordedConversation :: FilePath -> MockConversation -> IO ()
writeRecordedConversation filePath conversation = do
createDirectoryIfMissing True (takeDirectory filePath)
BSL.writeFile filePath (encodePretty conversation)
-- | Replay a recorded conversation. Requests must match a recording exactly.
-- Streaming bodies are fed through the provider's real 'parseStream', and the
-- parsed result must match the response recorded alongside them (ignoring
-- tool-call ids; see 'withoutToolCallIds').
mockProvider :: MockConversationMap -> LLMProvider -> LLMProvider
mockProvider mp adapter =
adapter
{ sendRequest = \val -> do
case M.lookup val mp of
Nothing ->
error (show val <> "\n" <> show (fst $ head $ M.toList mp))
Just rr -> pure (200, rr.response),
openStream = \body k -> do
case M.lookup body mp of
Nothing -> error ("value not found for:" <> show body)
Just rr -> case rr.sse of
Nothing -> error "recorded streaming call has no raw SSE body; re-record the fixture"
Just raw -> do
result <- k =<< bytesReader (TE.encodeUtf8 raw)
case result of
Right resp
| withoutToolCallIds (streamResponseJson resp) /= withoutToolCallIds rr.response ->
error $
"replayed stream parsed differently than recorded:\n"
<> show (streamResponseJson resp)
<> "\nexpected:\n"
<> show rr.response
_ -> pure result
}
-- | Gemini has no wire-level tool-call ids; the library mints them from a
-- process-global counter, so they differ between recording and replay. Where
-- ids do come from the wire, the next request's tool results still pin them.
withoutToolCallIds :: Value -> Value
withoutToolCallIds = \case
Object o -> Object (fmap stripParts (KM.filterWithKey (\k _ -> k == "content") o) <> o)
v -> v
where
stripParts (Array parts) = Array (fmap stripId parts)
stripParts v = v
stripId (Object p)
| KM.lookup "type" p == Just (String "tool_call") = Object (KM.delete "id" p)
stripId v = v
-- | Yield the whole payload once, then EOF.
bytesReader :: BS.ByteString -> IO (IO BS.ByteString)
bytesReader bs = do
ref <- newIORef (Just bs)
pure $ do
m <- readIORef ref
writeIORef ref Nothing
pure (fromMaybe BS.empty m)
-- | Wrap a provider so every streaming body it reads is appended to @ref@.
teeStream :: IORef [BS.ByteString] -> LLMProvider -> LLMProvider
teeStream ref p =
p
{ openStream = \body k ->
p.openStream body $ \readChunk ->
k $ do
chunk <- readChunk
modifyIORef' ref (chunk :)
pure chunk
}
streamChatLoopMain :: Bool -> (Agent, ModelWithFallbacks, ToolMap Text, RuntimeArgs) -> [Text] -> IO ()
streamChatLoopMain stream env prompts = do
putStrLn "\n=== recorded conversation replay ==="
_ <- streamChatLoop stream env prompts
pure ()
streamChatLoop :: Bool -> (Agent, ModelWithFallbacks, ToolMap Text, RuntimeArgs) -> [Text] -> IO [Turn]
streamChatLoop stream (agent, models, toolMap, rt) = aux emptyUsage []
where
aux _totalUsage conv [] = pure conv
aux totalUsage conv (prompt : rest) = do
let conv' = conv ++ [UserTurn prompt]
result <-
if stream
then streamText (const (pure ())) agent models toolMap rt conv'
else generateText agent models toolMap rt conv'
case result of
Left (GenerateErrorResult {gerError = err}) -> do
print err
pure conv
Right (GenerateTextResult {gtrUsage = usage, gtrNewMessages = newMessages}) -> do
let conv'' = conv' ++ newMessages
aux (addUsage totalUsage usage) conv'' rest
mkRecordedConversationRuntime ::
LLMGateway ->
Text ->
Maybe ThinkingMode ->
IORef (Maybe Text) ->
IORef (Maybe Value) ->
IORef [BS.ByteString] ->
IORef [MockRequestResponse] ->
IO (Agent, ModelWithFallbacks, ToolMap Text, RuntimeArgs)
mkRecordedConversationRuntime gateway modelName thinking promptRef pendingRef sseRef entriesRef = do
genId <- generate
let hooks = recordingHooks promptRef pendingRef sseRef entriesRef
agent =
Agent
{ agName = "test",
agSystemPrompt = Just recordedConversationSystemPrompt,
agTools = ["get_weather"],
agMaxToolRounds = 3,
agContextWindow = Nothing
}
modelConf =
ModelConfig
{ mcGateway = gateway,
mcModel = modelName,
mcPricing = defaultPricingInfo 0.0 0.0,
mcMaxTokens = 1024,
mcTemperature = Nothing,
mcThinking = thinking,
mcCapabilities =
defaultModelCapabilities
{ capThinking = maybe False (.tmEnabled) thinking
},
mcRequestTimeout = Nothing,
mcThrottleDelay = Nothing,
mcRetryCount = 3,
mcJitterBackoff = 1_000
}
models = ModelWithFallbacks modelConf []
toolMap = M.fromList [("get_weather", toTool weatherToolTyped)]
rt =
RuntimeArgs
{ rtGenerationId = genId,
rtAbortSignal = Nothing,
rtLLMHooks = llmHooks hooks,
rtHooks = hooks,
rtOnEvent = noEventObserver,
rtReadonly = False
}
pure (agent, models, toolMap, rt)
recordConversation :: Bool -> LLMProvider -> Text -> Maybe ThinkingMode -> FilePath -> IO MockConversation
recordConversation stream provider modelName thinking outPath = do
promptRef <- newIORef Nothing
pendingRef <- newIORef Nothing
sseRef <- newIORef []
entriesRef <- newIORef []
let gateway = toGateway (teeStream sseRef provider)
env <- mkRecordedConversationRuntime gateway modelName thinking promptRef pendingRef sseRef entriesRef
turns <- recordStreamChatLoop stream promptRef env recordedConversationPrompts
entries <- readIORef entriesRef
let conversation = reverse entries
writeRecordedConversation outPath conversation
putStrLn $
unlines
[ "Wrote " <> outPath,
" entries: " <> show (length conversation),
" turns: " <> show (length turns)
]
pure conversation
recordStreamChatLoop ::
Bool ->
IORef (Maybe Text) ->
(Agent, ModelWithFallbacks, ToolMap Text, RuntimeArgs) ->
[Text] ->
IO [Turn]
recordStreamChatLoop stream promptRef (agent, models, toolMap, rt) = aux emptyUsage []
where
aux _totalUsage conv [] = pure conv
aux totalUsage conv (prompt : rest) = do
writeIORef promptRef (Just prompt)
TIO.putStrLn $ "> " <> prompt
let conv' = conv ++ [UserTurn prompt]
result <-
if stream
then streamText (const (pure ())) agent models toolMap rt conv'
else generateText agent models toolMap rt conv'
case result of
-- Fail before anything is written, so a broken run never replaces a good fixture.
Left (GenerateErrorResult {gerError = err}) ->
fail ("recording failed, fixture not written: " <> show err)
Right (GenerateTextResult {gtrUsage = usage, gtrNewMessages = newMessages}) -> do
let conv'' = conv' ++ newMessages
aux (addUsage totalUsage usage) conv'' rest
recordingHooks ::
IORef (Maybe Text) ->
IORef (Maybe Value) ->
IORef [BS.ByteString] ->
IORef [MockRequestResponse] ->
Hooks
recordingHooks promptRef pendingRef sseRef entriesRef =
noHooks
{ onRequest = \_ req -> do
writeIORef pendingRef (Just req)
writeIORef sseRef [],
onResponse = \_ resp -> do
mReq <- readIORef pendingRef
mPrompt <- readIORef promptRef
chunks <- readIORef sseRef
case mReq of
Nothing -> pure ()
Just req -> do
let raw = BS.concat (reverse chunks)
mSse = if BS.null raw then Nothing else Just (TE.decodeUtf8 raw)
entry = MockRequestResponse mPrompt req resp mSse
entriesRef `modifyIORef'` (entry :)
writeIORef pendingRef Nothing
whenJustPrompt mPrompt $ writeIORef promptRef Nothing
}
where
whenJustPrompt :: Maybe Text -> IO () -> IO ()
whenJustPrompt mp act = if isJust mp then act else pure ()
modifyIORef' :: IORef a -> (a -> a) -> IO ()
modifyIORef' ref f = do
v <- readIORef ref
writeIORef ref (f v)