packages feed

langchain-hs-gemini-0.0.6.0: test/Test/Langchain/Provider/GeminiSpec.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -Wno-unused-matches #-}

module Test.Langchain.Provider.GeminiSpec (tests) where

import Control.Monad.Except (runExceptT)
import Control.Monad.Trans.Resource (runResourceT)
import Data.Aeson (encode)
import Data.Aeson.QQ (aesonQQ)
import qualified Data.Map.Strict as Map
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import qualified Data.Text.Lazy as TL
import Network.HTTP.Types (status200, status500)
import Network.Wai (Application, responseLBS, responseStream)
import Network.Wai.Handler.Warp (testWithApplication)
import System.Directory (doesFileExist, getHomeDirectory)
import System.Environment (lookupEnv)
import System.FilePath ((</>))
import Test.Tasty
import Test.Tasty.HUnit

import qualified Data.ByteString.Builder as Builder
import Gemini.Client (InteractionsInput (..))
import Langchain.Cache.Core (CacheableChatModel (..))
import Langchain.DocumentLoader.Core (Document (..))
import Langchain.Embeddings.Core (Embeddings (..))
import Langchain.Embeddings.Gemini (GeminiEmbeddings (..), defaultGeminiEmbeddings)
import Langchain.Model
import Langchain.Provider.Gemini
import Langchain.Stream (StreamEvent (..), collectEvents)
import Langchain.Tool (Tool (..), createTool)
import Langchain.Tool.Binding (ToolBinder (..))

withMockServer :: Application -> (T.Text -> IO a) -> IO a
withMockServer app action =
  testWithApplication (pure app) $ \port ->
    action $ "http://127.0.0.1:" <> T.pack (show port)

getLiveApiKey :: IO (Maybe T.Text)
getLiveApiKey = do
  mbEnv <- lookupEnv "GEMINI_API_KEY"
  case mbEnv of
    Just k | not (null k) -> pure (Just (T.pack k))
    _ -> do
      home <- getHomeDirectory
      let p = home </> ".gemini_api_key"
      exists <- doesFileExist p
      if exists
        then do
          txt <- T.strip <$> TIO.readFile p
          pure $ if T.null txt then Nothing else Just txt
        else pure Nothing

