packages feed

langchain-hs-0.0.5.0: test/Test/Langchain/Cache/CacheSpec.hs

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}

module Test.Langchain.Cache.CacheSpec (tests) where

import Control.Concurrent.STM (newTVarIO)
import Control.Monad.Except (runExceptT)
import Data.Aeson (Value, object, (.=))
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import Test.Tasty
import Test.Tasty.HUnit

import Langchain.Cache.Core
import Langchain.Core.Model
  ( ChatModel (..)
  , ContentBlock (..)
  , ImageContent (..)
  , ImageSource (..)
  , Message (..)
  , Role (..)
  , ToolCall (..)
  , assistantMessage
  , extractMessageText
  , userMessage
  )
import Langchain.Provider.Gemini (Gemini (..))
import Langchain.Provider.Ollama (Ollama, newOllamaWithClient)
import Langchain.Provider.OpenAI (OpenAI (OpenAI))
import qualified Ollama.API.Chat as OllamaChat
import Ollama.Client (newClient)
import qualified Ollama.Client.Config as OllamaClientConfig
import Ollama.Types.Common (ModelName (..), Think (..))
import Ollama.Types.Format (Format (..))
import qualified Ollama.Types.Message as OllamaMessage
import Ollama.Types.Options (ModelOptions (..), defaultOptions)
import Ollama.Types.Tool (FunctionDef (..))
import qualified Ollama.Types.Tool as OllamaTool
import Test.Langchain.Provider.Mock (MockModel (..), newMockModel)

testMessages :: [Message]
testMessages = [userMessage "Describe the image"]

baseOllamaRequest :: OllamaChat.ChatRequest
baseOllamaRequest =
  OllamaChat.chatRequest
    (ModelName "llama3.2")
    (OllamaMessage.userMessage "ignored-message" :| [])

newOllamaForEndpoint :: Text -> IO Ollama
newOllamaForEndpoint endpoint = do
  ollamaClient <-
    newClient $
      OllamaClientConfig.defaultConfig
        { OllamaClientConfig.configBaseUrl = endpoint
        }
  pure $ newOllamaWithClient "llama3.2" ollamaClient

assertKeysDiffer :: Text -> Text -> Assertion
assertKeysDiffer first second =
  assertBool "Expected cache keys to differ" (first /= second)

