packages feed

llm-simple-0.1.0.2: test/LLM/LoadSpec.hs

module LLM.LoadSpec (spec) where

import Control.Exception (IOException, SomeException, catch, displayException, fromException, try)
import Control.Monad.Except (runExceptT)
import Data.Aeson (Value (Array), eitherDecodeFileStrict')
import Data.Map qualified as Map
import Data.Maybe (isJust)
import Data.Text (Text)
import Data.Text qualified as T
import LLM.Core.Types (LLMGateway (..))
import LLM.Generate.ModelConfig (ModelConfig (..))
import LLM.Load.LoadGateways (buildGateway, loadGateways, loadGatewaysFromCatalog, parseProviderBaseUrl)
import LLM.Load.LoadModels (loadModelOrThrow, loadModelOrThrow_, loadModelsOrThrow)
import LLM.Load.ProviderCatalog
  ( ProviderCatalogItem (..),
    ProviderProtocol (..),
    defaultProviderCatalogMap,
    loadProviderCatalog,
    loadProviderCatalogForModelCatalog,
  )
import LLM.Load.Types (LoadConfigError (..))
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import Test.Hspec
  ( Spec,
    describe,
    expectationFailure,
    it,
    shouldBe,
    shouldContain,
    shouldSatisfy,
  )

ollamaCatalog :: FilePath
ollamaCatalog = "./test/fixtures/ollama-catalog.json"

providersOllamaOnly :: FilePath
providersOllamaOnly = "./test/fixtures/providers-ollama-only.json"

providersOpenrouterOnly :: FilePath
providersOpenrouterOnly = "./test/fixtures/providers-openrouter-only.json"

spec :: Spec
spec = describe "Load" $ do
  describe "model catalog JSON" $ do
    it "parses the bundled model catalog" $ do
      result <- eitherDecodeFileStrict' "./model-catalog.json"
      case result of
        Right (Array xs) -> length xs `shouldSatisfy` (> 0)
        _ -> expectationFailure "expected a JSON array"

    it "fails on missing catalog file" $ do
      result <- try @SomeException (loadModelOrThrow "./no-such-catalog.json" "llama_3_2")
      case result of
        Left err
          | isJust (fromException err :: Maybe IOException) -> pure ()
          | isJust (fromException err :: Maybe LoadConfigError) -> pure ()
          | otherwise ->
              expectationFailure $ "expected missing-file failure, got: " <> displayException err
        Right _ -> expectationFailure "expected failure"

  describe "loadModelsOrThrow" $ do
    it "loads a known model config" $ do
      cfg <- loadModelOrThrow ollamaCatalog "llama_3_2"
      cfg.mcModel `shouldBe` "llama3.2:latest"

    it "throws when a model config is missing" $ do
      result <- try @LoadConfigError (loadModelsOrThrow ollamaCatalog ("llama_3_2" :: Text, "missing_model" :: Text))
      case result of
        Left (LoadModelConfigError msg) -> T.unpack msg `shouldContain` "Model not found"
        Left other -> expectationFailure $ "expected LoadModelConfigError, got: " <> show other
        Right _ -> expectationFailure "expected failure"

    it "loads the full catalog map from an ollama-only fixture" $ do
      (_, catalog, _) <- loadModelOrThrow_ ollamaCatalog "llama_3_2"
      Map.member "llama_3_2" catalog `shouldBe` True
      Map.member "mistral" catalog `shouldBe` True

  describe "loadGateways" $ do
    it "always includes ollama" $ do
      gateways <- loadGateways
      Map.member "ollama" gateways `shouldBe` True

    it "builds an openai-compatible gateway from a custom provider catalog" $ do
      let item =
            ProviderCatalogItem
              { providerName = "openrouter",
                protocol = OpenAIProtocol,
                baseUrl = "https://openrouter.ai/api",
                apiKeyEnv = Just "OPENROUTER_API_KEY",
                baseUrlEnv = Nothing
              }
      Right baseUrl <- pure $ parseProviderBaseUrl item.baseUrl
      let gateway = buildGateway item baseUrl "test-key"
          LLMGateway {gwName = name} = gateway
      name `shouldBe` "openrouter"

    it "loads gateways from a provider catalog file" $ do
      result <- runExceptT $ loadProviderCatalog providersOllamaOnly
      case result of
        Left err -> expectationFailure $ show err
        Right catalog -> do
          gateways <- loadGatewaysFromCatalog catalog
          Map.keys gateways `shouldSatisfy` ("ollama" `elem`)

  describe "provider catalog JSON" $ do
    it "parses the bundled provider catalog" $ do
      result <- eitherDecodeFileStrict' "./providers.json"
      case result of
        Right (Array xs) -> length xs `shouldSatisfy` (> 0)
        _ -> expectationFailure "expected a JSON array"

    it "includes built-in providers in the default catalog" $ do
      Map.member "openai" defaultProviderCatalogMap `shouldBe` True
      Map.member "ollama" defaultProviderCatalogMap `shouldBe` True

    it "merges a partial providers.json with built-in defaults" $
      withProviderCatalogDir providersOpenrouterOnly $ \catalogPath -> do
        result <- runExceptT $ loadProviderCatalogForModelCatalog catalogPath
        case result of
          Left err -> expectationFailure $ show err
          Right catalog -> do
            Map.member "openrouter" catalog `shouldBe` True
            Map.member "openai" catalog `shouldBe` True
            Map.member "ollama" catalog `shouldBe` True

    it "lets providers.json override a built-in provider" $
      withProviderCatalogDir providersOllamaOnly $ \catalogPath -> do
        result <- runExceptT $ loadProviderCatalogForModelCatalog catalogPath
        case result of
          Left err -> expectationFailure $ show err
          Right catalog -> do
            Map.lookup "ollama" catalog
              `shouldBe` Just
                ProviderCatalogItem
                  { providerName = "ollama",
                    protocol = OllamaProtocol,
                    baseUrl = "http://localhost:11434",
                    apiKeyEnv = Nothing,
                    baseUrlEnv = Nothing
                  }
            Map.member "openai" catalog `shouldBe` True

  describe "invalid catalog JSON" $ do
    it "reports a catalog parse/load error" $ withTempCatalog "[not valid json" $ \path -> do
      result <- try @LoadConfigError (loadModelOrThrow path "x")
      case result of
        Left (LoadModelCatalogError _) -> pure ()
        Left other -> expectationFailure $ "expected LoadModelCatalogError, got: " <> show other
        Right _ -> expectationFailure "expected failure"

withTempCatalog :: String -> (FilePath -> IO a) -> IO a
withTempCatalog contents act =
  withSystemTempDirectory "llm-simple-load" $ \root -> do
    let path = root </> "catalog.json"
    writeFile path contents
    act path `catch` \(_ :: IOException) -> act path

withProviderCatalogDir :: FilePath -> (FilePath -> IO a) -> IO a
withProviderCatalogDir providersFixture act =
  withSystemTempDirectory "llm-simple-providers" $ \root -> do
    let catalogPath = root </> "model-catalog.json"
        providersPath = root </> "providers.json"
    writeFile catalogPath "[]"
    providersJson <- readFile providersFixture
    writeFile providersPath providersJson
    act catalogPath `catch` \(_ :: IOException) -> act catalogPath