packages feed

bloodhound-1.0.0.0: tests/Test/InferenceSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module Test.InferenceSpec (spec) where

import Data.Aeson
import Data.Aeson.Key (Key)
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.Map.Strict qualified as Map
import Data.Maybe (isJust, isNothing)
import Database.Bloodhound.Client.Cluster (BackendType (..))
import Database.Bloodhound.ElasticSearch8.Client qualified as ClientES8
import Database.Bloodhound.ElasticSearch8.Requests qualified as RequestsES8
import Database.Bloodhound.ElasticSearch8.Types qualified as Types
import Database.Bloodhound.ElasticSearch9.Client qualified as ClientES9
import Database.Bloodhound.ElasticSearch9.Requests qualified as RequestsES9
import TestsUtils.Common
import TestsUtils.Import

hasKey :: Key -> LBS.ByteString -> Bool
hasKey k bs = case decode bs of
  Just (Object obj) -> k `KM.member` obj
  _ -> False

-- | Decode a @GET /_inference@ response body into a list of
-- 'Types.InferenceInfo'. The wire format is a JSON object keyed by
-- @<task_type>\/<inference_id>@; 'Types.InferenceEndpointsResponse'
-- flattens it on decode.
decodeInferenceList :: LBS.ByteString -> Maybe [Types.InferenceInfo]
decodeInferenceList =
  fmap Types.unInferenceEndpointsResponse . decode