tests :: TestTree
tests =
  testGroup
    "Langchain.Provider.GeminiSpec"
    [ testGroup
        "Provider Initialization and Config"
        [ testCase "newGemini initializes correctly" $ do
            let p = newGemini "my-api-key" "gemini-3.6-flash" (Just "http://custom")
            apiKey p @?= "my-api-key"
            model p @?= "gemini-3.6-flash"
            baseUrl p @?= Just "http://custom"
        , testCase "defaultGeminiConfig has empty fields" $ do
            let cfg = defaultGeminiConfig
            configTools cfg @?= Nothing
            configSystemInstruction cfg @?= Nothing
        , testCase "cacheModelIdentity returns correct identity" $ do
            let p = newGemini "key" "gemini-3.6-flash" Nothing
                identity = cacheModelIdentity p (Just defaultGeminiConfig)
            identity @?= [aesonQQ|{"provider": "gemini", "model": "gemini-3.6-flash"}|]
        ]
    , testGroup
        "Message to InteractionsInput mapping"
        [ testCase "single user message maps to TextInput" $ do
            let msgs = [userMessage "Hello world"]
                (mbSys, input) = messagesToInteractionsInput msgs
            mbSys @?= Nothing
            case input of
              TextInput t -> t @?= "Hello world"
              other -> assertFailure ("Expected TextInput, got: " ++ show other)
        , testCase "system and user messages extract system instruction" $ do
            let msgs = [systemMessage "Be brief", userMessage "Hello"]
                (mbSys, _input) = messagesToInteractionsInput msgs
            mbSys @?= Just "Be brief"
        ]
    , testGroup
        "ChatModel invoke (Mock)"
        [ testCase "invoke returns assistant message" $ do
            let app _req respond = do
                  let resp =
                        [aesonQQ|{
                          "id": "v1_test_invoke",
                          "status": "completed",
                          "steps": [
                            {
                              "type": "model_output",
                              "content": [{"type": "text", "text": "Hello from Gemini"}]
                            }
                          ]
                        }|]
                  respond $ responseLBS status200 [("Content-Type", "application/json")] (encode resp)
            withMockServer app $ \url -> do
              let provider = newGemini "fake-key" "gemini-3.6-flash" (Just url)
              eRes <- runExceptT $ invoke provider [userMessage "Hi"] Nothing
              case eRes of
                Left err -> assertFailure (show err)
                Right msg -> do
                  messageRole msg @?= Assistant
                  extractMessageText msg @?= "Hello from Gemini"
        , testCase "invoke redacts API key on error" $ do
            let app _req respond = respond $ responseLBS status500 [] "Internal Server Error"
            withMockServer app $ \url -> do
              let provider = newGemini "SECRET_KEY_12345" "gemini-3.6-flash" (Just url)
              eRes <- runExceptT $ invoke provider [userMessage "Hi"] Nothing
              case eRes of
                Left err -> do
                  let errStr = T.pack (show err)
                  assertBool "Does not contain raw secret key" (not ("SECRET_KEY_12345" `T.isInfixOf` errStr))
                Right _ -> assertFailure "Expected failure on HTTP 500"
        ]
    , testGroup
        "ToolBinder integration"
        [ testCase "bindToolsConfig binds function tool to GeminiConfig" $ do
            let dummyTool :: Tool IO
                dummyTool =
                  createTool
                    "calculator"
                    "Do math"
                    [aesonQQ|{"type": "object"}|]
                    (\_ -> pure (Right "42"))
                cfg :: Maybe GeminiConfig
                cfg = bindToolsConfig @Gemini [dummyTool] Nothing
            case cfg of
              Nothing -> assertFailure "Expected Just GeminiConfig"
              Just c -> case configTools c of
                Just [_] -> pure ()
                other -> assertFailure ("Expected tool in configTools, got: " ++ show other)
        ]
    , testGroup
        "ChatModel stream (Mock)"
        [ testCase "stream yields LLMStart, LLMChunk, and LLMEnd" $ do
            let sseBody =
                  "data: {\"event_type\": \"step.delta\", \"index\": 0, \"delta\": {\"type\": \"text\", \"text\": \"Hello \"}}\n\n"
                    <> "data: {\"event_type\": \"step.delta\", \"index\": 0, \"delta\": {\"type\": \"text\", \"text\": \"World!\"}}\n\n"
                    <> "data: {\"event_type\": \"interaction.completed\", \"interaction\": {\"id\": \"v1_st\", \"status\": \"completed\", \"steps\": [{\"type\": \"model_output\", \"content\": [{\"type\": \"text\", \"text\": \"Hello World!\"}]}]}}\n\n"
                app _req respond =
                  respond $
                    responseStream status200 [("Content-Type", "text/event-stream")] $ \write flush -> do
                      write (Builder.lazyByteString sseBody)
                      flush
            withMockServer app $ \url -> do
              let provider = newGemini "fake-key" "gemini-3.6-flash" (Just url)
              eEvents <- runResourceT $ runExceptT $ collectEvents $ stream provider [userMessage "Hi"] Nothing
              case eEvents of
                Left err -> assertFailure (show err)
                Right events -> do
                  assertBool "Has at least 3 events" (length events >= 3)
                  case (events, reverse events) of
                    (LLMStart {} : _, LLMEnd {} : _) -> pure ()
                    other -> assertFailure ("Unexpected events: " ++ show other)
        ]
    , testGroup
        "Embeddings GeminiEmbeddings (Mock)"
        [ testCase "embedQuery and embedDocuments return vectors" $ do
            let app req respond = do
                  let resp =
                        [aesonQQ|{
                          "embedding": {"values": [0.1, 0.2, 0.3]},
                          "embeddings": [{"values": [0.1, 0.2]}, {"values": [0.3, 0.4]}]
                        }|]
                  respond $ responseLBS status200 [("Content-Type", "application/json")] (encode resp)
            withMockServer app $ \url -> do
              let emb = GeminiEmbeddings "fake-key" "gemini-embedding-001" (Just url)
              -- Query
              eQ <- runExceptT $ embedQuery emb "query text"
              case eQ of
                Left err -> assertFailure (show err)
                Right vec -> vec @?= [0.1, 0.2, 0.3]
              -- Docs
              let doc1 = Document (TL.pack "doc1") Map.empty
                  doc2 = Document (TL.pack "doc2") Map.empty
              eD <- runExceptT $ embedDocuments emb [doc1, doc2]
              case eD of
                Left err -> assertFailure (show err)
                Right vecs -> length vecs @?= 2
        ]
    , testGroup
        "Live Langchain Integration Tests (~/.gemini_api_key)"
        [ testCase "Live invoke ChatModel" $ do
            mbKey <- getLiveApiKey
            case mbKey of
              Nothing -> pure ()
              Just key -> do
                let provider = newGemini key "gemini-3.6-flash" Nothing
                eRes <- runExceptT $ invoke provider [userMessage "Say 'LANGCHAIN_OK'"] Nothing
                case eRes of
                  Left err -> assertFailure ("Live invoke failed: " ++ show err)
                  Right msg -> do
                    messageRole msg @?= Assistant
                    assertBool "Contains text" (not (T.null (extractMessageText msg)))
        , testCase "Live stream ChatModel" $ do
            mbKey <- getLiveApiKey
            case mbKey of
              Nothing -> pure ()
              Just key -> do
                let provider = newGemini key "gemini-3.6-flash" Nothing
                eEvents <-
                  runResourceT $ runExceptT $ collectEvents $ stream provider [userMessage "Count 1, 2, 3"] Nothing
                case eEvents of
                  Left err -> assertFailure ("Live stream failed: " ++ show err)
                  Right events -> do
                    assertBool "Received streaming events" (not (null events))
        , testCase "Live Embeddings (query & docs)" $ do
            mbKey <- getLiveApiKey
            case mbKey of
              Nothing -> pure ()
              Just key -> do
                let emb = defaultGeminiEmbeddings key
                eQ <- runExceptT $ embedQuery emb "Haskell LangChain"
                case eQ of
                  Left err -> assertFailure ("Live embedQuery failed: " ++ show err)
                  Right vec -> assertBool "Dimension > 0" (not (null vec))
                let docs = [Document "Document one" Map.empty, Document "Document two" Map.empty]
                eD <- runExceptT $ embedDocuments emb docs
                case eD of
                  Left err -> assertFailure ("Live embedDocuments failed: " ++ show err)
                  Right vecs -> length vecs @?= 2
        ]
    ]