packages feed

llm-simple-0.1.0.1: test/LLM/GenerateObjectSpec.hs

{-# OPTIONS_GHC -Wno-missing-fields #-}

module LLM.GenerateObjectSpec (spec) where

import Data.Aeson (Value, object, (.=))
import Data.Text (Text)
import Data.Text qualified as T
import Heptapod (generate)
import LLM.Agent.GenerateObject (generateObject, generateObjectUntyped)
import LLM.Agent.Types (Agent (..), RuntimeArgs (..))
import LLM.Core.Abort (AbortSignal, abort, newAbortSignal)
import LLM.Core.Types (ChatResponse (..), LLMError (..), LLMGateway (..), LLMHooks (..), Turn (..))
import LLM.Core.Usage (PricingInfo (..), Usage (..))
import LLM.Generate.Logger (noHooks)
import LLM.Generate.ModelConfig
  ( ModelConfig (..),
    ModelWithFallbacks (..),
  )
import LLM.Generate.Types (GenerateError (..), GenerateErrorResult (..))
import LLM.WeatherTool (WeatherToolArgs (..))
import Test.Hspec
  ( Spec,
    describe,
    expectationFailure,
    it,
    shouldBe,
    shouldContain,
  )

spec :: Spec
spec = describe "GenerateObject" $ do
  describe "generateObjectUntyped" $ do
    it "returns the provider JSON and usage" $ do
      let gw = objectGateway (object ["location" .= ("Paris" :: Text)]) (Usage 12 3 0)
          models = ModelWithFallbacks (mockModel gw) []
      result <- runUntyped models (object ["type" .= ("object" :: Text)])
      case result of
        Right (value, usage) -> do
          value `shouldBe` object ["location" .= ("Paris" :: Text)]
          usage `shouldBe` Usage 12 3 0
        Left err -> expectationFailure $ show err

    it "propagates provider errors" $ do
      let gw = errorObjectGateway (HttpError 503 "unavailable")
          models = ModelWithFallbacks (mockModel gw) []
      result <- runUntyped models (object ["type" .= ("object" :: Text)])
      case result of
        Left GenerateErrorResult {gerError = GErrLLM (HttpError 503 _)} -> pure ()
        other -> expectationFailure $ "expected HttpError 503, got: " <> show other

    it "returns Aborted when the signal is already set" $ do
      sig <- newAbortSignal
      abort sig
      let gw = objectGateway (object []) (Usage 0 0 0)
          models = ModelWithFallbacks (mockModel gw) []
      rt <- mkRuntime (Just sig)
      result <- generateObjectUntyped defaultAgent models rt [UserTurn "go"] (object ["type" .= ("object" :: Text)])
      case result of
        Left GenerateErrorResult {gerError = GErrAborted} -> pure ()
        other -> expectationFailure $ "expected GErrAborted, got: " <> show other

    it "falls back to the next model on failure" $ do
      let failGw = errorObjectGateway (HttpError 500 "boom")
          okGw = objectGateway (object ["ok" .= True]) (Usage 1 1 0)
          models = ModelWithFallbacks (mockModel failGw) [mockModel okGw]
      result <- runUntyped models (object ["type" .= ("object" :: Text)])
      case result of
        Right (value, _) -> value `shouldBe` object ["ok" .= True]
        Left err -> expectationFailure $ show err

  describe "generateObject" $ do
    it "decodes a typed object from provider JSON" $ do
      let gw = objectGateway (object ["location" .= ("London" :: Text)]) (Usage 4 2 0)
          models = ModelWithFallbacks (mockModel gw) []
      result <- runTyped models
      case result of
        Right (WeatherToolArgs loc, usage) -> do
          loc `shouldBe` "London"
          usage `shouldBe` Usage 4 2 0
        Left err -> expectationFailure $ show err

    it "reports a parse error when provider JSON does not match the codec" $ do
      let gw = objectGateway (object ["wrong" .= (1 :: Int)]) (Usage 0 0 0)
          models = ModelWithFallbacks (mockModel gw) []
      result <- runTyped models
      case result of
        Left GenerateErrorResult {gerError = GErrParseObjectError msg} ->
          T.unpack msg `shouldContain` "Can't decode object"
        _ -> expectationFailure "expected GErrParseObjectError"

objectGateway :: Value -> Usage -> LLMGateway
objectGateway value usage =
  LLMGateway
    { gwName = "object-mock",
      gwGenerateText = \_ _ -> pure (Right (ChatResponse "" [] Nothing Nothing)),
      gwStreamText = \_ _ _ -> pure (Right (ChatResponse "" [] Nothing Nothing)),
      gwGenerateObject = \_ _ _ -> pure (Right (value, Just usage))
    }

errorObjectGateway :: LLMError -> LLMGateway
errorObjectGateway err =
  LLMGateway
    { gwName = "object-error",
      gwGenerateText = \_ _ -> pure (Left err),
      gwStreamText = \_ _ _ -> pure (Left err),
      gwGenerateObject = \_ _ _ -> pure (Left err)
    }

mockModel :: LLMGateway -> ModelConfig
mockModel gw =
  ModelConfig
    { mcGateway = gw,
      mcModel = "test-model",
      mcPricing = zeroPricing,
      mcMaxTokens = 256,
      mcTemperature = Nothing,
      mcThinking = Nothing,
      mcRequestTimeout = Nothing,
      mcThrottleDelay = Nothing,
      mcRetryCount = 0,
      mcJitterBackoff = 0
    }

zeroPricing :: PricingInfo
zeroPricing = PricingInfo 0 0

defaultAgent :: Agent
defaultAgent =
  Agent
    { agName = "test",
      agSystemPrompt = Nothing,
      agTools = [],
      agMaxToolRounds = 3,
      agContextWindow = Nothing
    }

mkRuntime :: Maybe AbortSignal -> IO RuntimeArgs
mkRuntime mSig = do
  genId <- generate
  pure
    RuntimeArgs
      { rtGenerationId = genId,
        rtAbortSignal = mSig,
        rtLLMHooks =
          LLMHooks
            { onLLMRequest = \_ _ -> pure (),
              onLLMResponse = \_ _ -> pure (),
              onLLMResponseError = \_ _ -> pure ()
            },
        rtHooks = noHooks,
        rtOnEvent = \_ -> pure (),
        rtReadonly = False
      }

runUntyped :: ModelWithFallbacks -> Value -> IO (Either GenerateErrorResult (Value, Usage))
runUntyped models schema = do
  rt <- mkRuntime Nothing
  generateObjectUntyped defaultAgent models rt [UserTurn "extract"] schema

runTyped :: ModelWithFallbacks -> IO (Either GenerateErrorResult (WeatherToolArgs, Usage))
runTyped models = do
  rt <- mkRuntime Nothing
  generateObject defaultAgent models rt [UserTurn "weather?"]