tests :: TestTree
tests =
  testGroup
    "Langchain.Cache.CacheSpec"
    [ testCase "InMemoryCache stores and retrieves cached message" $ do
        cache <- newInMemoryCache
        let msg = assistantMessage "Cached result"
        putCache cache "key1" msg
        res <- getCache cache "key1"
        res @?= Just msg
        clearCache cache
        resAfter <- getCache cache "key1"
        resAfter @?= Nothing
    , testCase "SQLiteCache stores and persists message across queries" $ do
        withSystemTempDirectory "sqlite-cache-test" $ \tmpDir -> do
          let dbPath = tmpDir </> "cache.db"
          cache <- newSQLiteCache dbPath
          let msg = assistantMessage "SQLite Cached"
          putCache cache "keyA" msg
          res <- getCache cache "keyA"
          res @?= Just msg
    , testCase "CachedModel caches response and returns cached on second call" $ do
        _ <- newTVarIO (0 :: Int)
        let mockModel = newMockModel "Dynamic Output"
        cache <- newInMemoryCache
        let cachedModel = withCaching mockModel cache
            msgs = [userMessage "Compute 2+2"]
        res1 <- runExceptT $ invoke cachedModel msgs Nothing
        res2 <- runExceptT $ invoke cachedModel msgs Nothing
        case (res1, res2) of
          (Right m1, Right m2) -> do
            extractMessageText m1 @?= "Dynamic Output"
            extractMessageText m2 @?= "Dynamic Output"
          _ -> assertFailure "Expected successful CachedModel invocations"
    , testCase "cache key is stable for identical inputs" $ do
        let mockModel = newMockModel "Dynamic Output"
        computeCacheKey mockModel Nothing testMessages
          @?= computeCacheKey mockModel Nothing testMessages
    , testCase "cache key distinguishes complete message content" $ do
        let mockModel = newMockModel "Dynamic Output"
            imageMessage =
              Message
                User
                ( TextBlock "Describe the image"
                    :| [ImageBlock $ ImageContent (ImageUrl "https://example.com/image.png") Nothing Nothing]
                )
                Nothing
                Nothing
                Nothing
                Map.empty
            toolMessage =
              (userMessage "Describe the image")
                { messageToolCalls = Just [ToolCall "call-1" "function" "describe_image" (object [])]
                }
            baseKey = computeCacheKey mockModel Nothing testMessages
        assertKeysDiffer baseKey $ computeCacheKey mockModel Nothing [imageMessage]
        assertKeysDiffer baseKey $ computeCacheKey mockModel Nothing [toolMessage]
    , testCase "cache key distinguishes mock model identity" $ do
        let first = newMockModel "first response"
            second = MockModel "first response" "other-mock"
        assertKeysDiffer
          (computeCacheKey first Nothing testMessages)
          (computeCacheKey second Nothing testMessages)
    , testCase "cache key distinguishes OpenAI identity and ignores its config" $ do
        let base = OpenAI "key" "gpt-4o" "https://api.openai.com/v1/chat/completions" (Just 0.7)
            otherModel = OpenAI "key" "gpt-4.1" "https://api.openai.com/v1/chat/completions" (Just 0.7)
            otherEndpoint = OpenAI "key" "gpt-4o" "https://example.com/v1/chat/completions" (Just 0.7)
            otherTemperature = OpenAI "key" "gpt-4o" "https://api.openai.com/v1/chat/completions" (Just 0.2)
            baseKey = computeCacheKey base Nothing testMessages
        assertKeysDiffer baseKey $ computeCacheKey otherModel Nothing testMessages
        assertKeysDiffer baseKey $ computeCacheKey otherEndpoint Nothing testMessages
        assertKeysDiffer baseKey $ computeCacheKey otherTemperature Nothing testMessages
        baseKey @?= computeCacheKey base (Just $ object ["unused" .= True]) testMessages
    , testCase "cache key distinguishes Gemini identity and request config" $ do
        let base = Gemini "key" "gemini-2.0-flash" Nothing
            otherModel = Gemini "key" "gemini-2.5-pro" Nothing
            baseKey = computeCacheKey base Nothing testMessages
        assertKeysDiffer baseKey $ computeCacheKey otherModel Nothing testMessages
        assertKeysDiffer baseKey $
          computeCacheKey base (Just $ object ["tools" .= ([] :: [Value])]) testMessages
    , testCase "cache key distinguishes Gemini custom endpoints" $ do
        let defaultEndpoint = Gemini "key" "gemini-2.0-flash" Nothing
            url1 = Just "http://gemini-one.example.com"
            url2 = Just "http://gemini-two.example.com"
            firstEndpoint = Gemini "key" "gemini-2.0-flash" url1
            sameEndpoint = Gemini "key" "gemini-2.0-flash" url1
            secondEndpoint = Gemini "key" "gemini-2.0-flash" url2
            defaultKey = computeCacheKey defaultEndpoint Nothing testMessages
            firstKey = computeCacheKey firstEndpoint Nothing testMessages
        assertKeysDiffer defaultKey firstKey
        firstKey @?= computeCacheKey sameEndpoint Nothing testMessages
        assertKeysDiffer firstKey $ computeCacheKey secondEndpoint Nothing testMessages
    , testCase "cache key ignores MockModel config" $ do
        let mockModel = newMockModel "Dynamic Output"
        computeCacheKey mockModel Nothing testMessages
          @?= computeCacheKey mockModel (Just ()) testMessages
    , testCase "cache key distinguishes Ollama endpoints and effective config" $ do
        firstEndpoint <- newOllamaForEndpoint "http://ollama-one.example.com:11434"
        secondEndpoint <- newOllamaForEndpoint "http://ollama-two.example.com:11434"
        let baseKey = computeCacheKey firstEndpoint (Just baseOllamaRequest) testMessages
            ignoredFieldsRequest =
              baseOllamaRequest
                { OllamaChat.chatMessages = OllamaMessage.userMessage "another-ignored-message" :| []
                , OllamaChat.chStream = Just True
                }
            requestsThatChangeOutput =
              [ baseOllamaRequest {OllamaChat.chatModel = ModelName "different-model"}
              , baseOllamaRequest
                  { OllamaChat.chatTools =
                      Just [OllamaTool.Tool "function" (FunctionDef "get_weather" Nothing Nothing Nothing)]
                  }
              , baseOllamaRequest {OllamaChat.chatFormat = Just JsonFormat}
              , baseOllamaRequest {OllamaChat.chatOptions = Just defaultOptions {optTemperature = Just 0.2}}
              , baseOllamaRequest {OllamaChat.chatKeepAlive = Just "10m"}
              , baseOllamaRequest {OllamaChat.chatThink = Just ThinkEnabled}
              ]
        assertKeysDiffer baseKey $ computeCacheKey secondEndpoint (Just baseOllamaRequest) testMessages
        baseKey @?= computeCacheKey firstEndpoint Nothing testMessages
        baseKey @?= computeCacheKey firstEndpoint (Just ignoredFieldsRequest) testMessages
        mapM_
          (\request -> assertKeysDiffer baseKey $ computeCacheKey firstEndpoint (Just request) testMessages)
          requestsThatChangeOutput
    ]