packages feed

llm-simple-0.1.0.1: 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, decodeFileStrict)
import Data.Aeson.Encode.Pretty (encodePretty)
import Data.Aeson.Types (parseMaybe)
import Data.ByteString.Lazy qualified as BSL
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Map qualified as M
import Data.Maybe (isJust)
import Data.Text (Text)
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 (..))
import LLM.Core.Types (LLMGateway, ThinkingMode (..), Turn (..))
import LLM.Core.Usage (PricingInfo (..), addUsage, emptyUsage)
import LLM.Core.Utils (parseChatResponse)
import LLM.Generate.GenerateUtils (llmHooks)
import LLM.Generate.Logger (Hooks (..), noHooks)
import LLM.Generate.ModelConfig (ModelConfig (..), ModelWithFallbacks (..))
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,
    response :: Value
  }
  deriving (Show, Generic)
  deriving anyclass (FromJSON, ToJSON)

type MockConversation = [MockRequestResponse]

type MockConversationMap = M.Map Value Value

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.response)) 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)

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 r -> pure (200, r),
      sendStreamRequest = \body _callback -> do
        case M.lookup body mp of
          Nothing -> error ("value not found for:" <> show body)
          Just r -> case parseMaybe parseChatResponse r of
            Nothing -> error "can't parse recorded ChatResponse json"
            Just chatResponse -> pure $ Right chatResponse
    }

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 [MockRequestResponse] ->
  IO (Agent, ModelWithFallbacks, ToolMap Text, RuntimeArgs)
mkRecordedConversationRuntime gateway modelName thinking promptRef pendingRef entriesRef = do
  genId <- generate
  let hooks = recordingHooks promptRef pendingRef entriesRef
      agent =
        Agent
          { agName = "test",
            agSystemPrompt = Just recordedConversationSystemPrompt,
            agTools = ["get_weather"],
            agMaxToolRounds = 3,
            agContextWindow = Nothing
          }
      modelConf =
        ModelConfig
          { mcGateway = gateway,
            mcModel = modelName,
            mcPricing = PricingInfo {pricePerMillionInput = 0.0, pricePerMillionOutput = 0.0},
            mcMaxTokens = 1024,
            mcTemperature = Nothing,
            mcThinking = 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 -> LLMGateway -> Text -> Maybe ThinkingMode -> FilePath -> IO MockConversation
recordConversation stream gateway modelName thinking outPath = do
  promptRef <- newIORef Nothing
  pendingRef <- newIORef Nothing
  entriesRef <- newIORef []
  env <- mkRecordedConversationRuntime gateway modelName thinking promptRef pendingRef 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
        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

recordingHooks ::
  IORef (Maybe Text) ->
  IORef (Maybe Value) ->
  IORef [MockRequestResponse] ->
  Hooks
recordingHooks promptRef pendingRef entriesRef =
  noHooks
    { onRequest = \_ req -> writeIORef pendingRef (Just req),
      onResponse = \_ resp -> do
        mReq <- readIORef pendingRef
        mPrompt <- readIORef promptRef
        case mReq of
          Nothing -> pure ()
          Just req -> do
            let entry = MockRequestResponse mPrompt req resp
            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)