packages feed

ollama-haskell-0.2.0.0: test/Test/Ollama/Chat.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE OverloadedStrings #-}

module Test.Ollama.Chat (tests) where

import Control.Monad (void)
import Data.Aeson qualified as Aeson
import Data.ByteString.Lazy.Char8 qualified as BSL
import Data.IORef (modifyIORef, newIORef, readIORef, writeIORef)
import Data.List.NonEmpty (fromList)
import Data.List.NonEmpty qualified as NE
import Data.Map qualified as HM
import Data.Maybe (isJust)
import Data.Ollama.Chat
import Data.Scientific
import Data.Text qualified as T
import Data.Time (diffUTCTime, getCurrentTime)
import Network.HTTP.Client
import Network.HTTP.Client.TLS
import Test.Tasty
import Test.Tasty.HUnit

-- | Basic chat test with default options
basicChatTest :: TestTree
basicChatTest = testCase "Basic chat should contain 4 for 2+2" $ do
  let ops = defaultChatOps
  eRes <- chat ops Nothing
  case eRes of
    Left err -> assertFailure $ "Expected success, got error: " ++ show err
    Right r -> case message r of
      Nothing -> assertFailure "Expected a message in response"
      Just msg -> assertBool "Should contain '4'" (T.isInfixOf "4" (content msg))

-- | Test timeout configuration
timeoutTest :: TestTree
timeoutTest = testCase "Setting timeout" $ do
  let config = Just $ defaultOllamaConfig {timeout = 1}
  eRes <- chat defaultChatOps config
  case eRes of
    Left (TimeoutError _) -> pure ()
    _ -> assertFailure "Expected timeout error"

-- | Test model lifecycle hooks on failure
hooksFailTest :: TestTree
hooksFailTest = testCase "Model lifecycle hooks should trigger on failure" $ do
  refStart <- newIORef False
  refError <- newIORef False
  refFinish <- newIORef True
  let config =
        defaultOllamaConfig
          { hostUrl = "http://localhost:12345" -- Guaranteed to fail
          , onModelStart = Just $ writeIORef refStart True
          , onModelError = Just $ writeIORef refError True
          , onModelFinish = Just $ writeIORef refFinish False
          }
  void $ chat defaultChatOps (Just config)
  wasStarted <- readIORef refStart
  wasErrored <- readIORef refError
  wasFinished <- readIORef refFinish
  assertBool "onModelStart should be called" wasStarted
  assertBool "onModelError should be called" wasErrored
  assertBool "onModelFinish should be called" wasFinished

-- | Test model lifecycle hooks on success
hooksSuccessTest :: TestTree
hooksSuccessTest = testCase "Model lifecycle hooks should trigger on success" $ do
  refStart <- newIORef False
  refError <- newIORef True
  refFinish <- newIORef False
  let config =
        defaultOllamaConfig
          { onModelStart = Just $ writeIORef refStart True
          , onModelError = Just $ writeIORef refError False
          , onModelFinish = Just $ writeIORef refFinish True
          }
  void $ chat defaultChatOps (Just config)
  wasStarted <- readIORef refStart
  wasErrored <- readIORef refError
  wasFinished <- readIORef refFinish
  assertBool "onModelStart should be called" wasStarted
  assertBool "onModelError should not be called" wasErrored
  assertBool "onModelFinish should be called" wasFinished

-- | Test retry count
retryCountTest :: TestTree
retryCountTest = testCase "Should retry chat call retryCount times" $ do
  counter <- newIORef (0 :: Int)
  let config =
        defaultOllamaConfig
          { hostUrl = "http://localhost:12345" -- Fails
          , retryCount = Just 2
          , retryDelay = Just 1
          , onModelStart = Just $ modifyIORef counter (+ 1)
          , onModelError = Just $ pure ()
          , onModelFinish = Just $ pure ()
          }
  _ <- chat defaultChatOps (Just config)
  calls <- readIORef counter
  assertEqual "Expected 3 attempts (1 initial + 2 retries)" 3 calls

-- | Test retry delay
retryDelayTest :: TestTree
retryDelayTest = testCase "Should delay between retries" $ do
  counter <- newIORef (0 :: Int)
  let delaySecs = 2
  start <- getCurrentTime
  let config =
        defaultOllamaConfig
          { hostUrl = "http://localhost:12345" -- Fails
          , retryCount = Just 1
          , retryDelay = Just delaySecs
          , onModelStart = Just $ modifyIORef counter (+ 1)
          , onModelError = Just $ pure ()
          , onModelFinish = Just $ pure ()
          }
  _ <- chat defaultChatOps (Just config)
  end <- getCurrentTime
  let elapsed = realToFrac (diffUTCTime end start) :: Double
      expectedMin = fromIntegral delaySecs
  assertBool
    ("Elapsed time should be at least " ++ show expectedMin ++ "s, but was " ++ show elapsed)
    (elapsed >= expectedMin)