spec :: Spec
spec = describe "Inference API (GET/PUT/POST/DELETE /_inference/{task_type}/{inference_id})" $ do
  describe "TaskType JSON" $ do
    it "serializes known task types to snake_case" $ do
      Types.taskTypeToText Types.TextEmbeddingTaskType `shouldBe` "text_embedding"
      Types.taskTypeToText Types.SparseEmbeddingTaskType `shouldBe` "sparse_embedding"
      Types.taskTypeToText Types.ChatCompletionTaskType `shouldBe` "chat_completion"
      Types.taskTypeToText Types.RerankTaskType `shouldBe` "rerank"

    it "round-trips every known task type" $ do
      mapM_
        (\tt -> decode (encode tt) `shouldBe` Just tt)
        [ minBound
          .. maxBound :: Types.TaskType
        ]

  describe "Similarity JSON" $ do
    it "round-trips every valid similarity value" $ do
      mapM_
        (\s -> decode (encode s) `shouldBe` Just s)
        [ Types.SimilarityCosine,
          Types.SimilarityDotProduct,
          Types.SimilarityL2Norm
        ]

    it "rejects max_inner_product (not valid for any inference provider)" $ do
      (decode "\"max_inner_product\"" :: Maybe Types.Similarity)
        `shouldBe` Nothing

  describe "ChatMessage JSON" $ do
    it "round-trips a minimal message (role + content)" $ do
      let msg = Types.ChatMessage "user" (Just (toJSON ("hello" :: Text))) Nothing Nothing Nothing Nothing
      decode (encode msg) `shouldBe` Just msg

    it "round-trips a message with tool_call_id and tool_calls" $ do
      let msg =
            Types.ChatMessage
              { Types.cmRole = "tool",
                Types.cmContent = Nothing,
                Types.cmToolCallId = Just "call_abc",
                Types.cmToolCalls = Just (toJSON [object ["id" .= ("call_1" :: Text)]]),
                Types.cmReasoning = Nothing,
                Types.cmReasoningDetails = Nothing
              }
      decode (encode msg) `shouldBe` Just msg

    it "round-trips a message with reasoning fields" $ do
      let msg =
            Types.ChatMessage
              { Types.cmRole = "assistant",
                Types.cmContent = Just (toJSON ("thinking..." :: Text)),
                Types.cmToolCallId = Nothing,
                Types.cmToolCalls = Nothing,
                Types.cmReasoning = Just (toJSON True),
                Types.cmReasoningDetails = Just (toJSON [object ["type" .= ("summary" :: Text)]])
              }
      decode (encode msg) `shouldBe` Just msg

    it "omits Nothing optional fields from JSON" $ do
      let msg = Types.ChatMessage "system" (Just (toJSON ("hi" :: Text))) Nothing Nothing Nothing Nothing
      let json = encode msg
      json `shouldNotSatisfy` hasKey "tool_call_id"
      json `shouldNotSatisfy` hasKey "tool_calls"
      json `shouldNotSatisfy` hasKey "reasoning"
      json `shouldNotSatisfy` hasKey "reasoning_details"

    it "decodes a message without content" $ do
      let raw = LBS.pack "{\"role\":\"assistant\",\"tool_calls\":[]}"
      case decode raw :: Maybe Types.ChatMessage of
        Just msg -> do
          Types.cmRole msg `shouldBe` "assistant"
          Types.cmContent msg `shouldBe` Nothing
          Types.cmToolCalls msg `shouldSatisfy` isJust
        Nothing -> expectationFailure "failed to decode ChatMessage"

  describe "ChatCompletionRequest JSON" $ do
    it "round-trips a minimal request (messages only)" $ do
      let req =
            Types.ChatCompletionRequest
              { Types.ccrMessages =
                  [Types.ChatMessage "user" (Just (toJSON ("hi" :: Text))) Nothing Nothing Nothing Nothing],
                Types.ccrModel = Nothing,
                Types.ccrMaxCompletionTokens = Nothing,
                Types.ccrReasoning = Nothing,
                Types.ccrStop = Nothing,
                Types.ccrTemperature = Nothing,
                Types.ccrToolChoice = Nothing,
                Types.ccrTools = Nothing,
                Types.ccrTopP = Nothing
              }
      decode (encode req) `shouldBe` Just req

    it "round-trips a fully-populated request with top-level fields" $ do
      let req =
            Types.ChatCompletionRequest
              { Types.ccrMessages =
                  [Types.ChatMessage "user" (Just (toJSON ("hi" :: Text))) Nothing Nothing Nothing Nothing],
                Types.ccrModel = Just "gpt-4o",
                Types.ccrMaxCompletionTokens = Just 1024,
                Types.ccrReasoning = Just (toJSON True),
                Types.ccrStop = Just (toJSON ("STOP" :: Text)),
                Types.ccrTemperature = Just 0.7,
                Types.ccrToolChoice = Just (toJSON ("auto" :: Text)),
                Types.ccrTools = Just (toJSON [object ["type" .= ("function" :: Text)]]),
                Types.ccrTopP = Just 0.9
              }
      decode (encode req) `shouldBe` Just req

    it "does not emit a task_settings wrapper" $ do
      let req =
            Types.ChatCompletionRequest
              { Types.ccrMessages =
                  [Types.ChatMessage "user" (Just (toJSON ("hi" :: Text))) Nothing Nothing Nothing Nothing],
                Types.ccrModel = Just "gpt-4o",
                Types.ccrMaxCompletionTokens = Just 128,
                Types.ccrReasoning = Nothing,
                Types.ccrStop = Nothing,
                Types.ccrTemperature = Just 0.5,
                Types.ccrToolChoice = Nothing,
                Types.ccrTools = Nothing,
                Types.ccrTopP = Nothing
              }
      let json = encode req
      json `shouldNotSatisfy` hasKey "task_settings"
      json `shouldSatisfy` hasKey "messages"
      json `shouldSatisfy` hasKey "model"
      json `shouldSatisfy` hasKey "max_completion_tokens"
      json `shouldSatisfy` hasKey "temperature"

  describe "optional api_key round-trip" $ do
    it "round-trips an OpenAI config with api_key = Nothing" $ do
      let cfg =
            Types.InferenceConfig
              { Types.inferenceProvider =
                  Types.OpenAIProvider
                    (Types.OpenAIServiceSettings Nothing "text-embedding-3-small" Nothing Nothing Nothing Nothing Nothing)
                    Nothing,
                Types.inferenceChunkingSettings = Nothing
              }
      let json = encode cfg
      json `shouldNotSatisfy` hasKey "api_key"
      (decode json :: Maybe Types.InferenceConfig) `shouldBe` Just cfg

    it "round-trips a Cohere config with api_key = Nothing" $ do
      let cfg =
            Types.InferenceConfig
              { Types.inferenceProvider =
                  Types.CohereProvider
                    (Types.CohereServiceSettings Nothing "embed-v3" Nothing Nothing Nothing)
                    Nothing,
                Types.inferenceChunkingSettings = Nothing
              }
      (decode (encode cfg) :: Maybe Types.InferenceConfig) `shouldBe` Just cfg

  describe "InferenceConfig JSON" $ do
    let minimalOpenAI =
          Types.InferenceConfig
            { Types.inferenceProvider =
                Types.OpenAIProvider
                  (Types.OpenAIServiceSettings (Just "sk-xxx") "text-embedding-3-small" Nothing Nothing Nothing Nothing Nothing)
                  Nothing,
              Types.inferenceChunkingSettings = Nothing
            }

    it "emits service and service_settings for a minimal OpenAI config" $ do
      let json = encode minimalOpenAI
      json `shouldSatisfy` hasKey "service"
      json `shouldSatisfy` hasKey "service_settings"

    it "omits Nothing optional top-level fields" $ do
      let json = encode minimalOpenAI
      json `shouldNotSatisfy` hasKey "task_settings"
      json `shouldNotSatisfy` hasKey "chunking_settings"

    it "writes the OpenAI service name" $ do
      let Just (Object o) = decode (encode minimalOpenAI)
      KM.lookup "service" o `shouldBe` Just (String "openai")

    it "round-trips a minimal OpenAI config" $ do
      decode (encode minimalOpenAI) `shouldBe` Just minimalOpenAI

    it "round-trips a fully-populated Cohere config" $ do
      let cfg =
            Types.InferenceConfig
              { Types.inferenceProvider =
                  Types.CohereProvider
                    ( Types.CohereServiceSettings
                        { Types.cssApiKey = Just "cohere-key",
                          Types.cssModelId = "embed-english-light-v3.0",
                          Types.cssEmbeddingType = Just Types.CohereEmbeddingByte,
                          Types.cssSimilarity = Just Types.SimilarityCosine,
                          Types.cssRateLimit = Just (Types.RateLimit 100)
                        }
                    )
                    ( Just
                        Types.CohereTaskSettings
                          { Types.ctsInputType = Just Types.CohereInputIngest,
                            Types.ctsReturnDocuments = Just True,
                            Types.ctsTopN = Just 10,
                            Types.ctsTruncate = Just Types.CohereTruncateEnd
                          }
                    ),
                Types.inferenceChunkingSettings = Nothing
              }
      decode (encode cfg) `shouldBe` Just cfg

    it "round-trips an ELSER config" $ do
      let cfg =
            Types.InferenceConfig
              { Types.inferenceProvider =
                  Types.ElserProvider
                    Types.ElserServiceSettings
                      { Types.esserNumAllocations = 1,
                        Types.esserNumThreads = 1,
                        Types.esserAdaptiveAllocations = Nothing
                      },
                Types.inferenceChunkingSettings = Nothing
              }
      let Just (Object o) = decode (encode cfg)
      KM.lookup "service" o `shouldBe` Just (String "elser")
      decode (encode cfg) `shouldBe` Just cfg

    it "round-trips the generic escape hatch" $ do
      -- Use a service name not in the typed set so dispatch falls through
      -- to GenericProvider on decode (typed names like "mistral" decode to
      -- their typed constructor and would not round-trip via the generic).
      let rawSettings = object ["model_id" .= ("foo" :: Text), "api_key" .= ("bar" :: Text)]
          cfg =
            Types.InferenceConfig
              { Types.inferenceProvider =
                  Types.GenericProvider "some-future-provider" rawSettings Nothing,
                Types.inferenceChunkingSettings = Nothing
              }
      decode (encode cfg) `shouldBe` Just cfg

    it "round-trips every typed provider and writes its service wire string" $ do
      -- One minimal InferenceConfig per provider. Round-trips through
      -- encode/decode and the emitted `service` field matches the wire name.
      let rateLimit = Just (Types.RateLimit 100)
          providers =
            [ ( "openai",
                Types.OpenAIProvider
                  (Types.OpenAIServiceSettings (Just "sk") "m" Nothing Nothing Nothing Nothing rateLimit)
                  Nothing
              ),
              ( "cohere",
                Types.CohereProvider
                  (Types.CohereServiceSettings (Just "sk") "m" Nothing Nothing rateLimit)
                  Nothing
              ),
              ( "elser",
                Types.ElserProvider $
                  Types.ElserServiceSettings 1 1 Nothing
              ),
              ( "ai21",
                Types.Ai21Provider $
                  Types.Ai21ServiceSettings "m" (Just "sk") rateLimit
              ),
              ( "mistral",
                Types.MistralProvider $
                  Types.MistralServiceSettings (Just "sk") "m" (Just 1024) rateLimit
              ),
              ( "anthropic",
                Types.AnthropicProvider
                  (Types.AnthropicServiceSettings (Just "sk") "m" rateLimit)
                  (Just (Types.AnthropicTaskSettings 128 (Just 0.5) (Just 4) Nothing))
              ),
              ( "deepseek",
                Types.DeepSeekProvider $
                  Types.DeepSeekServiceSettings (Just "sk") "m" (Just "https://api.deepseek.com")
              ),
              ( "nvidia",
                Types.NvidiaProvider
                  (Types.NvidiaServiceSettings (Just "sk") "m" (Just 2048) rateLimit (Just Types.SimilarityCosine) Nothing)
                  (Just (Types.NvidiaTaskSettings (Just Types.NvidiaInputSearch) (Just Types.CohereTruncateEnd)))
              ),
              ( "contextualai",
                Types.ContextualAiProvider
                  (Types.ContextualAiServiceSettings (Just "sk") "m" rateLimit)
                  (Just (Types.ContextualAiTaskSettings (Just "ctx") (Just 5)))
              ),
              ( "fireworksai",
                Types.FireworksAiProvider
                  (Types.FireworksAiServiceSettings (Just "sk") "m" (Just 768) rateLimit (Just Types.SimilarityDotProduct) (Just "https://api.fireworks.ai"))
                  (Just (Types.FireworksAiTaskSettings (Just "u") Nothing))
              ),
              ( "googleaistudio",
                Types.GoogleAiStudioProvider $
                  Types.GoogleAiStudioServiceSettings (Just "sk") "m" rateLimit
              ),
              ( "googlevertexai",
                Types.GoogleVertexAiProvider
                  (Types.GoogleVertexAiServiceSettings "acct-json" (Just Types.GoogleModelGardenGoogle) Nothing Nothing (Just "us") (Just "m") (Just "proj") rateLimit (Just 768) (Just 10))
                  (Just (Types.GoogleVertexAiTaskSettings (Just True) (Just 1024) (Just (Types.ThinkingConfig (Just 64))) (Just 3)))
              ),
              ( "groq",
                Types.GroqProvider $
                  Types.GroqServiceSettings "m" (Just "sk") rateLimit
              ),
              ( "hugging_face",
                Types.HuggingFaceProvider
                  (Types.HuggingFaceServiceSettings (Just "sk") "https://api.hf.co" (Just "m") rateLimit)
                  (Just (Types.HuggingFaceTaskSettings (Just True) (Just 2)))
              ),
              ( "jinaai",
                Types.JinaAiProvider
                  (Types.JinaAiServiceSettings (Just "sk") "m" (Just 512) (Just Types.JinaAiElementFloat) (Just False) rateLimit (Just Types.SimilarityL2Norm))
                  (Just (Types.JinaAiTaskSettings (Just Types.JinaAiInputSearch) (Just True) (Just False) (Just 3)))
              ),
              ( "llama",
                Types.LlamaProvider $
                  Types.LlamaServiceSettings "https://llama" "m" (Just 512) rateLimit (Just Types.SimilarityCosine)
              ),
              ( "openshift_ai",
                Types.OpenShiftAiProvider
                  (Types.OpenShiftAiServiceSettings (Just "sk") "https://osh" (Just 512) (Just "m") rateLimit (Just Types.SimilarityCosine))
                  (Just (Types.OpenShiftAiTaskSettings (Just True) (Just 2)))
              ),
              ( "voyageai",
                Types.VoyageAiProvider
                  (Types.VoyageAiServiceSettings "m" (Just 512) (Just Types.JinaAiElementBit) rateLimit)
                  (Just (Types.VoyageAiTaskSettings (Just "search") (Just True) (Just 3) (Just False)))
              ),
              ( "elasticsearch",
                Types.ElasticsearchProvider
                  (Types.ElasticsearchServiceSettings "m" 1 Nothing Nothing (Just 1) Nothing Nothing)
                  (Just (Types.ElasticsearchTaskSettings (Just True)))
              ),
              ( "alibabacloud-ai-search",
                Types.AlibabaCloudProvider
                  (Types.AlibabaCloudServiceSettings (Just "sk") "host" "sid" "ws" rateLimit)
                  (Just (Types.AlibabaCloudTaskSettings (Just "search") (Just True)))
              ),
              ( "amazonbedrock",
                Types.AmazonBedrockProvider
                  (Types.AmazonBedrockServiceSettings "ak" "m" "us-east-1" "sk" (Just "anthropic") rateLimit)
                  (Just (Types.AmazonBedrockTaskSettings (Just 128) (Just 0.5) (Just 4) Nothing))
              ),
              ( "amazon_sagemaker",
                Types.AmazonSageMakerProvider
                  (Types.AmazonSageMakerServiceSettings "ak" "ep" Types.AmazonSageMakerApiElastic "us-east-1" "sk" (Just Types.SimilarityCosine) (Just Types.AmazonSageMakerElementFloat) Nothing Nothing Nothing (Just 8) (Just 256))
                  (Just (Types.AmazonSageMakerTaskSettings (Just "attr") Nothing Nothing Nothing Nothing))
              ),
              ( "azureaistudio",
                Types.AzureAiStudioProvider
                  (Types.AzureAiStudioServiceSettings (Just "sk") "openai" "t" "microsoft" rateLimit)
                  (Just (Types.AzureAiStudioTaskSettings (Just 0.5) (Just 128) (Just True) (Just 0.7) (Just 3) Nothing (Just "u")))
              ),
              ( "azureopenai",
                Types.AzureOpenAiProvider
                  (Types.AzureOpenAiServiceSettings "2024-02-15" "dep" "res" (Just "sk") Nothing Nothing Nothing rateLimit (Just ["s"]) Nothing)
                  (Just (Types.AzureOpenAiTaskSettings (Just "u") Nothing))
              ),
              ( "custom",
                Types.CustomProvider
                  (Types.CustomServiceSettings (object ["a" .= (1 :: Int)]) (object ["b" .= (2 :: Int)]) (Types.SecretValue (object [])) (Just 4) Nothing Nothing Nothing (Just "https://custom"))
                  (Just (Types.CustomTaskSettings (Just (object ["x" .= (1 :: Int)]))))
              ),
              ( "watsonxai",
                Types.WatsonxProvider $
                  Types.WatsonxServiceSettings (Just "sk") "2023-05-29" "m" "proj" "https://us-south.ml.cloud.ibm.com" rateLimit
              )
            ]
      mapM_
        ( \(svc, provider) -> do
            let cfg =
                  Types.InferenceConfig
                    { Types.inferenceProvider = provider,
                      Types.inferenceChunkingSettings = Nothing
                    }
                json = encode cfg
            json `shouldSatisfy` hasKey "service"
            case decode json :: Maybe Value of
              Just (Object o) -> KM.lookup "service" o `shouldBe` Just (String svc)
              _ -> expectationFailure "expected a JSON object"
            (decode json :: Maybe Types.InferenceConfig) `shouldBe` Just cfg
        )
        providers

  describe "InferencePutResponse JSON" $ do
    it "decodes a complete response" $ do
      let raw =
            LBS.pack
              "{\"inference_id\":\"my-openai\",\"task_type\":\"text_embedding\",\"service\":\"openai\",\"service_settings\":{\"api_key\":\"sk-x\",\"model_id\":\"text-embedding-3-small\"}}"
      let Just resp = decode raw
      Types.iprInferenceId resp `shouldBe` "my-openai"
      Types.iprTaskType resp `shouldBe` Types.TextEmbeddingTaskType
      Types.iprService resp `shouldBe` "openai"
      Types.iprServiceSettings resp `shouldSatisfy` isJust

    it "round-trips" $ do
      let resp =
            Types.InferencePutResponse
              { Types.iprInferenceId = "x",
                Types.iprTaskType = Types.SparseEmbeddingTaskType,
                Types.iprService = "elser",
                Types.iprServiceSettings = Nothing,
                Types.iprTaskSettings = Nothing,
                Types.iprChunkingSettings = Nothing
              }
      decode (encode resp) `shouldBe` Just resp

  describe "InferenceInput JSON" $ do
    it "always emits input as an array, omits optional fields" $ do
      let input = Types.InferenceInput ["hello"] Nothing Nothing Nothing
      let json = encode input
      json `shouldSatisfy` hasKey "input"
      json `shouldNotSatisfy` hasKey "query"
      json `shouldNotSatisfy` hasKey "input_type"
      json `shouldNotSatisfy` hasKey "task_settings"
      case decode json :: Maybe Types.InferenceInput of
        Just decoded -> Types.iiInput decoded `shouldBe` ["hello"]
        Nothing -> expectationFailure "failed to decode InferenceInput"

    it "includes query and task_settings when set" $ do
      let input =
            Types.InferenceInput
              { Types.iiInput = ["a", "b"],
                Types.iiQuery = Just "star wars",
                Types.iiInputType = Nothing,
                Types.iiTaskSettings = Just (object ["max_tokens" .= (128 :: Int)])
              }
      let json = encode input
      json `shouldSatisfy` hasKey "query"
      json `shouldSatisfy` hasKey "task_settings"

  describe "InferenceResult JSON" $ do
    it "decodes a text_embedding response" $ do
      let raw = LBS.pack "{\"text_embedding\":[{\"embedding\":[0.1,-0.2,0.3]}]}"
      let Just (Types.DenseEmbeddingResult fmt xs) = decode raw
      fmt `shouldBe` Types.FloatEmbedding
      length xs `shouldBe` 1
      case xs of
        (item : _) -> Types.deiEmbedding item `shouldBe` [0.1, -0.2, 0.3]
        [] -> expectationFailure "expected one embedding"

    it "decodes an embeddings (plural) response as Float format" $ do
      let raw = LBS.pack "{\"embeddings\":[{\"embedding\":[0.5]}]}"
      let Just (Types.DenseEmbeddingResult fmt _) = decode raw
      fmt `shouldBe` Types.FloatEmbedding

    it "decodes an embeddings_bytes response as Bytes format" $ do
      let raw = LBS.pack "{\"embeddings_bytes\":[{\"embedding\":[1,2,3]}]}"
      let Just (Types.DenseEmbeddingResult fmt _) = decode raw
      fmt `shouldBe` Types.BytesEmbedding

    it "decodes a text_embedding_bytes response as Bytes format" $ do
      let raw = LBS.pack "{\"text_embedding_bytes\":[{\"embedding\":[1,2]}]}"
      let Just (Types.DenseEmbeddingResult fmt _) = decode raw
      fmt `shouldBe` Types.BytesEmbedding

    it "decodes an embeddings_bits response as Bits format" $ do
      let raw = LBS.pack "{\"embeddings_bits\":[{\"embedding\":[0,1]}]}"
      let Just (Types.DenseEmbeddingResult fmt _) = decode raw
      fmt `shouldBe` Types.BitsEmbedding

    it "decodes a text_embedding_bits response as Bits format" $ do
      let raw = LBS.pack "{\"text_embedding_bits\":[{\"embedding\":[0]}]}"
      let Just (Types.DenseEmbeddingResult fmt _) = decode raw
      fmt `shouldBe` Types.BitsEmbedding

    it "decodes a sparse_embedding response" $ do
      let raw = LBS.pack "{\"sparse_embedding\":[{\"is_truncated\":false,\"embedding\":{\"tok1\":0.34,\"tok2\":1.2}}]}"
      let Just (Types.SparseEmbeddingResult [item]) = decode raw
      Types.seiIsTruncated item `shouldBe` False
      Types.seiEmbedding item `shouldBe` Map.fromList [("tok1" :: Text, 0.34), ("tok2", 1.2)]

    it "decodes a completion response" $ do
      let raw = LBS.pack "{\"completion\":[{\"result\":\"hello world\"}]}"
      let Just (Types.CompletionResult [item]) = decode raw
      Types.ciResult item `shouldBe` "hello world"

    it "decodes a rerank response" $ do
      let raw =
            LBS.pack
              "{\"rerank\":[{\"index\":5,\"relevance_score\":0.97,\"text\":\"star\"},{\"index\":0,\"relevance_score\":0.81}]}"
      let Just (Types.RerankResult items) = decode raw
      length items `shouldBe` 2
      case items of
        (first : _) -> do
          Types.riIndex first `shouldBe` 5
          Types.riRelevanceScore first `shouldBe` 0.97
          Types.riText first `shouldBe` Just "star"
        [] -> expectationFailure "expected rerank items"

    it "rejects a multi-key object" $ do
      let raw = LBS.pack "{\"completion\":[],\"rerank\":[]}"
      (decode raw :: Maybe Types.InferenceResult) `shouldBe` Nothing

    it "rejects an empty object" $ do
      let raw = LBS.pack "{}"
      (decode raw :: Maybe Types.InferenceResult) `shouldBe` Nothing

    it "rejects an unknown result key" $ do
      let raw = LBS.pack "{\"whatever\":[]}"
      (decode raw :: Maybe Types.InferenceResult) `shouldBe` Nothing

    it "round-trips a text_embedding result" $ do
      let res =
            Types.DenseEmbeddingResult
              Types.FloatEmbedding
              [Types.DenseEmbeddingItem [0.1, 0.2]]
      decode (encode res) `shouldBe` Just res

  describe "endpoint shape" $ do
    it "PUTs to /_inference/<task_type>/<inference_id> with a body" $ do
      let cfg =
            Types.InferenceConfig
              { Types.inferenceProvider =
                  Types.ElserProvider
                    Types.ElserServiceSettings
                      { Types.esserNumAllocations = 1,
                        Types.esserNumThreads = 1,
                        Types.esserAdaptiveAllocations = Nothing
                      },
                Types.inferenceChunkingSettings = Nothing
              }
      let req = RequestsES8.putInferenceEndpoint Types.SparseEmbeddingTaskType "my-elser" cfg
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_inference", "sparse_embedding", "my-elser"]
      bhRequestBody req `shouldSatisfy` isJust

    it "POSTs to /_inference/<task_type>/<inference_id> with a body" $ do
      let input = Types.InferenceInput ["hello"] Nothing Nothing Nothing
      let req = RequestsES8.runInference Types.TextEmbeddingTaskType "my-openai" input
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_inference", "text_embedding", "my-openai"]
      bhRequestBody req `shouldSatisfy` isJust

    it "DELETEs /_inference/<task_type>/<inference_id> with no body (ES8)" $ do
      let req = RequestsES8.deleteInferenceEndpoint Types.TextEmbeddingTaskType "my-openai"
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_inference", "text_embedding", "my-openai"]
      bhRequestBody req `shouldSatisfy` isNothing

    it "DELETEs encode the snake_case task_type segment (ES8)" $ do
      let req = RequestsES8.deleteInferenceEndpoint Types.SparseEmbeddingTaskType "my-elser"
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_inference", "sparse_embedding", "my-elser"]
      bhRequestBody req `shouldSatisfy` isNothing

    it "PUTs to /_inference/<task_type>/<inference_id>/_update with a body (ES8)" $ do
      let cfg =
            Types.InferenceConfig
              { Types.inferenceProvider =
                  Types.ElserProvider
                    Types.ElserServiceSettings
                      { Types.esserNumAllocations = 1,
                        Types.esserNumThreads = 1,
                        Types.esserAdaptiveAllocations = Nothing
                      },
                Types.inferenceChunkingSettings = Nothing
              }
      let req = RequestsES8.updateInferenceEndpoint Types.SparseEmbeddingTaskType "my-elser" cfg
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_inference", "sparse_embedding", "my-elser", "_update"]
      bhRequestBody req `shouldSatisfy` isJust

    it "ES9 update builder hits the same path as ES8" $ do
      let cfg =
            Types.InferenceConfig
              { Types.inferenceProvider =
                  Types.OpenAIProvider
                    (Types.OpenAIServiceSettings (Just "sk-xxx") "text-embedding-3-small" Nothing Nothing Nothing Nothing Nothing)
                    Nothing,
                Types.inferenceChunkingSettings = Nothing
              }
      let req = RequestsES9.updateInferenceEndpoint Types.TextEmbeddingTaskType "my-openai" cfg
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_inference", "text_embedding", "my-openai", "_update"]
      bhRequestBody req `shouldSatisfy` isJust

    it "ES9 request builders hit the same paths as ES8" $ do
      let cfg =
            Types.InferenceConfig
              { Types.inferenceProvider =
                  Types.ElserProvider
                    Types.ElserServiceSettings
                      { Types.esserNumAllocations = 1,
                        Types.esserNumThreads = 1,
                        Types.esserAdaptiveAllocations = Nothing
                      },
                Types.inferenceChunkingSettings = Nothing
              }
      let input = Types.InferenceInput ["q"] (Just "query") Nothing Nothing
      let putReq = RequestsES9.putInferenceEndpoint Types.RerankTaskType "r" cfg
      let postReq = RequestsES9.runInference Types.RerankTaskType "r" input
      getRawEndpoint (bhRequestEndpoint putReq) `shouldBe` ["_inference", "rerank", "r"]
      getRawEndpoint (bhRequestEndpoint postReq) `shouldBe` ["_inference", "rerank", "r"]

    it "ES9 DELETE reuses the ES8 path (no body)" $ do
      let req = RequestsES9.deleteInferenceEndpoint Types.RerankTaskType "r"
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_inference", "rerank", "r"]
      bhRequestBody req `shouldSatisfy` isNothing

    it "POSTs to /_inference/completion/<inference_id>/_stream with a body (ES8)" $ do
      let input = Types.InferenceInput ["What is Elastic?"] Nothing Nothing Nothing
      let req = RequestsES8.streamCompletionInference "my-openai" input
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_inference", "completion", "my-openai", "_stream"]
      bhRequestBody req `shouldSatisfy` isJust

    it "POSTs to /_inference/chat_completion/<inference_id>/_stream with a body (ES8)" $ do
      let chatReq =
            Types.ChatCompletionRequest
              { Types.ccrMessages =
                  [Types.ChatMessage "user" (Just (toJSON ("What is Elastic?" :: Text))) Nothing Nothing Nothing Nothing],
                Types.ccrModel = Just "gpt-4o",
                Types.ccrMaxCompletionTokens = Nothing,
                Types.ccrReasoning = Nothing,
                Types.ccrStop = Nothing,
                Types.ccrTemperature = Nothing,
                Types.ccrToolChoice = Nothing,
                Types.ccrTools = Nothing,
                Types.ccrTopP = Nothing
              }
      let req = RequestsES8.streamChatCompletionInference "my-openai" chatReq
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_inference", "chat_completion", "my-openai", "_stream"]
      bhRequestBody req `shouldSatisfy` isJust

    it "ES9 streaming builders hit the same paths as ES8" $ do
      let input = Types.InferenceInput ["q"] Nothing Nothing Nothing
      getRawEndpoint
        (bhRequestEndpoint $ RequestsES9.streamCompletionInference "c" input)
        `shouldBe` ["_inference", "completion", "c", "_stream"]
      let chatReq =
            Types.ChatCompletionRequest
              { Types.ccrMessages =
                  [Types.ChatMessage "user" (Just (toJSON ("hi" :: Text))) Nothing Nothing Nothing Nothing],
                Types.ccrModel = Nothing,
                Types.ccrMaxCompletionTokens = Nothing,
                Types.ccrReasoning = Nothing,
                Types.ccrStop = Nothing,
                Types.ccrTemperature = Nothing,
                Types.ccrToolChoice = Nothing,
                Types.ccrTools = Nothing,
                Types.ccrTopP = Nothing
              }
      getRawEndpoint
        (bhRequestEndpoint $ RequestsES9.streamChatCompletionInference "c" chatReq)
        `shouldBe` ["_inference", "chat_completion", "c", "_stream"]

  describe "deleteInferenceOptionsParams URI rendering" $ do
    it "default emits dry_run=false and force=false" $
      Types.deleteInferenceOptionsParams Types.defaultDeleteInferenceOptions
        `shouldBe` [ ("dry_run", Just "false"),
                     ("force", Just "false")
                   ]

    it "renders dry_run=true when set" $
      Types.deleteInferenceOptionsParams
        Types.defaultDeleteInferenceOptions {Types.dioDryRun = True}
        `shouldBe` [ ("dry_run", Just "true"),
                     ("force", Just "false")
                   ]

    it "renders force=true when set" $
      Types.deleteInferenceOptionsParams
        Types.defaultDeleteInferenceOptions {Types.dioForce = True}
        `shouldBe` [ ("dry_run", Just "false"),
                     ("force", Just "true")
                   ]

    it "renders both flags true together" $
      Types.deleteInferenceOptionsParams
        Types.defaultDeleteInferenceOptions {Types.dioDryRun = True, Types.dioForce = True}
        `shouldBe` [ ("dry_run", Just "true"),
                     ("force", Just "true")
                   ]

  describe "deleteInferenceEndpointWith endpoint shape" $ do
    it "attaches dry_run/force query params (ES8)" $ do
      let opts = Types.defaultDeleteInferenceOptions {Types.dioForce = True}
      let req = RequestsES8.deleteInferenceEndpointWith Types.TextEmbeddingTaskType "my-openai" opts
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_inference", "text_embedding", "my-openai"]
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [ ("dry_run", Just "false"),
                     ("force", Just "true")
                   ]
      bhRequestBody req `shouldSatisfy` isNothing

    it "shares the path of deleteInferenceEndpoint, which stays query-less (ES8)" $ do
      let plain = RequestsES8.deleteInferenceEndpoint Types.RerankTaskType "r"
      let withOpts = RequestsES8.deleteInferenceEndpointWith Types.RerankTaskType "r" Types.defaultDeleteInferenceOptions
      getRawEndpoint (bhRequestEndpoint plain)
        `shouldBe` getRawEndpoint (bhRequestEndpoint withOpts)
      getRawEndpointQueries (bhRequestEndpoint plain) `shouldBe` []

    it "ES9 With reuses the ES8 builder" $ do
      let opts = Types.defaultDeleteInferenceOptions {Types.dioDryRun = True}
      let req = RequestsES9.deleteInferenceEndpointWith Types.RerankTaskType "r" opts
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_inference", "rerank", "r"]
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [ ("dry_run", Just "true"),
                     ("force", Just "false")
                   ]
      bhRequestBody req `shouldSatisfy` isNothing

  describe "inference timeout options URI rendering" $ do
    it "putInferenceOptionsParams default emits no params" $
      Types.putInferenceOptionsParams Types.defaultPutInferenceOptions
        `shouldBe` []
    it "putInferenceOptionsParams renders timeout (Seconds)" $
      Types.putInferenceOptionsParams
        Types.defaultPutInferenceOptions {Types.pioTimeout = Just (Types.TimeUnitSeconds, 30)}
        `shouldBe` [("timeout", Just "30s")]
    it "runInferenceOptionsParams renders timeout (Milliseconds)" $
      Types.runInferenceOptionsParams
        Types.defaultRunInferenceOptions {Types.rnioTimeout = Just (Types.TimeUnitMilliseconds, 500)}
        `shouldBe` [("timeout", Just "500ms")]
    it "streamCompletionInferenceOptionsParams renders timeout" $
      Types.streamCompletionInferenceOptionsParams
        Types.defaultStreamCompletionInferenceOptions {Types.scioTimeout = Just (Types.TimeUnitSeconds, 5)}
        `shouldBe` [("timeout", Just "5s")]
    it "streamChatCompletionInferenceOptionsParams renders timeout (Minutes)" $
      Types.streamChatCompletionInferenceOptionsParams
        Types.defaultStreamChatCompletionInferenceOptions {Types.sccioTimeout = Just (Types.TimeUnitMinutes, 2)}
        `shouldBe` [("timeout", Just "2m")]

  describe "putInferenceEndpointWith endpoint shape" $ do
    let cfg =
          Types.InferenceConfig
            { Types.inferenceProvider =
                Types.ElserProvider
                  Types.ElserServiceSettings
                    { Types.esserNumAllocations = 1,
                      Types.esserNumThreads = 1,
                      Types.esserAdaptiveAllocations = Nothing
                    },
              Types.inferenceChunkingSettings = Nothing
            }
    it "is byte-identical to putInferenceEndpoint with default opts (ES8)" $ do
      let plain = RequestsES8.putInferenceEndpoint Types.SparseEmbeddingTaskType "my-elser" cfg
          withOpts = RequestsES8.putInferenceEndpointWith Types.SparseEmbeddingTaskType "my-elser" cfg Types.defaultPutInferenceOptions
      getRawEndpoint (bhRequestEndpoint plain) `shouldBe` getRawEndpoint (bhRequestEndpoint withOpts)
      getRawEndpointQueries (bhRequestEndpoint plain) `shouldBe` []
      getRawEndpointQueries (bhRequestEndpoint withOpts) `shouldBe` []
    it "attaches ?timeout=30s with opts (ES8)" $ do
      let opts = Types.defaultPutInferenceOptions {Types.pioTimeout = Just (Types.TimeUnitSeconds, 30)}
          req = RequestsES8.putInferenceEndpointWith Types.TextEmbeddingTaskType "my-openai" cfg opts
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_inference", "text_embedding", "my-openai"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` [("timeout", Just "30s")]
    it "ES9 With reuses the ES8 builder" $ do
      let opts = Types.defaultPutInferenceOptions {Types.pioTimeout = Just (Types.TimeUnitSeconds, 30)}
          req = RequestsES9.putInferenceEndpointWith Types.RerankTaskType "r" cfg opts
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_inference", "rerank", "r"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` [("timeout", Just "30s")]

  describe "runInferenceWith endpoint shape" $ do
    let input = Types.InferenceInput ["q"] Nothing Nothing Nothing
    it "is byte-identical to runInference with default opts (ES8)" $ do
      let plain = RequestsES8.runInference Types.TextEmbeddingTaskType "my-openai" input
          withOpts = RequestsES8.runInferenceWith Types.TextEmbeddingTaskType "my-openai" input Types.defaultRunInferenceOptions
      getRawEndpoint (bhRequestEndpoint plain) `shouldBe` getRawEndpoint (bhRequestEndpoint withOpts)
      getRawEndpointQueries (bhRequestEndpoint plain) `shouldBe` []
    it "attaches ?timeout=10s with opts (ES8)" $ do
      let opts = Types.defaultRunInferenceOptions {Types.rnioTimeout = Just (Types.TimeUnitSeconds, 10)}
          req = RequestsES8.runInferenceWith Types.TextEmbeddingTaskType "my-openai" input opts
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_inference", "text_embedding", "my-openai"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` [("timeout", Just "10s")]
    it "ES9 With reuses the ES8 builder" $ do
      let opts = Types.defaultRunInferenceOptions {Types.rnioTimeout = Just (Types.TimeUnitSeconds, 10)}
          req = RequestsES9.runInferenceWith Types.RerankTaskType "r" input opts
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_inference", "rerank", "r"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` [("timeout", Just "10s")]

  describe "streamCompletionInferenceWith endpoint shape" $ do
    let input = Types.InferenceInput ["q"] Nothing Nothing Nothing
    it "is byte-identical to streamCompletionInference with default opts (ES8)" $ do
      let plain = RequestsES8.streamCompletionInference "my-openai" input
          withOpts = RequestsES8.streamCompletionInferenceWith "my-openai" input Types.defaultStreamCompletionInferenceOptions
      getRawEndpoint (bhRequestEndpoint plain) `shouldBe` getRawEndpoint (bhRequestEndpoint withOpts)
      getRawEndpointQueries (bhRequestEndpoint plain) `shouldBe` []
    it "attaches ?timeout=5s with opts (ES8)" $ do
      let opts = Types.defaultStreamCompletionInferenceOptions {Types.scioTimeout = Just (Types.TimeUnitSeconds, 5)}
          req = RequestsES8.streamCompletionInferenceWith "my-openai" input opts
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_inference", "completion", "my-openai", "_stream"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` [("timeout", Just "5s")]
    it "ES9 With reuses the ES8 builder" $ do
      let opts = Types.defaultStreamCompletionInferenceOptions {Types.scioTimeout = Just (Types.TimeUnitSeconds, 5)}
          req = RequestsES9.streamCompletionInferenceWith "c" input opts
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_inference", "completion", "c", "_stream"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` [("timeout", Just "5s")]

  describe "streamChatCompletionInferenceWith endpoint shape" $ do
    let chatReq =
          Types.ChatCompletionRequest
            { Types.ccrMessages = [Types.ChatMessage "user" (Just (toJSON ("hi" :: Text))) Nothing Nothing Nothing Nothing],
              Types.ccrModel = Nothing,
              Types.ccrMaxCompletionTokens = Nothing,
              Types.ccrReasoning = Nothing,
              Types.ccrStop = Nothing,
              Types.ccrTemperature = Nothing,
              Types.ccrToolChoice = Nothing,
              Types.ccrTools = Nothing,
              Types.ccrTopP = Nothing
            }
    it "is byte-identical to streamChatCompletionInference with default opts (ES8)" $ do
      let plain = RequestsES8.streamChatCompletionInference "my-openai" chatReq
          withOpts = RequestsES8.streamChatCompletionInferenceWith "my-openai" chatReq Types.defaultStreamChatCompletionInferenceOptions
      getRawEndpoint (bhRequestEndpoint plain) `shouldBe` getRawEndpoint (bhRequestEndpoint withOpts)
      getRawEndpointQueries (bhRequestEndpoint plain) `shouldBe` []
    it "attaches ?timeout=5s with opts (ES8)" $ do
      let opts = Types.defaultStreamChatCompletionInferenceOptions {Types.sccioTimeout = Just (Types.TimeUnitSeconds, 5)}
          req = RequestsES8.streamChatCompletionInferenceWith "my-openai" chatReq opts
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_inference", "chat_completion", "my-openai", "_stream"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` [("timeout", Just "5s")]
    it "ES9 With reuses the ES8 builder" $ do
      let opts = Types.defaultStreamChatCompletionInferenceOptions {Types.sccioTimeout = Just (Types.TimeUnitSeconds, 5)}
          req = RequestsES9.streamChatCompletionInferenceWith "c" chatReq opts
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_inference", "chat_completion", "c", "_stream"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` [("timeout", Just "5s")]

  describe "GET /_inference endpoint shape" $ do
    it "lists every endpoint when no filter is given (ES8)" $ do
      let req = RequestsES8.getInferenceEndpoints Nothing Nothing
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_inference"]
      bhRequestBody req `shouldSatisfy` isNothing

    it "narrows to a task type when only TaskType is given (ES8)" $ do
      let req = RequestsES8.getInferenceEndpoints (Just Types.TextEmbeddingTaskType) Nothing
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_inference", "text_embedding", "_all"]
      bhRequestBody req `shouldSatisfy` isNothing

    it "targets a specific endpoint when both are given (ES8)" $ do
      let req =
            RequestsES8.getInferenceEndpoints
              (Just Types.SparseEmbeddingTaskType)
              (Just "my-elser")
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_inference", "sparse_embedding", "my-elser"]
      bhRequestBody req `shouldSatisfy` isNothing

    it "encodes the snake_case task_type segment (ES8)" $ do
      let req = RequestsES8.getInferenceEndpoints (Just Types.ChatCompletionTaskType) Nothing
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_inference", "chat_completion", "_all"]

    it "falls back to list-all when only InferenceId is given (ES8)" $ do
      -- (Nothing, Just iid) is not a valid ES URL; the builder drops the
      -- bare id and lists every endpoint instead of synthesising an
      -- unsupported path.
      let req = RequestsES8.getInferenceEndpoints Nothing (Just "orphan-id")
      getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_inference"]

    it "ES9 request builder hits the same paths as ES8" $ do
      getRawEndpoint
        (bhRequestEndpoint $ RequestsES9.getInferenceEndpoints Nothing Nothing)
        `shouldBe` ["_inference"]
      getRawEndpoint
        ( bhRequestEndpoint $
            RequestsES9.getInferenceEndpoints (Just Types.RerankTaskType) Nothing
        )
        `shouldBe` ["_inference", "rerank", "_all"]
      getRawEndpoint
        ( bhRequestEndpoint $
            RequestsES9.getInferenceEndpoints
              (Just Types.RerankTaskType)
              (Just "r")
        )
        `shouldBe` ["_inference", "rerank", "r"]

  describe "InferenceInfo JSON" $ do
    it "decodes a single-endpoint response, splitting the key" $ do
      let raw =
            LBS.pack
              "{\"text_embedding/my-openai\":{\"service\":\"openai\",\"service_settings\":{\"model_id\":\"text-embedding-3-small\",\"api_key\":null}}}"
      case decodeInferenceList raw of
        Just [info] -> do
          Types.infTaskType info `shouldBe` Types.TextEmbeddingTaskType
          Types.infInferenceId info `shouldBe` "my-openai"
          Types.infService info `shouldBe` "openai"
          Types.infServiceSettings info `shouldSatisfy` isJust
          Types.infTaskSettings info `shouldSatisfy` isNothing
          Types.infChunkingSettings info `shouldSatisfy` isNothing
        other ->
          expectationFailure $ "expected one InferenceInfo, got: " <> show other

    it "decodes a multi-endpoint response into a list" $ do
      let raw =
            LBS.pack
              "{\"text_embedding/a\":{\"service\":\"openai\"},\"rerank/b\":{\"service\":\"cohere\",\"task_settings\":{\"top_n\":5}}}"
      case decodeInferenceList raw of
        Just infos -> length infos `shouldBe` 2
        Nothing -> expectationFailure "failed to decode multi-endpoint response"

    it "decodes when task_settings and chunking_settings are present" $ do
      let raw =
            LBS.pack
              "{\"completion/c\":{\"service\":\"openai\",\"task_settings\":{\"user\":\"x\"},\"chunking_settings\":{\"strategy\":\"sentence\"}}}"
      case decodeInferenceList raw of
        Just [info] -> do
          Types.infTaskType info `shouldBe` Types.CompletionTaskType
          Types.infInferenceId info `shouldBe` "c"
          Types.infTaskSettings info `shouldSatisfy` isJust
          Types.infChunkingSettings info `shouldSatisfy` isJust
        other ->
          expectationFailure $ "expected one InferenceInfo, got: " <> show other

    it "tolerates a null api_key in service_settings" $ do
      let raw =
            LBS.pack
              "{\"text_embedding/d\":{\"service\":\"openai\",\"service_settings\":{\"api_key\":null}}}"
      case decodeInferenceList raw of
        Just [info] ->
          Types.infServiceSettings info
            `shouldBe` Just (object ["api_key" .= Null])
        other ->
          expectationFailure $ "expected exactly one InferenceInfo, got: " <> show other

    it "rejects a key without a '/' separator" $ do
      let raw = LBS.pack "{\"no-separator\":{\"service\":\"openai\"}}"
      decodeInferenceList raw `shouldBe` Nothing

    it "rejects an unknown task_type in the key" $ do
      let raw = LBS.pack "{\"bogus_type/x\":{\"service\":\"openai\"}}"
      decodeInferenceList raw `shouldBe` Nothing

    it "decodes an empty response as the empty list" $ do
      let raw = LBS.pack "{}"
      decodeInferenceList raw `shouldBe` Just []

    it "round-trips a bare InferenceInfo (direct FromJSON/ToJSON)" $ do
      let info =
            Types.InferenceInfo
              { Types.infTaskType = Types.TextEmbeddingTaskType,
                Types.infInferenceId = "my-openai",
                Types.infService = "openai",
                Types.infServiceSettings = Just (object ["model_id" .= ("text-embedding-3-small" :: Text)]),
                Types.infTaskSettings = Nothing,
                Types.infChunkingSettings = Nothing
              }
      (decode (encode info) :: Maybe Types.InferenceInfo) `shouldBe` Just info

    it "decodes a bare InferenceInfo object (no key wrapper)" $ do
      let raw =
            LBS.pack
              "{\"task_type\":\"rerank\",\"inference_id\":\"r1\",\"service\":\"cohere\",\"task_settings\":{\"top_n\":5}}"
      case (decode raw :: Maybe Types.InferenceInfo) of
        Just info -> do
          Types.infTaskType info `shouldBe` Types.RerankTaskType
          Types.infInferenceId info `shouldBe` "r1"
          Types.infService info `shouldBe` "cohere"
          Types.infTaskSettings info `shouldSatisfy` isJust
        Nothing -> expectationFailure "expected InferenceInfo, got Nothing"

    it "InferenceEndpointsResponse decodes the per-id {\"endpoints\":[...]} envelope" $ do
      -- GET /_inference/{task_type}/{inference_id} returns this shape, which
      -- must decode to the same [InferenceInfo] as the list envelope.
      let raw =
            LBS.pack
              "{\"endpoints\":[{\"task_type\":\"text_embedding\",\"inference_id\":\"x\",\"service\":\"openai\"}]}"
      decodeInferenceList raw `shouldBe` Just [Types.InferenceInfo Types.TextEmbeddingTaskType "x" "openai" Nothing Nothing Nothing]

  describe "live integration (requires ES8+ backend)" $
    backendSpecific [ElasticSearch8, ElasticSearch9] $ do
      it "rejects an unknown inference service with a 4xx error" $
        withTestEnv $ do
          let bogusSettings = object ["model_id" .= ("nope" :: Text)]
              cfg =
                Types.InferenceConfig
                  { Types.inferenceProvider =
                      Types.GenericProvider "definitely_not_a_real_service" bogusSettings Nothing,
                    Types.inferenceChunkingSettings = Nothing
                  }
          result <- do
            backend <- liftIO detectBackendType
            if backend == Just ElasticSearch9
              then ClientES9.putInferenceEndpoint Types.TextEmbeddingTaskType "bloodhound-test-bogus" cfg
              else ClientES8.putInferenceEndpoint Types.TextEmbeddingTaskType "bloodhound-test-bogus" cfg
          case result of
            Left e -> do
              case errorStatus e of
                Just s -> liftIO $ (s >= 400 && s < 500) `shouldBe` True
                Nothing ->
                  liftIO $
                    expectationFailure $
                      "expected a 4xx status, got none. Message: " <> show (errorMessage e)
            Right _ ->
              liftIO $
                expectationFailure
                  "expected the bogus inference config to be rejected, but it succeeded"

      it "rejects DELETE of a non-existent inference endpoint with a 4xx error" $
        withTestEnv $ do
          -- ES9 reuses the ES8 request builder verbatim, so a single call
          -- covers both backends; 'tryPerformBHRequest' surfaces the 4xx as
          -- 'Left EsError'.
          result <-
            tryPerformBHRequest $
              RequestsES8.deleteInferenceEndpoint Types.TextEmbeddingTaskType "bloodhound-test-definitely-missing"
          case result of
            Left e ->
              case errorStatus e of
                Just s -> liftIO $ (s >= 400 && s < 500) `shouldBe` True
                Nothing ->
                  liftIO $
                    expectationFailure $
                      "expected a 4xx status, got none. Message: " <> show (errorMessage e)
            Right _ ->
              liftIO $
                expectationFailure
                  "expected DELETE of a missing inference endpoint to fail, but it succeeded"