-- | Test common manager usage
commonManagerTest :: TestTree
commonManagerTest = testCase "Should reuse provided commonManager" $ do
  refStart <- newIORef (0 :: Int)
  mgr <-
    newTlsManagerWith
      tlsManagerSettings {managerResponseTimeout = responseTimeoutMicro 1000000}
  let config =
        defaultOllamaConfig
          { hostUrl = "http://localhost:12345" -- Will fail fast
          , commonManager = Just mgr
          , timeout = 999 -- Shouldn’t matter, manager timeout takes precedence
          , onModelStart = Just $ modifyIORef refStart (+ 1)
          , onModelError = Just $ pure ()
          , onModelFinish = Just $ pure ()
          }
  _ <- chat defaultChatOps (Just config)
  _ <- chat defaultChatOps (Just config)
  startCount <- readIORef refStart
  assertEqual "Both requests should start (reuse manager)" 2 startCount

-- | Test JSON format response
jsonFormatTest :: TestTree
jsonFormatTest = testCase "Should return response in JSON format" $ do
  let ops =
        defaultChatOps
          { messages =
              fromList
                [userMessage "Return a JSON with keys 'name' and 'age' for John, 25 years old."]
          , format = Just JsonFormat
          }
  eRes <- chat ops Nothing
  case eRes of
    Left err -> assertFailure $ "Expected success, got error: " ++ show err
    Right r -> case message r of
      Nothing -> assertFailure "Expected a message in response"
      Just msg -> do
        let responseText = content msg
        let decoded = Aeson.decode (BSL.pack $ T.unpack responseText) :: Maybe Aeson.Value
        assertBool "Expected valid JSON object in response" (decoded /= Nothing)

-- | Test streaming response
streamingTest :: TestTree
streamingTest = testCase "Should handle streaming response" $ do
  chunksRef <- newIORef []
  let streamHandler chunk = modifyIORef chunksRef (++ [message chunk])
      flushHandler = pure ()
      ops = defaultChatOps {stream = Just (streamHandler, flushHandler)}
  eRes <- chat ops Nothing
  chunks <- readIORef chunksRef
  let fullOutput = T.concat (map (maybe "" content) chunks)
  case eRes of
    Left err -> assertFailure $ "Expected streaming success, got error: " ++ show err
    Right _ -> assertBool "Expected some streamed content" (not $ T.null fullOutput)

-- | Test custom model options
modelOptionsTest :: TestTree
modelOptionsTest = testCase "Should use custom model options" $ do
  let opts =
        Just $
          defaultModelOptions
            { temperature = Just 0.9
            , topP = Just 0.8
            , topK = Nothing
            , numPredict = Just 20
            }
      ops = defaultChatOps {options = opts}
  eRes <- chat ops Nothing
  case eRes of
    Left err -> assertFailure $ "Expected success, got error: " ++ show err
    Right r -> assertBool "Expected a response message" (isJust (message r))

testToolCall_addTwoNumbers :: TestTree
testToolCall_addTwoNumbers = testCase "Tool call: addTwoNumbers(23, 46)" $ do
  let messageList = NE.singleton $ userMessage "What is 23 + 46? (Use tool)"
      paramProps =
        HM.fromList
          [ ("a", FunctionParameters "number" Nothing Nothing Nothing)
          , ("b", FunctionParameters "number" Nothing Nothing Nothing)
          ]
      functionParams =
        FunctionParameters
          { parameterType = "object"
          , requiredParams = Just ["a", "b"]
          , parameterProperties = Just paramProps
          , additionalProperties = Just False
          }
      functionDef =
        FunctionDef
          { functionName = "addTwoNumbers"
          , functionDescription = Just "Add two numbers"
          , functionParameters = Just functionParams
          , functionStrict = Nothing
          }
      tool =
        InputTool
          { toolType = "function"
          , function = functionDef
          }
      ops =
        defaultChatOps
          { chatModelName = "qwen3:0.6b"
          , messages = messageList
          , tools = Just [tool]
          }

  res <- chat ops Nothing
  case res of
    Left err -> assertFailure $ "Chat failed: " ++ show err
    Right ChatResponse {message = Nothing} -> assertFailure "No message in response"
    Right ChatResponse {message = Just msg} ->
      case tool_calls msg of
        Nothing -> assertFailure "No tool calls received"
        Just [toolCall] -> do
          result <- captureAddToolCall toolCall
          assertEqual "Expected result of 23 + 46" 69 result
        Just other -> assertFailure $ "Unexpected number of tool calls: " ++ show other

-- Helper to evaluate the tool call
captureAddToolCall :: ToolCall -> IO Int
captureAddToolCall (ToolCall func)
  | outputFunctionName func == "addTwoNumbers" =
      case ( HM.lookup "a" (arguments func) >>= convertToNumber
           , HM.lookup "b" (arguments func) >>= convertToNumber
           ) of
        (Just a, Just b) -> return $ addTwoNumbers a b
        _ -> assertFailure "Missing parameters a or b" >> return 0
  | otherwise = assertFailure "Unexpected function name" >> return 0

addTwoNumbers :: Int -> Int -> Int
addTwoNumbers = (+)

-- Convert Aeson value to Int
convertToNumber :: Aeson.Value -> Maybe Int
convertToNumber (Aeson.Number n) = toBoundedInteger n
convertToNumber _ = Nothing

-- | Group all tests
tests :: TestTree
tests =
  sequentialTestGroup
    "Chat tests"
    AllFinish
    [ basicChatTest
    , timeoutTest
    , hooksFailTest
    , hooksSuccessTest
    , retryCountTest
    , retryDelayTest
    , commonManagerTest
    , jsonFormatTest
    , streamingTest
    , modelOptionsTest
    , testToolCall_addTwoNumbers
    ]