packages feed

bloodhound-1.0.0.0: tests/Test/MLModelSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module Test.MLModelSpec (spec) where

import Control.Monad.IO.Class (liftIO)
import Data.Aeson (Value (..), decode, eitherDecode, encode, object, (.=))
import Data.ByteString.Char8 qualified as BS
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.Either (isLeft)
import Data.HashMap.Strict (HashMap)
import Data.HashMap.Strict qualified as HashMap
import Data.Int (Int64)
import Data.Map.Strict qualified as M
import Data.Text qualified as Text
import Data.Time.Clock.POSIX (getPOSIXTime)
import Database.Bloodhound.Client.Cluster (BH)
import Database.Bloodhound.OpenSearch1.Requests qualified as OS1Requests
import Database.Bloodhound.OpenSearch1.Types qualified as OS1Types
import Database.Bloodhound.OpenSearch2.Requests qualified as OS2Requests
import Database.Bloodhound.OpenSearch2.Types qualified as OS2Types
import Database.Bloodhound.OpenSearch3.Client qualified as OS3Client
import Database.Bloodhound.OpenSearch3.Requests qualified as OS3Requests
import Database.Bloodhound.OpenSearch3.Types qualified as OS3Types
import TestsUtils.Common
import TestsUtils.Import
import Prelude

-- =========================================================================
-- Sample fixtures (literal JSON copied from the OS ML Commons docs)
-- =========================================================================

-- | The canonical GET model response: a bare object with all metadata
-- fields populated for a deployed pretrained embedding model. Drawn from
-- <https://docs.opensearch.org/latest/ml-commons-plugin/api/model-apis/get-model/>.
sampleGetResponse :: LBS.ByteString
sampleGetResponse =
  "{\
  \  \"name\" : \"all-MiniLM-L6-v2_onnx\",\
  \  \"algorithm\" : \"TEXT_EMBEDDING\",\
  \  \"version\" : \"1\",\
  \  \"model_format\" : \"TORCH_SCRIPT\",\
  \  \"model_state\" : \"DEPLOYED\",\
  \  \"model_content_size_in_bytes\" : 83408741,\
  \  \"model_content_hash_value\" : \"9376c2ebd7c83f99ec2526323786c348d2382e6d86576f750c89ea544d6bbb14\",\
  \  \"model_config\" : {\
  \      \"model_type\" : \"bert\",\
  \      \"embedding_dimension\" : 384,\
  \      \"framework_type\" : \"SENTENCE_TRANSFORMERS\",\
  \      \"all_config\" : \"{\\\"_name_or_path\\\":\\\"nreimers/MiniLM-L6-H384uncased\\\"}\"\
  \  },\
  \  \"created_time\" : 1665961344044,\
  \  \"last_uploaded_time\" : 1665961373000,\
  \  \"last_loaded_time\" : 1665961815959,\
  \  \"total_chunks\" : 9\
  \}"

-- | A minimal GET response with only the required fields present. Proves
-- the decoder accepts sparse payloads (optional fields absent).
sampleMinimalGetResponse :: LBS.ByteString
sampleMinimalGetResponse =
  "{\
  \  \"name\" : \"my-model\",\
  \  \"algorithm\" : \"TEXT_EMBEDDING\",\
  \  \"version\" : \"1.0.0\"\
  \}"

-- | Register response for both pretrained and custom variants. The same
-- shape is returned by every register-model variant.
sampleRegisterResponse :: LBS.ByteString
sampleRegisterResponse =
  "{ \"task_id\": \"ew8I44MBhyWuIwnfvDIH\", \"status\": \"CREATED\", \"model_id\": \"t8qvDY4BChVAiNVEuo8q\" }"

-- | Deploy response: same envelope as register minus @model_id@, plus
-- @task_type@. 'MLTaskAck' parses both with the same record.
sampleDeployResponse :: LBS.ByteString
sampleDeployResponse =
  "{ \"task_id\": \"hA8P44MBhyWuIwnfvTKP\", \"task_type\": \"DEPLOY_MODEL\", \"status\": \"CREATED\" }"

-- | Delete-model response: OpenSearch stores models as documents in
-- @.plugins-ml-model@, so DELETE returns the standard document-delete
-- envelope (parsed here as 'IndexedDocument'), not an ack. Drawn from
-- <https://docs.opensearch.org/latest/ml-commons-plugin/api/model-apis/delete-model/>.
sampleDeleteModelResponse :: LBS.ByteString
sampleDeleteModelResponse =
  "{\
  \  \"_index\": \".plugins-ml-model\",\
  \  \"_id\": \"MzcIJ4MBpMjr0oYyaTn9\",\
  \  \"_version\": 2,\
  \  \"result\": \"deleted\",\
  \  \"forced_refresh\": true,\
  \  \"_shards\": {\
  \    \"total\": 2,\
  \    \"successful\": 2,\
  \    \"failed\": 0\
  \  },\
  \  \"_seq_no\": 3,\
  \  \"_primary_term\": 1\
  \}"

spec :: Spec
spec = do
  describe "ML Model API types" $ do
    describe "ModelId / AlgorithmName JSON" $ do
      it "ModelId round-trips as a bare JSON string" $ do
        encode (OS3Types.ModelId "model-123") `shouldBe` "\"model-123\""
        decode "\"abc\"" `shouldBe` Just (OS3Types.ModelId "abc")

      it "AlgorithmName round-trips as a bare JSON string" $ do
        encode (OS3Types.AlgorithmName "text_embedding")
          `shouldBe` "\"text_embedding\""
        decode "\"remote\"" `shouldBe` Just (OS3Types.AlgorithmName "remote")

    describe "ModelFormat JSON" $ do
      it "encodes the two documented formats" $ do
        encode OS3Types.ModelFormatTorchScript `shouldBe` "\"TORCH_SCRIPT\""
        encode OS3Types.ModelFormatOnnx `shouldBe` "\"ONNX\""

      it "decodes the two documented formats" $ do
        decode "\"TORCH_SCRIPT\"" `shouldBe` Just OS3Types.ModelFormatTorchScript
        decode "\"ONNX\"" `shouldBe` Just OS3Types.ModelFormatOnnx

      it "rejects an unknown format" $ do
        let parsed = eitherDecode "\"FAKE\"" :: Either String OS3Types.ModelFormat
        parsed `shouldSatisfy` isLeft

    describe "ModelState JSON" $ do
      let allStates =
            [ (OS3Types.ModelStateRegistering, "REGISTERING"),
              (OS3Types.ModelStateRegistered, "REGISTERED"),
              (OS3Types.ModelStateDeploying, "DEPLOYING"),
              (OS3Types.ModelStateDeployed, "DEPLOYED"),
              (OS3Types.ModelStatePartiallyDeployed, "PARTIALLY_DEPLOYED"),
              (OS3Types.ModelStateUndeployed, "UNDEPLOYED"),
              (OS3Types.ModelStateDeployFailed, "DEPLOY_FAILED")
            ]
      it "encodes every documented state" $
        mapM_
          (\(state, lit) -> encode state `shouldBe` "\"" <> lit <> "\"")
          allStates

      it "decodes every documented state" $
        mapM_
          (\(state, lit) -> decode ("\"" <> lit <> "\"") `shouldBe` Just state)
          allStates

      it "rejects an unknown state" $ do
        let parsed = eitherDecode "\"BOGUS\"" :: Either String OS3Types.ModelState
        parsed `shouldSatisfy` isLeft

    describe "ModelConfig JSON" $ do
      it "decodes a full config with all_config" $ do
        let Just decoded =
              decode
                "{ \"model_type\": \"bert\"\
                \, \"embedding_dimension\": 384\
                \, \"framework_type\": \"SENTENCE_TRANSFORMERS\"\
                \, \"all_config\": \"{\\\"foo\\\":1}\"\
                \, \"pooling_mode\": \"mean\"\
                \, \"normalize_result\": true\
                \ }" ::
                Maybe OS3Types.ModelConfig
        OS3Types.modelConfigModelType decoded `shouldBe` "bert"
        OS3Types.modelConfigEmbeddingDimension decoded `shouldBe` 384
        OS3Types.modelConfigFrameworkType decoded `shouldBe` "SENTENCE_TRANSFORMERS"
        OS3Types.modelConfigPoolingMode decoded `shouldBe` Just "mean"
        OS3Types.modelConfigNormalizeResult decoded `shouldBe` Just True

      it "decodes a minimal config (required fields only)" $ do
        let Just decoded =
              decode
                "{ \"model_type\": \"bert\"\
                \, \"embedding_dimension\": 768\
                \, \"framework_type\": \"huggingface_transformers\"\
                \ }" ::
                Maybe OS3Types.ModelConfig
        OS3Types.modelConfigAllConfig decoded `shouldBe` Nothing
        OS3Types.modelConfigPoolingMode decoded `shouldBe` Nothing

      it "round-trips through encode -> decode" $ do
        let original =
              OS3Types.ModelConfig
                { OS3Types.modelConfigModelType = "bert",
                  OS3Types.modelConfigEmbeddingDimension = 384,
                  OS3Types.modelConfigFrameworkType = "SENTENCE_TRANSFORMERS",
                  OS3Types.modelConfigAllConfig = Just "{}",
                  OS3Types.modelConfigAdditionalConfig = Nothing,
                  OS3Types.modelConfigPoolingMode = Nothing,
                  OS3Types.modelConfigNormalizeResult = Nothing
                }
        (decode . encode) original `shouldBe` Just original

    describe "ModelInfo JSON" $ do
      it "decodes the canonical full GET response" $ do
        let Just decoded = decode sampleGetResponse :: Maybe OS3Types.ModelInfo
        OS3Types.modelInfoName decoded `shouldBe` "all-MiniLM-L6-v2_onnx"
        OS3Types.modelInfoAlgorithm decoded `shouldBe` "TEXT_EMBEDDING"
        OS3Types.modelInfoVersion decoded `shouldBe` "1"
        OS3Types.modelInfoModelFormat decoded `shouldBe` Just OS3Types.ModelFormatTorchScript
        OS3Types.modelInfoModelState decoded `shouldBe` Just OS3Types.ModelStateDeployed
        OS3Types.modelInfoModelContentSizeInBytes decoded `shouldBe` Just 83408741
        OS3Types.modelInfoTotalChunks decoded `shouldBe` Just 9
        OS3Types.modelInfoCreatedTime decoded `shouldBe` Just 1665961344044

      it "decodes the embedded ModelConfig" $ do
        let Just decoded = decode sampleGetResponse :: Maybe OS3Types.ModelInfo
        let Just cfg = OS3Types.modelInfoModelConfig decoded
        OS3Types.modelConfigModelType cfg `shouldBe` "bert"
        OS3Types.modelConfigEmbeddingDimension cfg `shouldBe` 384

      it "decodes a minimal response (optional fields absent)" $ do
        let Just decoded = decode sampleMinimalGetResponse :: Maybe OS3Types.ModelInfo
        OS3Types.modelInfoName decoded `shouldBe` "my-model"
        OS3Types.modelInfoModelFormat decoded `shouldBe` Nothing
        OS3Types.modelInfoModelState decoded `shouldBe` Nothing
        OS3Types.modelInfoModelConfig decoded `shouldBe` Nothing

      it "round-trips the canonical response" $ do
        let Just decoded = decode sampleGetResponse :: Maybe OS3Types.ModelInfo
        (decode . encode) decoded `shouldBe` Just decoded

      it "rejects a top-level null" $ do
        (decode "null" :: Maybe OS3Types.ModelInfo) `shouldBe` Nothing

      it "rejects a top-level array" $ do
        (decode "[]" :: Maybe OS3Types.ModelInfo) `shouldBe` Nothing

      it "rejects a top-level scalar" $ do
        (decode "42" :: Maybe OS3Types.ModelInfo) `shouldBe` Nothing

      it "accepts model_version key (spec GET schema)" $ do
        let Just decoded = decode "{\"name\":\"m\",\"algorithm\":\"TEXT_EMBEDDING\",\"model_version\":\"2.1\"}" :: Maybe OS3Types.ModelInfo
        OS3Types.modelInfoVersion decoded `shouldBe` "2.1"

      it "prefers model_version over version when both are present" $ do
        let Just decoded = decode "{\"name\":\"m\",\"algorithm\":\"X\",\"model_version\":\"2\",\"version\":\"1\"}" :: Maybe OS3Types.ModelInfo
        OS3Types.modelInfoVersion decoded `shouldBe` "2"

    describe "RegisterModelRequest JSON" $ do
      it "encodes a pretrained-variant request" $ do
        let req =
              OS3Types.RegisterModelRequest
                { OS3Types.registerModelName = "huggingface/sentence-transformers/msmarco-distilbert-base-tas-b",
                  OS3Types.registerModelVersion = Just "1.0.3",
                  OS3Types.registerModelDescription = Nothing,
                  OS3Types.registerModelFormat = Just OS3Types.ModelFormatTorchScript,
                  OS3Types.registerModelFunctionName = Nothing,
                  OS3Types.registerModelContentHashValue = Nothing,
                  OS3Types.registerModelConfig = Nothing,
                  OS3Types.registerModelUrl = Nothing,
                  OS3Types.registerModelGroupId = Just "Z1eQf4oB5Vm0Tdw8EIP2",
                  OS3Types.registerModelConnectorId = Nothing,
                  OS3Types.registerModelConnector = Nothing,
                  OS3Types.registerModelIsEnabled = Nothing,
                  OS3Types.registerModelProvisionedBy = Nothing
                }
            encoded = LBS.toStrict (encode req)
        encoded `shouldSatisfy` BS.isInfixOf "\"name\":\"huggingface/sentence-transformers/msmarco-distilbert-base-tas-b\""
        encoded `shouldSatisfy` BS.isInfixOf "\"model_format\":\"TORCH_SCRIPT\""
        encoded `shouldSatisfy` BS.isInfixOf "\"model_group_id\":\"Z1eQf4oB5Vm0Tdw8EIP2\""
        -- omitNulls drops the Nothing fields
        encoded `shouldNotSatisfy` BS.isInfixOf "\"description\""
        encoded `shouldNotSatisfy` BS.isInfixOf "\"connector\""

      it "round-trips a custom-variant request with model_config" $ do
        let original =
              OS3Types.RegisterModelRequest
                { OS3Types.registerModelName = "all-MiniLM-L6-v2",
                  OS3Types.registerModelVersion = Just "1.0.0",
                  OS3Types.registerModelDescription = Just "test model",
                  OS3Types.registerModelFormat = Just OS3Types.ModelFormatTorchScript,
                  OS3Types.registerModelFunctionName = Just "TEXT_EMBEDDING",
                  OS3Types.registerModelContentHashValue =
                    Just "c15f0d2e62d872be5b5bc6c84d2e0f4921541e29fefbef51d59cc10a8ae30e0f",
                  OS3Types.registerModelConfig =
                    Just
                      OS3Types.ModelConfig
                        { OS3Types.modelConfigModelType = "bert",
                          OS3Types.modelConfigEmbeddingDimension = 384,
                          OS3Types.modelConfigFrameworkType = "sentence_transformers",
                          OS3Types.modelConfigAllConfig = Nothing,
                          OS3Types.modelConfigAdditionalConfig = Nothing,
                          OS3Types.modelConfigPoolingMode = Nothing,
                          OS3Types.modelConfigNormalizeResult = Nothing
                        },
                  OS3Types.registerModelUrl =
                    Just "https://example.com/model.zip",
                  OS3Types.registerModelGroupId = Nothing,
                  OS3Types.registerModelConnectorId = Nothing,
                  OS3Types.registerModelConnector = Nothing,
                  OS3Types.registerModelIsEnabled = Nothing,
                  OS3Types.registerModelProvisionedBy = Nothing
                }
        (decode . encode) original `shouldBe` Just original

      it "encodes provisioned_by when set and omits it when Nothing" $ do
        let withProvider =
              OS3Types.RegisterModelRequest
                { OS3Types.registerModelName = "remote-clf",
                  OS3Types.registerModelVersion = Nothing,
                  OS3Types.registerModelDescription = Nothing,
                  OS3Types.registerModelFormat = Nothing,
                  OS3Types.registerModelFunctionName = Nothing,
                  OS3Types.registerModelContentHashValue = Nothing,
                  OS3Types.registerModelConfig = Nothing,
                  OS3Types.registerModelUrl = Nothing,
                  OS3Types.registerModelGroupId = Nothing,
                  OS3Types.registerModelConnectorId = Just "abc123",
                  OS3Types.registerModelConnector = Nothing,
                  OS3Types.registerModelIsEnabled = Nothing,
                  OS3Types.registerModelProvisionedBy = Just "flow-framework"
                }
            encodedWith = LBS.toStrict (encode withProvider)
        encodedWith `shouldSatisfy` BS.isInfixOf "\"provisioned_by\":\"flow-framework\""
        let withoutProvider = withProvider {OS3Types.registerModelProvisionedBy = Nothing}
            encodedWithout = LBS.toStrict (encode withoutProvider)
        encodedWithout `shouldNotSatisfy` BS.isInfixOf "provisioned_by"

    describe "MLTaskAck JSON" $ do
      it "decodes a register response (carries model_id)" $ do
        let Just decoded = decode sampleRegisterResponse :: Maybe OS3Types.MLTaskAck
        OS3Types.mlTaskAckTaskId decoded `shouldBe` Just "ew8I44MBhyWuIwnfvDIH"
        OS3Types.mlTaskAckStatus decoded `shouldBe` Just "CREATED"
        OS3Types.mlTaskAckModelId decoded `shouldBe` Just "t8qvDY4BChVAiNVEuo8q"
        OS3Types.mlTaskAckTaskType decoded `shouldBe` Nothing

      it "decodes a deploy response (carries task_type, no model_id)" $ do
        let Just decoded = decode sampleDeployResponse :: Maybe OS3Types.MLTaskAck
        OS3Types.mlTaskAckTaskId decoded `shouldBe` Just "hA8P44MBhyWuIwnfvTKP"
        OS3Types.mlTaskAckTaskType decoded `shouldBe` Just "DEPLOY_MODEL"
        OS3Types.mlTaskAckModelId decoded `shouldBe` Nothing

      it "round-trips the register ack" $ do
        let Just decoded = decode sampleRegisterResponse :: Maybe OS3Types.MLTaskAck
        (decode . encode) decoded `shouldBe` Just decoded

    describe "DeployModelRequest JSON" $ do
      it "encodes a request with node_ids" $ do
        let req = OS3Types.DeployModelRequest (Just ["node-1", "node-2"])
            encoded = LBS.toStrict (encode req)
        encoded `shouldSatisfy` BS.isInfixOf "\"node_ids\":[\"node-1\",\"node-2\"]"

      it "encodes a request without node_ids as {}" $ do
        let req = OS3Types.DeployModelRequest Nothing
        encode req `shouldBe` "{}"

    describe "deleteModel response envelope" $ do
      -- Models live as documents in @.plugins-ml-model@, so the DELETE
      -- response is the standard document-delete envelope decoded as
      -- 'IndexedDocument' (the same type 'deleteISMPolicy' uses), not an
      -- 'Acknowledged' ack.
      it "decodes the document-delete envelope" $ do
        let Just decoded = decode sampleDeleteModelResponse :: Maybe IndexedDocument
        idxDocIndex decoded `shouldBe` ".plugins-ml-model"
        idxDocId decoded `shouldBe` "MzcIJ4MBpMjr0oYyaTn9"
        idxDocVersion decoded `shouldBe` 2
        idxDocResult decoded `shouldBe` "deleted"
        idxDocSeqNo decoded `shouldBe` 3
        idxDocPrimaryTerm decoded `shouldBe` 1

      it "rejects a body missing the _shards object" $ do
        let parsed =
              decode
                "{ \"_index\": \".plugins-ml-model\"\
                \, \"_id\": \"x\"\
                \, \"_version\": 1\
                \, \"result\": \"deleted\"\
                \, \"_seq_no\": 0\
                \, \"_primary_term\": 1\
                \ }" ::
                Maybe IndexedDocument
        parsed `shouldBe` Nothing

    describe "UndeployModelResponse JSON" $ do
      let undeployResp =
            "{\
            \  \"K4uwY8Bi5E5jEzpvT7ha\": {\
            \    \"stats\": {\
            \      \"N3fQYoB0a2IPzwnEYE\": \"UNDEPLOYED\"\
            \    }\
            \  }\
            \}"
      it "decodes a node-keyed stats map" $ do
        let Just decoded = decode undeployResp :: Maybe OS3Types.UndeployModelResponse
        M.keys (OS3Types.undeployModelResponseNodes decoded)
          `shouldBe` ["K4uwY8Bi5E5jEzpvT7ha"]
      it "decodes the inner stats map" $ do
        let Just decoded = decode undeployResp :: Maybe OS3Types.UndeployModelResponse
        let Just nodeStats =
              M.lookup
                "K4uwY8Bi5E5jEzpvT7ha"
                (OS3Types.undeployModelResponseNodes decoded)
        M.toList (OS3Types.modelNodeUndeployStatsStats nodeStats)
          `shouldBe` [("N3fQYoB0a2IPzwnEYE", "UNDEPLOYED")]
      it "round-trips the full response" $ do
        let Just decoded = decode undeployResp :: Maybe OS3Types.UndeployModelResponse
        (decode . encode) decoded `shouldBe` Just decoded
      it "decodes an empty response as an empty map" $ do
        let Just decoded = decode "{}" :: Maybe OS3Types.UndeployModelResponse
        OS3Types.undeployModelResponseNodes decoded `shouldBe` M.empty

    describe "UpdateModelRequest JSON" $ do
      it "encodes only the supplied fields (omitNulls)" $ do
        let req =
              OS3Types.UpdateModelRequest
                { OS3Types.updateModelName = Just "renamed",
                  OS3Types.updateModelDescription = Nothing,
                  OS3Types.updateModelIsEnabled = Just False,
                  OS3Types.updateModelGroupId = Nothing,
                  OS3Types.updateModelConfig = Nothing,
                  OS3Types.updateModelConnectorId = Nothing,
                  OS3Types.updateModelConnector = Nothing,
                  OS3Types.updateModelRateLimiter = Nothing,
                  OS3Types.updateModelGuardrails = Nothing,
                  OS3Types.updateModelInterface = Nothing
                }
            encoded = LBS.toStrict (encode req)
        encoded `shouldSatisfy` BS.isInfixOf "\"name\":\"renamed\""
        encoded `shouldSatisfy` BS.isInfixOf "\"is_enabled\":false"
        encoded `shouldNotSatisfy` BS.isInfixOf "\"description\""
        encoded `shouldNotSatisfy` BS.isInfixOf "\"model_group_id\""
      it "round-trips a partial update body" $ do
        let original =
              OS3Types.UpdateModelRequest
                { OS3Types.updateModelName = Just "n",
                  OS3Types.updateModelDescription = Just "d",
                  OS3Types.updateModelIsEnabled = Nothing,
                  OS3Types.updateModelGroupId = Just "g1",
                  OS3Types.updateModelConfig = Nothing,
                  OS3Types.updateModelConnectorId = Nothing,
                  OS3Types.updateModelConnector = Nothing,
                  OS3Types.updateModelRateLimiter =
                    Just
                      OS3Types.ModelRateLimiter
                        { OS3Types.modelRateLimiterLimit = Just (OS3Types.ModelRateLimit 10),
                          OS3Types.modelRateLimiterUnit = Just OS3Types.ModelRateLimiterUnitMinutes
                        },
                  OS3Types.updateModelGuardrails = Nothing,
                  OS3Types.updateModelInterface = Nothing
                }
        (decode . encode) original `shouldBe` Just original
      it "decodes the documented example" $ do
        let parsed =
              decode
                "{ \"name\": \"new-name\",\
                \ \"description\": \"updated desc\",\
                \ \"is_enabled\": true,\
                \ \"model_group_id\": \"Z1eQf4oB5Vm0Tdw8EIP2\"\
                \ }" ::
                Maybe OS3Types.UpdateModelRequest
        OS3Types.updateModelName <$> parsed `shouldBe` Just (Just "new-name")
        OS3Types.updateModelIsEnabled <$> parsed `shouldBe` Just (Just True)

    describe "ModelRateLimiter JSON" $ do
      it "round-trips a full limiter" $ do
        let original =
              OS3Types.ModelRateLimiter
                { OS3Types.modelRateLimiterLimit = Just (OS3Types.ModelRateLimit 5),
                  OS3Types.modelRateLimiterUnit = Just OS3Types.ModelRateLimiterUnitMinutes
                }
        (decode . encode) original `shouldBe` Just original
      it "decodes the documented shape" $
        decode "{\"limit\": 10, \"unit\": \"MINUTES\"}"
          `shouldBe` Just
            OS3Types.ModelRateLimiter
              { OS3Types.modelRateLimiterLimit = Just (OS3Types.ModelRateLimit 10),
                OS3Types.modelRateLimiterUnit = Just OS3Types.ModelRateLimiterUnitMinutes
              }
      it "accepts a stringified limit (StringifiedDouble)" $
        decode "{\"limit\": \"5.5\", \"unit\": \"SECONDS\"}"
          `shouldBe` Just
            OS3Types.ModelRateLimiter
              { OS3Types.modelRateLimiterLimit = Just (OS3Types.ModelRateLimit 5.5),
                OS3Types.modelRateLimiterUnit = Just OS3Types.ModelRateLimiterUnitSeconds
              }
      it "rejects an unknown unit enum" $
        (decode "{\"limit\": 1, \"unit\": \"CENTURIES\"}" :: Maybe OS3Types.ModelRateLimiter)
          `shouldBe` Nothing
      it "round-trips every ModelRateLimiterUnit variant" $
        mapM_
          ( \u -> do
              let original = OS3Types.ModelRateLimiter {OS3Types.modelRateLimiterLimit = Just (OS3Types.ModelRateLimit 1), OS3Types.modelRateLimiterUnit = Just u}
              (decode . encode) original `shouldBe` Just (original :: OS3Types.ModelRateLimiter)
          )
          [ OS3Types.ModelRateLimiterUnitDays,
            OS3Types.ModelRateLimiterUnitHours,
            OS3Types.ModelRateLimiterUnitMicroseconds,
            OS3Types.ModelRateLimiterUnitMilliseconds,
            OS3Types.ModelRateLimiterUnitMinutes,
            OS3Types.ModelRateLimiterUnitNanoseconds,
            OS3Types.ModelRateLimiterUnitSeconds
          ]

    describe "ModelShards / ModelTotal JSON" $ do
      it "ModelShards decodes the standard shape" $
        decode
          "{ \"total\": 1\
          \, \"successful\": 1\
          \, \"skipped\": 0\
          \, \"failed\": 0\
          \ }"
          `shouldBe` Just
            OS3Types.ModelShards
              { OS3Types.modelShardsTotal = 1,
                OS3Types.modelShardsSuccessful = 1,
                OS3Types.modelShardsSkipped = 0,
                OS3Types.modelShardsFailed = 0
              }
      it "ModelShards tolerates missing fields (default 0)" $
        decode "{}"
          `shouldBe` Just
            OS3Types.ModelShards
              { OS3Types.modelShardsTotal = 0,
                OS3Types.modelShardsSuccessful = 0,
                OS3Types.modelShardsSkipped = 0,
                OS3Types.modelShardsFailed = 0
              }
      it "ModelTotal decodes {value, relation}" $
        decode "{ \"value\": 5, \"relation\": \"eq\" }"
          `shouldBe` Just
            OS3Types.ModelTotal
              { OS3Types.modelTotalValue = 5,
                OS3Types.modelTotalRelation = OS3Types.ModelTotalRelationEq
              }
      it "ModelTotalRelation round-trips unknown values" $ do
        let Just decoded =
              decode "\"weird\"" :: Maybe OS3Types.ModelTotalRelation
        encode decoded `shouldBe` "\"weird\""

    describe "ModelSearchResponse JSON" $ do
      let searchResp =
            "{\
            \  \"took\": 8,\
            \  \"timed_out\": false,\
            \  \"_shards\": {\
            \    \"total\": 1,\
            \    \"successful\": 1,\
            \    \"skipped\": 0,\
            \    \"failed\": 0\
            \  },\
            \  \"hits\": {\
            \    \"total\": {\
            \      \"value\": 1,\
            \      \"relation\": \"eq\"\
            \    },\
            \    \"max_score\": 1.0,\
            \    \"hits\": [\
            \      {\
            \        \"_index\": \".plugins-ml-model\",\
            \        \"_id\": \"m1\",\
            \        \"_version\": 1,\
            \        \"_seq_no\": 0,\
            \        \"_primary_term\": 1,\
            \        \"_score\": 1.0,\
            \        \"_source\": {\
            \          \"name\": \"all-MiniLM-L6-v2\",\
            \          \"algorithm\": \"TEXT_EMBEDDING\",\
            \          \"version\": \"1\"\
            \        }\
            \      }\
            \    ]\
            \  }\
            \}"
      it "decodes a full search response" $ do
        let Just decoded = decode searchResp :: Maybe OS3Types.ModelSearchResponse
        OS3Types.modelSearchResponseTook decoded `shouldBe` 8
        OS3Types.modelSearchResponseTimedOut decoded `shouldBe` False
        OS3Types.modelTotalValue (OS3Types.modelSearchResponseTotal decoded) `shouldBe` 1
        length (OS3Types.modelSearchResponseHits decoded) `shouldBe` 1
      it "extracts the inner ModelInfo via _source" $ do
        let Just decoded = decode searchResp :: Maybe OS3Types.ModelSearchResponse
        let hit = head (OS3Types.modelSearchResponseHits decoded)
        OS3Types.modelHitId hit `shouldBe` "m1"
        OS3Types.modelInfoName (OS3Types.modelHitSource hit) `shouldBe` "all-MiniLM-L6-v2"
      it "round-trips the full response" $ do
        let Just decoded = decode searchResp :: Maybe OS3Types.ModelSearchResponse
        (decode . encode) decoded `shouldBe` Just decoded
      it "decodes an empty-hits page" $ do
        let resp =
              "{\
              \  \"took\": 1,\
              \  \"timed_out\": false,\
              \  \"_shards\": {\"total\": 1, \"successful\": 1, \"skipped\": 0, \"failed\": 0},\
              \  \"hits\": {\
              \    \"total\": {\"value\": 0, \"relation\": \"eq\"},\
              \    \"max_score\": null,\
              \    \"hits\": []\
              \  }\
              \}"
            Just decoded = decode resp :: Maybe OS3Types.ModelSearchResponse
        OS3Types.modelSearchResponseHits decoded `shouldBe` []

  -- =========================================================================
  -- ML Agent API types (pure round-trip; no live cluster)
  -- =========================================================================
  describe "ML Agent API types" $ do
    -- Sample fixtures copied verbatim from the OS ML Commons
    -- register-agent docs, then exercised through encode/decode to pin
    -- the wire shape against future plugin drift.
    let fullFlowAgentJson :: LBS.ByteString
        fullFlowAgentJson =
          "{\
          \  \"name\": \"my-flow-agent\",\
          \  \"type\": \"flow\",\
          \  \"description\": \"A flow agent with tools\",\
          \  \"llm\": {\
          \    \"model_id\": \"gTLE_Eo=\",\
          \    \"parameters\": { \"temperature\": 0.5 }\
          \  },\
          \  \"tools\": [\
          \    \"CatIndexTool\",\
          \    {\
          \      \"type\": \"VectorDBTool\",\
          \      \"parameters\": { \"index\": \"test-index\" },\
          \      \"description\": \"vector db tool\"\
          \    }\
          \  ],\
          \  \"parameters\": { \"key\": \"value\" },\
          \  \"memory\": { \"type\": \"conversation\" }\
          \}"
        minimalConversationJson :: LBS.ByteString
        minimalConversationJson =
          "{\
          \  \"name\": \"my-conversation-agent\",\
          \  \"type\": \"conversational\",\
          \  \"llm\": { \"model_id\": \"gTLE_Eo=\" }\
          \}"

    it "AgentId round-trips as a bare JSON string" $ do
      encode (OS3Types.AgentId "8X7x9o8Bj7KqQ") `shouldBe` "\"8X7x9o8Bj7KqQ\""
      decode "\"8X7x9o8Bj7KqQ\"" `shouldBe` Just (OS3Types.AgentId "8X7x9o8Bj7KqQ")
    it "AgentId rejects a non-string JSON value" $
      (decode "42" :: Maybe OS3Types.AgentId) `shouldBe` Nothing

    it "encodes the spec-documented agent types" $ do
      encode OS3Types.AgentTypeFlow `shouldBe` "\"flow\""
      encode OS3Types.AgentTypeConversational `shouldBe` "\"conversational\""
      encode OS3Types.AgentTypeConversationalFlow `shouldBe` "\"conversational_flow\""
      encode OS3Types.AgentTypePlanExecuteAndReflect `shouldBe` "\"plan_execute_and_reflect\""
    it "encodes the historically documented agent types" $ do
      encode OS3Types.AgentTypeRouter `shouldBe` "\"router\""
      encode OS3Types.AgentTypeCoordinator `shouldBe` "\"coordinator\""
      encode OS3Types.AgentTypeTemplate `shouldBe` "\"template\""
      encode OS3Types.AgentTypeManual `shouldBe` "\"manual\""
    it "decodes the spec-documented agent types" $ do
      decode "\"flow\"" `shouldBe` Just OS3Types.AgentTypeFlow
      decode "\"conversational\"" `shouldBe` Just OS3Types.AgentTypeConversational
      decode "\"conversational_flow\"" `shouldBe` Just OS3Types.AgentTypeConversationalFlow
      decode "\"plan_execute_and_reflect\"" `shouldBe` Just OS3Types.AgentTypePlanExecuteAndReflect
    it "decodes the historically documented agent types" $ do
      decode "\"router\"" `shouldBe` Just OS3Types.AgentTypeRouter
      decode "\"coordinator\"" `shouldBe` Just OS3Types.AgentTypeCoordinator
      decode "\"template\"" `shouldBe` Just OS3Types.AgentTypeTemplate
      decode "\"manual\"" `shouldBe` Just OS3Types.AgentTypeManual
    it "decodes an unknown agent type into the Other escape hatch" $
      (decode "\"vibes\"" :: Maybe OS3Types.AgentType)
        `shouldBe` Just (OS3Types.AgentTypeOther "vibes")
    it "decodes the legacy misspelled \"conversation\" into Other" $
      (decode "\"conversation\"" :: Maybe OS3Types.AgentType)
        `shouldBe` Just (OS3Types.AgentTypeOther "conversation")
    it "round-trips the Other escape hatch" $ do
      let other = OS3Types.AgentTypeOther "future_agent_kind"
      decode (encode other) `shouldBe` Just other

    it "LlmConfig decodes a minimal binding (model_id only)" $
      decode "{\"model_id\": \"gTLE_Eo=\"}"
        `shouldBe` Just
          OS3Types.LlmConfig
            { OS3Types.llmConfigModelId = "gTLE_Eo=",
              OS3Types.llmConfigParameters = Nothing
            }
    it "LlmConfig round-trips a full binding" $ do
      let cfg =
            OS3Types.LlmConfig
              { OS3Types.llmConfigModelId = "m1",
                OS3Types.llmConfigParameters = Just (object ["temperature" .= (0.5 :: Double)])
              }
      decode (encode cfg) `shouldBe` Just cfg

    it "MemoryConfig decodes {\"type\": \"conversation\"}" $
      decode "{\"type\": \"conversation\"}"
        `shouldBe` Just
          OS3Types.MemoryConfig
            { OS3Types.memoryConfigType = "conversation",
              OS3Types.memoryConfigParameters = Nothing
            }

    it "ToolConfig decodes the string shorthand" $
      decode "\"CatIndexTool\"" `shouldBe` Just (OS3Types.ToolConfigNamed "CatIndexTool")
    it "ToolConfig decodes the inline object form" $ do
      let parsed = decode "{\"type\": \"VectorDBTool\", \"parameters\": {\"index\": \"ix\"}}" :: Maybe OS3Types.ToolConfig
      parsed
        `shouldBe` Just
          ( OS3Types.ToolConfigInline
              OS3Types.ToolInlineConfig
                { OS3Types.toolInlineConfigType = "VectorDBTool",
                  OS3Types.toolInlineConfigParameters = Just (object ["index" .= ("ix" :: Text)]),
                  OS3Types.toolInlineConfigDescription = Nothing
                }
          )
    it "ToolConfig round-trips both forms" $ do
      let shorthand = OS3Types.ToolConfigNamed "CatIndexTool"
          inline =
            OS3Types.ToolConfigInline
              OS3Types.ToolInlineConfig
                { OS3Types.toolInlineConfigType = "VectorDBTool",
                  OS3Types.toolInlineConfigParameters = Nothing,
                  OS3Types.toolInlineConfigDescription = Just "vector db"
                }
      decode (encode shorthand) `shouldBe` Just shorthand
      decode (encode inline) `shouldBe` Just inline
    it "ToolConfig rejects a number" $
      (decode "42" :: Maybe OS3Types.ToolConfig) `shouldBe` Nothing

    it "RegisterAgentRequest decodes the full flow-agent fixture" $ do
      let parsed = decode fullFlowAgentJson :: Maybe OS3Types.RegisterAgentRequest
      parsed
        `shouldSatisfy` \case
          Just req ->
            OS3Types.registerAgentName req == "my-flow-agent"
              && OS3Types.registerAgentType req == OS3Types.AgentTypeFlow
              && OS3Types.registerAgentDescription req == Just "A flow agent with tools"
              && isJust (OS3Types.registerAgentLlm req)
              && (length <$> OS3Types.registerAgentTools req) == Just 2
              && isJust (OS3Types.registerAgentMemory req)
          Nothing -> False
    it "RegisterAgentRequest decodes the minimal conversation fixture" $ do
      let parsed = decode minimalConversationJson :: Maybe OS3Types.RegisterAgentRequest
      parsed
        `shouldBe` Just
          OS3Types.RegisterAgentRequest
            { OS3Types.registerAgentName = "my-conversation-agent",
              OS3Types.registerAgentType = OS3Types.AgentTypeConversational,
              OS3Types.registerAgentDescription = Nothing,
              OS3Types.registerAgentLlm =
                Just
                  OS3Types.LlmConfig
                    { OS3Types.llmConfigModelId = "gTLE_Eo=",
                      OS3Types.llmConfigParameters = Nothing
                    },
              OS3Types.registerAgentTools = Nothing,
              OS3Types.registerAgentParameters = Nothing,
              OS3Types.registerAgentMemory = Nothing,
              OS3Types.registerAgentAppType = Nothing
            }
    it "RegisterAgentRequest round-trips the full fixture" $ do
      let parsed = decode fullFlowAgentJson :: Maybe OS3Types.RegisterAgentRequest
      parsed `shouldSatisfy` isJust
      case parsed of
        Just req -> decode (encode req) `shouldBe` Just req
        Nothing -> expectationFailure "decode failed"
    it "RegisterAgentRequest omits Nothing fields on encode" $ do
      let req =
            OS3Types.RegisterAgentRequest
              { OS3Types.registerAgentName = "n",
                OS3Types.registerAgentType = OS3Types.AgentTypeFlow,
                OS3Types.registerAgentDescription = Nothing,
                OS3Types.registerAgentLlm = Nothing,
                OS3Types.registerAgentTools = Nothing,
                OS3Types.registerAgentParameters = Nothing,
                OS3Types.registerAgentMemory = Nothing,
                OS3Types.registerAgentAppType = Nothing
              }
      encode req `shouldBe` "{\"name\":\"n\",\"type\":\"flow\"}"

    it "RegisterAgentResponse decodes {\"agent_id\": \"...\"}" $
      decode "{\"agent_id\": \"8X7x9o8Bj7KqQ\"}"
        `shouldBe` Just
          (OS3Types.RegisterAgentResponse (OS3Types.AgentId "8X7x9o8Bj7KqQ"))
    it "RegisterAgentResponse rejects a missing agent_id" $
      (decode "{}" :: Maybe OS3Types.RegisterAgentResponse) `shouldBe` Nothing
    it "RegisterAgentResponse rejects a non-string agent_id" $
      (decode "{\"agent_id\": 42}" :: Maybe OS3Types.RegisterAgentResponse) `shouldBe` Nothing
    it "RegisterAgentResponse round-trips" $ do
      let resp = OS3Types.RegisterAgentResponse (OS3Types.AgentId "abc")
      decode (encode resp) `shouldBe` Just resp

    -- -----------------------------------------------------------------
    -- UpdateAgentRequest (PUT /_plugins/_ml/agents/{id}, OS 3.1+)
    -- -----------------------------------------------------------------
    describe "UpdateAgentRequest JSON" $ do
      it "encodes only the supplied fields (omitNulls)" $ do
        let req =
              OS3Types.UpdateAgentRequest
                { OS3Types.updateAgentName = Just "renamed",
                  OS3Types.updateAgentDescription = Nothing,
                  OS3Types.updateAgentTools = Nothing,
                  OS3Types.updateAgentAppType = Nothing,
                  OS3Types.updateAgentMemory = Nothing,
                  OS3Types.updateAgentLlm = Nothing
                }
            encoded = LBS.toStrict (encode req)
        encoded `shouldSatisfy` BS.isInfixOf "\"name\":\"renamed\""
        encoded `shouldNotSatisfy` BS.isInfixOf "\"description\""
        encoded `shouldNotSatisfy` BS.isInfixOf "\"tools\""
        encoded `shouldNotSatisfy` BS.isInfixOf "\"llm\""
      it "round-trips a partial update with tools" $ do
        let original =
              OS3Types.UpdateAgentRequest
                { OS3Types.updateAgentName = Just "Updated_Test_Agent",
                  OS3Types.updateAgentDescription = Just "Updated description",
                  OS3Types.updateAgentTools =
                    Just
                      [ OS3Types.ToolConfigInline
                          OS3Types.ToolInlineConfig
                            { OS3Types.toolInlineConfigType = "MLModelTool",
                              OS3Types.toolInlineConfigParameters = Nothing,
                              OS3Types.toolInlineConfigDescription = Just "general tool"
                            }
                      ],
                  OS3Types.updateAgentAppType = Nothing,
                  OS3Types.updateAgentMemory = Nothing,
                  OS3Types.updateAgentLlm = Nothing
                }
        (decode . encode) original `shouldBe` Just original
      it "decodes the documented update example" $ do
        let parsed =
              decode
                "{ \"name\": \"Updated_Test_Agent_For_RAG\",\
                \ \"description\": \"Updated description for test agent\",\
                \ \"tools\": [{\"type\": \"MLModelTool\",\
                \              \"description\": \"tool\",\
                \              \"parameters\": {\"model_id\": \"m1\"}}]\
                \ }" ::
                Maybe OS3Types.UpdateAgentRequest
        OS3Types.updateAgentName <$> parsed `shouldBe` Just (Just "Updated_Test_Agent_For_RAG")
        (length <$> (OS3Types.updateAgentTools =<< parsed)) `shouldBe` Just 1
      it "decodes an empty object as all-Nothing" $
        decode "{}"
          `shouldBe` Just
            (OS3Types.UpdateAgentRequest Nothing Nothing Nothing Nothing Nothing Nothing)

    -- -----------------------------------------------------------------
    -- ExecuteAgentRequest / ExecuteAgentResponse (POST .../{id}/_execute)
    -- -----------------------------------------------------------------
    describe "ExecuteAgentRequest JSON" $ do
      it "encodes a parameters-only body" $ do
        let req =
              OS3Types.ExecuteAgentRequest
                { OS3Types.executeAgentParameters = Just (object ["question" .= ("hi" :: Text)]),
                  OS3Types.executeAgentInput = Nothing
                }
            encoded = LBS.toStrict (encode req)
        encoded `shouldSatisfy` BS.isInfixOf "\"question\":\"hi\""
        encoded `shouldNotSatisfy` BS.isInfixOf "\"input\""
      it "encodes an input-as-string (unified registration)" $ do
        let req =
              OS3Types.ExecuteAgentRequest
                { OS3Types.executeAgentParameters = Nothing,
                  OS3Types.executeAgentInput = Just (String "What tools do you have?")
                }
            encoded = LBS.toStrict (encode req)
        encoded `shouldSatisfy` BS.isInfixOf "\"input\":\"What tools do you have?\""
      it "encodes an input-as-array (multimodal content blocks)" $ do
        let req =
              OS3Types.ExecuteAgentRequest
                { OS3Types.executeAgentParameters = Nothing,
                  OS3Types.executeAgentInput = Just (String "block")
                }
        encode req `shouldSatisfy` (\bs -> LBS.length bs > 2)
      it "round-trips a parameters + include_token_usage body" $ do
        let original =
              OS3Types.ExecuteAgentRequest
                { OS3Types.executeAgentParameters =
                    Just (object ["question" .= ("q" :: Text), "include_token_usage" .= True]),
                  OS3Types.executeAgentInput = Nothing
                }
        (decode . encode) original `shouldBe` Just original
      it "decodes an empty body as all-Nothing" $
        decode "{}" `shouldBe` Just (OS3Types.ExecuteAgentRequest Nothing Nothing)

    describe "ExecuteAgentResponse JSON" $ do
      it "decodes a regular response (result string)" $ do
        let resp =
              "{\
              \  \"inference_results\": [\
              \    {\"output\": [{\"result\": \"the answer is 42\"}]}\
              \  ]\
              \}"
            Just decoded = decode resp :: Maybe OS3Types.ExecuteAgentResponse
        (length . OS3Types.executeAgentInferenceResults) decoded `shouldBe` 1
      it "decodes a memory-bearing response (named outputs)" $ do
        let resp =
              "{\
              \  \"inference_results\": [\
              \    {\"output\": [\
              \      {\"name\": \"memory_id\", \"result\": \"iEgpJZwBZx9B0F4spD5v\"},\
              \      {\"name\": \"parent_interaction_id\", \"result\": \"ikgpJZwBZx9B0F4spT61\"},\
              \      {\"name\": \"response\", \"result\": \"You like red.\"}\
              \    ]}\
              \  ]\
              \}"
            Just decoded = decode resp :: Maybe OS3Types.ExecuteAgentResponse
        let outputs =
              OS3Types.inferenceResultOutput $
                head (OS3Types.executeAgentInferenceResults decoded)
        length outputs `shouldBe` 3
        OS3Types.executeOutputName (head outputs) `shouldBe` Just "memory_id"
      it "decodes a conversational_v2 response (dataAsMap)" $ do
        let resp =
              "{\
              \  \"inference_results\": [\
              \    {\"output\": [\
              \      {\"name\": \"response\",\
              \       \"dataAsMap\": {\
              \         \"stop_reason\": \"end_turn\",\
              \         \"memory_id\": \"abc123\"\
              \       }}\
              \    ]}\
              \  ]\
              \}"
            Just decoded = decode resp :: Maybe OS3Types.ExecuteAgentResponse
        let out =
              head $
                OS3Types.inferenceResultOutput $
                  head (OS3Types.executeAgentInferenceResults decoded)
        OS3Types.executeOutputDataAsMap out `shouldSatisfy` isJust
      it "decodes a token_usage output" $ do
        let resp =
              "{\
              \  \"inference_results\": [\
              \    {\"output\": [\
              \      {\"name\": \"token_usage\",\
              \       \"dataAsMap\": {\"per_turn_usage\": [], \"per_model_usage\": []}}\
              \    ]}\
              \  ]\
              \}"
            Just decoded = decode resp :: Maybe OS3Types.ExecuteAgentResponse
        let out =
              head $
                OS3Types.inferenceResultOutput $
                  head (OS3Types.executeAgentInferenceResults decoded)
        OS3Types.executeOutputName out `shouldBe` Just "token_usage"
      it "round-trips a full response" $ do
        let resp =
              "{\
              \  \"inference_results\": [\
              \    {\"output\": [{\"name\": \"response\", \"result\": \"hi\"}]}\
              \  ]\
              \}"
            Just decoded = decode resp :: Maybe OS3Types.ExecuteAgentResponse
        (decode . encode) decoded `shouldBe` Just decoded
      it "decodes an empty inference_results as []" $ do
        let Just decoded = decode "{\"inference_results\": []}" :: Maybe OS3Types.ExecuteAgentResponse
        OS3Types.executeAgentInferenceResults decoded `shouldBe` []
      it "tolerates a missing inference_results key" $ do
        let Just decoded = decode "{}" :: Maybe OS3Types.ExecuteAgentResponse
        OS3Types.executeAgentInferenceResults decoded `shouldBe` []

    -- -----------------------------------------------------------------
    -- ExecuteStreamChunk + AgUIEvent (POST .../{id}/_execute/stream)
    -- -----------------------------------------------------------------
    describe "ExecuteStreamChunk" $ do
      it "isStreamChunkLast reads the flag through" $ do
        OS3Types.isStreamChunkLast
          ( OS3Types.ExecuteStreamChunk
              (OS3Types.ExecuteAgentResponse [])
              True
          )
          `shouldBe` True
      it "detectIsLast finds is_last:true under a response output" $
        -- Re-uses the client-side helper indirectly via the chunk
        -- constructor: the parser populates the flag from the wire.
        OS3Types.executeStreamChunkIsLast
          (OS3Types.ExecuteStreamChunk (OS3Types.ExecuteAgentResponse []) False)
          `shouldBe` False

    describe "AgUIEvent JSON" $ do
      it "decodes RUN_STARTED" $
        decode
          "{\"type\":\"RUN_STARTED\",\"timestamp\":1734567890123,\"threadId\":\"t1\",\"runId\":\"r1\"}"
          `shouldBe` Just
            (OS3Types.AgUIRunStarted 1734567890123 "t1" "r1")
      it "decodes TEXT_MESSAGE_CONTENT" $
        decode
          "{\"type\":\"TEXT_MESSAGE_CONTENT\",\"timestamp\":1,\"messageId\":\"m1\",\"delta\":\"hi\"}"
          `shouldBe` Just
            (OS3Types.AgUITextMessageContent 1 "m1" "hi")
      it "decodes RUN_FINISHED" $
        decode
          "{\"type\":\"RUN_FINISHED\",\"timestamp\":2,\"threadId\":\"t1\",\"runId\":\"r1\"}"
          `shouldBe` Just
            (OS3Types.AgUIRunFinished 2 "t1" "r1")
      it "decodes TEXT_MESSAGE_START with default role when absent" $
        decode
          "{\"type\":\"TEXT_MESSAGE_START\",\"timestamp\":3,\"messageId\":\"m1\"}"
          `shouldBe` Just
            (OS3Types.AgUITextMessageStart 3 "m1" "assistant")
      it "decodes an unknown event type into AgUIOther" $ do
        let Just (OS3Types.AgUIOther typ _) =
              decode
                "{\"type\":\"FUTURE_EVENT\",\"timestamp\":4,\"foo\":\"bar\"}" ::
                Maybe OS3Types.AgUIEvent
        typ `shouldBe` "FUTURE_EVENT"
      it "round-trips every documented variant" $
        mapM_
          (\ev -> (decode . encode) ev `shouldBe` Just (ev :: OS3Types.AgUIEvent))
          [ OS3Types.AgUIRunStarted 1 "t" "r",
            OS3Types.AgUITextMessageStart 2 "m" "assistant",
            OS3Types.AgUITextMessageContent 3 "m" "delta",
            OS3Types.AgUITextMessageEnd 4 "m",
            OS3Types.AgUIRunFinished 5 "t" "r"
          ]

    -- -----------------------------------------------------------------
    -- AgentInfo, AgentHit, AgentSearchResponse (new in bloodhound-hv6)
    -- -----------------------------------------------------------------
    describe "AgentInfo JSON" $ do
      let getAgentResp =
            "{\
            \  \"name\": \"my-flow-agent\",\
            \  \"type\": \"flow\",\
            \  \"description\": \"desc\",\
            \  \"tools\": [\
            \    {\"type\": \"CatIndexTool\"}\
            \  ],\
            \  \"created_time\": 1717000000000,\
            \  \"last_updated_time\": 1717000100000\
            \}"
      it "decodes a documented get-agent response" $ do
        let Just decoded = decode getAgentResp :: Maybe OS3Types.AgentInfo
        OS3Types.agentInfoName decoded `shouldBe` Just "my-flow-agent"
        OS3Types.agentInfoType decoded `shouldBe` Just OS3Types.AgentTypeFlow
        OS3Types.agentInfoCreatedTime decoded `shouldBe` Just 1717000000000
        OS3Types.agentInfoLastUpdatedTime decoded `shouldBe` Just 1717000100000
        (length <$> OS3Types.agentInfoTools decoded) `shouldBe` Just 1
      it "decodes an empty/minimal response" $ do
        let Just decoded = decode "{}" :: Maybe OS3Types.AgentInfo
        OS3Types.agentInfoName decoded `shouldBe` Nothing
        OS3Types.agentInfoType decoded `shouldBe` Nothing
      it "preserves unknown keys through round-trip" $ do
        let Just decoded = decode getAgentResp :: Maybe OS3Types.AgentInfo
        let reEncoded = LBS.toStrict (encode decoded)
        reEncoded `shouldSatisfy` BS.isInfixOf "\"created_time\""
        reEncoded `shouldSatisfy` BS.isInfixOf "\"name\":\"my-flow-agent\""
      it "round-trips an unknown top-level key (forward-compat)" $ do
        -- The agentInfoOther field must preserve any key the parser
        -- does not statically know, so a future plugin release adding
        -- fields round-trips safely rather than silently dropping.
        let withUnknown =
              "{\
              \  \"name\": \"n\",\
              \  \"type\": \"flow\",\
              \  \"future_plugin_field\": {\"inner\": 42},\
              \  \"stray_tag\": \"x\"\
              \}"
            Just decoded = decode withUnknown :: Maybe OS3Types.AgentInfo
        let reEncoded = LBS.toStrict (encode decoded)
        reEncoded `shouldSatisfy` BS.isInfixOf "\"future_plugin_field\""
        reEncoded `shouldSatisfy` BS.isInfixOf "\"stray_tag\":\"x\""
      it "decodes a conversation agent with llm + memory" $ do
        let conversationResp =
              "{\
              \  \"name\": \"my-conversation-agent\",\
              \  \"type\": \"conversational\",\
              \  \"llm\": {\"model_id\": \"a1\"},\
              \  \"memory\": {\"type\": \"conversation\"}\
              \}"
            Just decoded = decode conversationResp :: Maybe OS3Types.AgentInfo
        OS3Types.agentInfoType decoded `shouldBe` Just OS3Types.AgentTypeConversational
        (OS3Types.llmConfigModelId <$> OS3Types.agentInfoLlm decoded)
          `shouldBe` Just "a1"

    describe "AgentSearchResponse JSON" $ do
      let agentSearchResp =
            "{\
            \  \"took\": 4,\
            \  \"timed_out\": false,\
            \  \"_shards\": {\
            \    \"total\": 1,\
            \    \"successful\": 1,\
            \    \"skipped\": 0,\
            \    \"failed\": 0\
            \  },\
            \  \"hits\": {\
            \    \"total\": {\
            \      \"value\": 1,\
            \      \"relation\": \"eq\"\
            \    },\
            \    \"max_score\": 1.0,\
            \    \"hits\": [\
            \      {\
            \        \"_index\": \".plugins-ml-agent\",\
            \        \"_id\": \"8X7x9o8Bj7KqQ\",\
            \        \"_version\": 1,\
            \        \"_seq_no\": 0,\
            \        \"_primary_term\": 1,\
            \        \"_score\": 1.0,\
            \        \"_source\": {\
            \          \"name\": \"a-flow-agent\",\
            \          \"type\": \"flow\",\
            \          \"created_time\": 1717000000000\
            \        }\
            \      }\
            \    ]\
            \  }\
            \}"
      it "decodes a full agent search response" $ do
        let Just decoded = decode agentSearchResp :: Maybe OS3Types.AgentSearchResponse
        OS3Types.agentSearchResponseTook decoded `shouldBe` 4
        length (OS3Types.agentSearchResponseHits decoded) `shouldBe` 1
      it "extracts the agent_id from _id (not _source)" $ do
        let Just decoded = decode agentSearchResp :: Maybe OS3Types.AgentSearchResponse
        let hit = head (OS3Types.agentSearchResponseHits decoded)
        OS3Types.agentHitId hit `shouldBe` "8X7x9o8Bj7KqQ"
      it "extracts the inner AgentInfo via _source" $ do
        let Just decoded = decode agentSearchResp :: Maybe OS3Types.AgentSearchResponse
        let hit = head (OS3Types.agentSearchResponseHits decoded)
        OS3Types.agentInfoName (OS3Types.agentHitSource hit) `shouldBe` Just "a-flow-agent"
      it "round-trips the full response" $ do
        let Just decoded = decode agentSearchResp :: Maybe OS3Types.AgentSearchResponse
        (decode . encode) decoded `shouldBe` Just decoded

  -- =========================================================================
  -- Endpoint shape (pure checks against the BHRequest, no live backend)
  -- =========================================================================
  describe "getModel endpoint shape (OS3)" $ do
    it "GETs /_plugins/_ml/models/{id}" $ do
      let req = OS3Requests.getModel (OS3Types.ModelId "abc123")
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_ml", "models", "abc123"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
    it "uses the GET method" $ do
      let req = OS3Requests.getModel (OS3Types.ModelId "x")
      bhRequestMethod req `shouldBe` "GET"
    it "does not attach a body" $ do
      let req = OS3Requests.getModel (OS3Types.ModelId "x")
      bhRequestBody req `shouldBe` Nothing

  describe "registerModel endpoint shape (OS3)" $ do
    let reqBody =
          OS3Types.RegisterModelRequest
            { OS3Types.registerModelName = "n",
              OS3Types.registerModelVersion = Nothing,
              OS3Types.registerModelDescription = Nothing,
              OS3Types.registerModelFormat = Nothing,
              OS3Types.registerModelFunctionName = Nothing,
              OS3Types.registerModelContentHashValue = Nothing,
              OS3Types.registerModelConfig = Nothing,
              OS3Types.registerModelUrl = Nothing,
              OS3Types.registerModelGroupId = Nothing,
              OS3Types.registerModelConnectorId = Nothing,
              OS3Types.registerModelConnector = Nothing,
              OS3Types.registerModelIsEnabled = Nothing,
              OS3Types.registerModelProvisionedBy = Nothing
            }
    it "registerModel POSTs to /_plugins/_ml/models/_register with no deploy flag" $ do
      let req = OS3Requests.registerModel reqBody
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_ml", "models", "_register"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
      bhRequestMethod req `shouldBe` "POST"
    it "registerModelWith True attaches ?deploy=true" $ do
      let req = OS3Requests.registerModelWith True reqBody
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_ml", "models", "_register"]
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("deploy", Just "true")]
    it "registerModelWith False omits the query" $ do
      let req = OS3Requests.registerModelWith False reqBody
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
    it "attaches an encoded body" $ do
      let req = OS3Requests.registerModel reqBody
      bhRequestBody req `shouldSatisfy` isJust

  describe "deployModel endpoint shape (OS3)" $ do
    it "POSTs to /_plugins/_ml/models/{id}/_deploy" $ do
      let req = OS3Requests.deployModel (OS3Types.ModelId "m1") Nothing
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_ml", "models", "m1", "_deploy"]
      bhRequestMethod req `shouldBe` "POST"
    it "uses {} as the body when Nothing is supplied" $ do
      let req = OS3Requests.deployModel (OS3Types.ModelId "m1") Nothing
      bhRequestBody req `shouldBe` Just "{}"
    it "encodes a supplied DeployModelRequest" $ do
      let req = OS3Requests.deployModel (OS3Types.ModelId "m1") (Just (OS3Types.DeployModelRequest (Just ["n1"])))
      bhRequestBody req `shouldSatisfy` isJust

  describe "predict endpoint shape (OS3)" $ do
    it "POSTs to /_plugins/_ml/_predict/{algo}/{id}" $ do
      let req = OS3Requests.predict (OS3Types.AlgorithmName "remote") (OS3Types.ModelId "m1") (object [])
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_ml", "_predict", "remote", "m1"]
      bhRequestMethod req `shouldBe` "POST"
      bhRequestBody req `shouldBe` Just "{}"

  describe "deleteModel endpoint shape (OS3)" $ do
    it "DELETEs /_plugins/_ml/models/{id}" $ do
      let req = OS3Requests.deleteModel (OS3Types.ModelId "abc123")
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_ml", "models", "abc123"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
    it "uses the DELETE method" $ do
      let req = OS3Requests.deleteModel (OS3Types.ModelId "x")
      bhRequestMethod req `shouldBe` "DELETE"
    it "does not attach a body" $ do
      let req = OS3Requests.deleteModel (OS3Types.ModelId "x")
      bhRequestBody req `shouldBe` Nothing

  describe "registerAgent endpoint shape (OS3)" $ do
    let agentReq =
          OS3Types.RegisterAgentRequest
            { OS3Types.registerAgentName = "my-conversation-agent",
              OS3Types.registerAgentType = OS3Types.AgentTypeConversational,
              OS3Types.registerAgentDescription = Nothing,
              OS3Types.registerAgentLlm =
                Just
                  OS3Types.LlmConfig
                    { OS3Types.llmConfigModelId = "gTLE_Eo=",
                      OS3Types.llmConfigParameters = Nothing
                    },
              OS3Types.registerAgentTools = Nothing,
              OS3Types.registerAgentParameters = Nothing,
              OS3Types.registerAgentMemory = Nothing,
              OS3Types.registerAgentAppType = Nothing
            }
    it "POSTs to /_plugins/_ml/agents/_register" $ do
      let req = OS3Requests.registerAgent agentReq
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_ml", "agents", "_register"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
    it "uses the POST method" $ do
      let req = OS3Requests.registerAgent agentReq
      bhRequestMethod req `shouldBe` "POST"
    it "attaches an encoded body" $ do
      let req = OS3Requests.registerAgent agentReq
      bhRequestBody req `shouldSatisfy` isJust

  describe "undeployModel endpoint shape (OS3)" $ do
    it "POSTs to /_plugins/_ml/models/{id}/_undeploy" $ do
      let req = OS3Requests.undeployModel (OS3Types.ModelId "m1")
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_ml", "models", "m1", "_undeploy"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
    it "uses the POST method" $
      bhRequestMethod (OS3Requests.undeployModel (OS3Types.ModelId "x"))
        `shouldBe` "POST"
    it "attaches an empty body" $
      bhRequestBody (OS3Requests.undeployModel (OS3Types.ModelId "x"))
        `shouldBe` Just "{}"

  describe "updateModel endpoint shape (OS3)" $ do
    let updReq =
          OS3Types.UpdateModelRequest
            { OS3Types.updateModelName = Just "n",
              OS3Types.updateModelDescription = Nothing,
              OS3Types.updateModelIsEnabled = Nothing,
              OS3Types.updateModelGroupId = Nothing,
              OS3Types.updateModelConfig = Nothing,
              OS3Types.updateModelConnectorId = Nothing,
              OS3Types.updateModelConnector = Nothing,
              OS3Types.updateModelRateLimiter = Nothing,
              OS3Types.updateModelGuardrails = Nothing,
              OS3Types.updateModelInterface = Nothing
            }
    it "PUTs to /_plugins/_ml/models/{id}" $ do
      let req = OS3Requests.updateModel (OS3Types.ModelId "m1") updReq
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_ml", "models", "m1"]
    it "uses the PUT method" $
      bhRequestMethod (OS3Requests.updateModel (OS3Types.ModelId "x") updReq)
        `shouldBe` "PUT"
    it "attaches an encoded body" $
      bhRequestBody (OS3Requests.updateModel (OS3Types.ModelId "x") updReq)
        `shouldSatisfy` isJust

  describe "searchModels endpoint shape (OS3)" $ do
    it "POSTs to /_plugins/_ml/models/_search" $ do
      let req = OS3Requests.searchModels Nothing
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_ml", "models", "_search"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
    it "uses the POST method" $
      bhRequestMethod (OS3Requests.searchModels Nothing) `shouldBe` "POST"
    it "sends {} when the query is Nothing" $
      bhRequestBody (OS3Requests.searchModels Nothing) `shouldBe` Just "{}"
    it "sends the encoded query when supplied" $ do
      let req = OS3Requests.searchModels (Just (object ["size" .= (5 :: Int)]))
      let body = LBS.toStrict <$> bhRequestBody req
      body `shouldSatisfy` maybe False (BS.isInfixOf "\"size\":5")

  describe "listModels endpoint shape (OS3)" $ do
    it "POSTs to /_plugins/_ml/models/_search (OS3 dropped _list)" $ do
      let req = OS3Requests.listModels Nothing
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_ml", "models", "_search"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
    it "uses the POST method" $
      bhRequestMethod (OS3Requests.listModels Nothing) `shouldBe` "POST"

  describe "getAgent endpoint shape (OS3)" $ do
    it "GETs /_plugins/_ml/agents/{id}" $ do
      let req = OS3Requests.getAgent (OS3Types.AgentId "a1")
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_ml", "agents", "a1"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
    it "uses the GET method" $
      bhRequestMethod (OS3Requests.getAgent (OS3Types.AgentId "x"))
        `shouldBe` "GET"
    it "does not attach a body" $
      bhRequestBody (OS3Requests.getAgent (OS3Types.AgentId "x"))
        `shouldBe` Nothing

  describe "deleteAgent endpoint shape (OS3)" $ do
    it "DELETEs /_plugins/_ml/agents/{id}" $ do
      let req = OS3Requests.deleteAgent (OS3Types.AgentId "a1")
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_ml", "agents", "a1"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
    it "uses the DELETE method" $
      bhRequestMethod (OS3Requests.deleteAgent (OS3Types.AgentId "x"))
        `shouldBe` "DELETE"
    it "does not attach a body" $
      bhRequestBody (OS3Requests.deleteAgent (OS3Types.AgentId "x"))
        `shouldBe` Nothing

  describe "searchAgents endpoint shape (OS3)" $ do
    it "POSTs to /_plugins/_ml/agents/_search" $ do
      let req = OS3Requests.searchAgents Nothing
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_ml", "agents", "_search"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
    it "uses the POST method" $
      bhRequestMethod (OS3Requests.searchAgents Nothing) `shouldBe` "POST"
    it "sends {} when the query is Nothing" $
      bhRequestBody (OS3Requests.searchAgents Nothing) `shouldBe` Just "{}"

  describe "updateAgent endpoint shape (OS3)" $ do
    let updReq =
          OS3Types.UpdateAgentRequest
            { OS3Types.updateAgentName = Just "n",
              OS3Types.updateAgentDescription = Nothing,
              OS3Types.updateAgentTools = Nothing,
              OS3Types.updateAgentAppType = Nothing,
              OS3Types.updateAgentMemory = Nothing,
              OS3Types.updateAgentLlm = Nothing
            }
    it "PUTs to /_plugins/_ml/agents/{id}" $ do
      let req = OS3Requests.updateAgent (OS3Types.AgentId "a1") updReq
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_ml", "agents", "a1"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
    it "uses the PUT method" $
      bhRequestMethod (OS3Requests.updateAgent (OS3Types.AgentId "x") updReq)
        `shouldBe` "PUT"
    it "attaches an encoded body" $
      bhRequestBody (OS3Requests.updateAgent (OS3Types.AgentId "x") updReq)
        `shouldSatisfy` isJust

  describe "executeAgent endpoint shape (OS3)" $ do
    let execReq =
          OS3Types.ExecuteAgentRequest
            { OS3Types.executeAgentParameters = Just (object ["question" .= ("q" :: Text)]),
              OS3Types.executeAgentInput = Nothing
            }
    it "POSTs to /_plugins/_ml/agents/{id}/_execute with no query" $ do
      let req = OS3Requests.executeAgent (OS3Types.AgentId "a1") execReq
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_ml", "agents", "a1", "_execute"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
    it "uses the POST method" $
      bhRequestMethod (OS3Requests.executeAgent (OS3Types.AgentId "x") execReq)
        `shouldBe` "POST"
    it "attaches an encoded body" $
      bhRequestBody (OS3Requests.executeAgent (OS3Types.AgentId "x") execReq)
        `shouldSatisfy` isJust

  describe "executeAgentAsync endpoint shape (OS3)" $ do
    let execReq =
          OS3Types.ExecuteAgentRequest
            { OS3Types.executeAgentParameters = Just (object ["question" .= ("q" :: Text)]),
              OS3Types.executeAgentInput = Nothing
            }
    it "POSTs to .../{id}/_execute with ?async=true" $ do
      let req = OS3Requests.executeAgentAsync (OS3Types.AgentId "a1") execReq
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_ml", "agents", "a1", "_execute"]
      getRawEndpointQueries (bhRequestEndpoint req)
        `shouldBe` [("async", Just "true")]
    it "uses the POST method" $
      bhRequestMethod (OS3Requests.executeAgentAsync (OS3Types.AgentId "x") execReq)
        `shouldBe` "POST"

  -- =========================================================================
  -- Cross-version parity (OS1 = OS2 = OS3 path shapes)
  -- =========================================================================
  describe "endpoint cross-version parity (OS1/OS2/OS3)" $ do
    it "OS1/OS2/OS3 build the same getModel path" $ do
      let os1 = getRawEndpoint (bhRequestEndpoint (OS1Requests.getModel (OS1Types.ModelId "z")))
          os2 = getRawEndpoint (bhRequestEndpoint (OS2Requests.getModel (OS2Types.ModelId "z")))
          os3 = getRawEndpoint (bhRequestEndpoint (OS3Requests.getModel (OS3Types.ModelId "z")))
      os1 `shouldBe` ["_plugins", "_ml", "models", "z"]
      os2 `shouldBe` os1
      os3 `shouldBe` os1

    it "OS2/OS3 build the same deployModel path (OS1 excluded: no register/deploy API in OS 1.3)" $ do
      let os2 = getRawEndpoint (bhRequestEndpoint (OS2Requests.deployModel (OS2Types.ModelId "z") Nothing))
          os3 = getRawEndpoint (bhRequestEndpoint (OS3Requests.deployModel (OS3Types.ModelId "z") Nothing))
      os2 `shouldBe` ["_plugins", "_ml", "models", "z", "_deploy"]
      os3 `shouldBe` os2

    it "OS1/OS2/OS3 build the same predict path" $ do
      let os1 = getRawEndpoint (bhRequestEndpoint (OS1Requests.predict (OS1Types.AlgorithmName "remote") (OS1Types.ModelId "z") (object [])))
          os2 = getRawEndpoint (bhRequestEndpoint (OS2Requests.predict (OS2Types.AlgorithmName "remote") (OS2Types.ModelId "z") (object [])))
          os3 = getRawEndpoint (bhRequestEndpoint (OS3Requests.predict (OS3Types.AlgorithmName "remote") (OS3Types.ModelId "z") (object [])))
      os1 `shouldBe` ["_plugins", "_ml", "_predict", "remote", "z"]
      os2 `shouldBe` os1
      os3 `shouldBe` os1

    it "OS1/OS2/OS3 build the same deleteModel path" $ do
      let os1 = getRawEndpoint (bhRequestEndpoint (OS1Requests.deleteModel (OS1Types.ModelId "z")))
          os2 = getRawEndpoint (bhRequestEndpoint (OS2Requests.deleteModel (OS2Types.ModelId "z")))
          os3 = getRawEndpoint (bhRequestEndpoint (OS3Requests.deleteModel (OS3Types.ModelId "z")))
      os1 `shouldBe` ["_plugins", "_ml", "models", "z"]
      os2 `shouldBe` os1
      os3 `shouldBe` os1

    it "OS2/OS3 build the same undeployModel path (OS1 excluded: no register/deploy API in OS 1.3)" $ do
      let os2 = getRawEndpoint (bhRequestEndpoint (OS2Requests.undeployModel (OS2Types.ModelId "z")))
          os3 = getRawEndpoint (bhRequestEndpoint (OS3Requests.undeployModel (OS3Types.ModelId "z")))
      os2 `shouldBe` ["_plugins", "_ml", "models", "z", "_undeploy"]
      os3 `shouldBe` os2

    it "OS2/OS3 build the same updateModel path (OS1 excluded: no register/deploy API in OS 1.3)" $ do
      let os2Req =
            OS2Types.UpdateModelRequest
              { OS2Types.updateModelName = Just "x",
                OS2Types.updateModelDescription = Nothing,
                OS2Types.updateModelIsEnabled = Nothing,
                OS2Types.updateModelGroupId = Nothing,
                OS2Types.updateModelConfig = Nothing,
                OS2Types.updateModelConnectorId = Nothing,
                OS2Types.updateModelConnector = Nothing,
                OS2Types.updateModelRateLimiter = Nothing,
                OS2Types.updateModelGuardrails = Nothing,
                OS2Types.updateModelInterface = Nothing
              }
          os3Req =
            OS3Types.UpdateModelRequest
              { OS3Types.updateModelName = Just "x",
                OS3Types.updateModelDescription = Nothing,
                OS3Types.updateModelIsEnabled = Nothing,
                OS3Types.updateModelGroupId = Nothing,
                OS3Types.updateModelConfig = Nothing,
                OS3Types.updateModelConnectorId = Nothing,
                OS3Types.updateModelConnector = Nothing,
                OS3Types.updateModelRateLimiter = Nothing,
                OS3Types.updateModelGuardrails = Nothing,
                OS3Types.updateModelInterface = Nothing
              }
          path2 = getRawEndpoint (bhRequestEndpoint (OS2Requests.updateModel (OS2Types.ModelId "z") os2Req))
          path3 = getRawEndpoint (bhRequestEndpoint (OS3Requests.updateModel (OS3Types.ModelId "z") os3Req))
      path2 `shouldBe` ["_plugins", "_ml", "models", "z"]
      path3 `shouldBe` path2

    it "OS1/OS2/OS3 build the same searchModels path" $ do
      let os1 = getRawEndpoint (bhRequestEndpoint (OS1Requests.searchModels Nothing))
          os2 = getRawEndpoint (bhRequestEndpoint (OS2Requests.searchModels Nothing))
          os3 = getRawEndpoint (bhRequestEndpoint (OS3Requests.searchModels Nothing))
      os1 `shouldBe` ["_plugins", "_ml", "models", "_search"]
      os2 `shouldBe` os1
      os3 `shouldBe` os1

    it "all backends hit _search for listModels (documented endpoint)" $ do
      -- The @_list@ path was never documented for ML Commons; OS 2.x
      -- removed POST from it (HTTP 405). The supported listing endpoint
      -- across all versions is @_search@.
      let os1 = getRawEndpoint (bhRequestEndpoint (OS1Requests.listModels Nothing))
          os2 = getRawEndpoint (bhRequestEndpoint (OS2Requests.listModels Nothing))
          os3 = getRawEndpoint (bhRequestEndpoint (OS3Requests.listModels Nothing))
      os1 `shouldBe` ["_plugins", "_ml", "models", "_search"]
      os2 `shouldBe` os1
      os3 `shouldBe` os1

    it "OS2/OS3 build the same getAgent path (OS1 excluded: no ML agents)" $ do
      let os2 = getRawEndpoint (bhRequestEndpoint (OS2Requests.getAgent (OS2Types.AgentId "z")))
          os3 = getRawEndpoint (bhRequestEndpoint (OS3Requests.getAgent (OS3Types.AgentId "z")))
      os2 `shouldBe` ["_plugins", "_ml", "agents", "z"]
      os3 `shouldBe` os2

    it "OS2/OS3 build the same deleteAgent path (OS1 excluded: no ML agents)" $ do
      let os2 = getRawEndpoint (bhRequestEndpoint (OS2Requests.deleteAgent (OS2Types.AgentId "z")))
          os3 = getRawEndpoint (bhRequestEndpoint (OS3Requests.deleteAgent (OS3Types.AgentId "z")))
      os2 `shouldBe` ["_plugins", "_ml", "agents", "z"]
      os3 `shouldBe` os2

    it "OS2/OS3 build the same searchAgents path (OS1 excluded: no ML agents)" $ do
      let os2 = getRawEndpoint (bhRequestEndpoint (OS2Requests.searchAgents Nothing))
          os3 = getRawEndpoint (bhRequestEndpoint (OS3Requests.searchAgents Nothing))
      os2 `shouldBe` ["_plugins", "_ml", "agents", "_search"]
      os3 `shouldBe` os2

    it "OS2/OS3 build the same executeAgent path (OS1 excluded: no ML agents)" $ do
      let os2Req =
            OS2Types.ExecuteAgentRequest
              { OS2Types.executeAgentParameters = Nothing,
                OS2Types.executeAgentInput = Nothing
              }
          os3Req =
            OS3Types.ExecuteAgentRequest
              { OS3Types.executeAgentParameters = Nothing,
                OS3Types.executeAgentInput = Nothing
              }
          os2 = getRawEndpoint (bhRequestEndpoint (OS2Requests.executeAgent (OS2Types.AgentId "z") os2Req))
          os3 = getRawEndpoint (bhRequestEndpoint (OS3Requests.executeAgent (OS3Types.AgentId "z") os3Req))
      os2 `shouldBe` ["_plugins", "_ml", "agents", "z", "_execute"]
      os3 `shouldBe` os2

    it "OS2/OS3 build the same registerModel path and ?deploy=true query (OS1 excluded: no register/deploy API in OS 1.3)" $ do
      let os2Req =
            OS2Types.RegisterModelRequest
              { OS2Types.registerModelName = "x",
                OS2Types.registerModelVersion = Nothing,
                OS2Types.registerModelDescription = Nothing,
                OS2Types.registerModelFormat = Nothing,
                OS2Types.registerModelFunctionName = Nothing,
                OS2Types.registerModelContentHashValue = Nothing,
                OS2Types.registerModelConfig = Nothing,
                OS2Types.registerModelUrl = Nothing,
                OS2Types.registerModelGroupId = Nothing,
                OS2Types.registerModelConnectorId = Nothing,
                OS2Types.registerModelConnector = Nothing,
                OS2Types.registerModelIsEnabled = Nothing
              }
          os3Req =
            OS3Types.RegisterModelRequest
              { OS3Types.registerModelName = "x",
                OS3Types.registerModelVersion = Nothing,
                OS3Types.registerModelDescription = Nothing,
                OS3Types.registerModelFormat = Nothing,
                OS3Types.registerModelFunctionName = Nothing,
                OS3Types.registerModelContentHashValue = Nothing,
                OS3Types.registerModelConfig = Nothing,
                OS3Types.registerModelUrl = Nothing,
                OS3Types.registerModelGroupId = Nothing,
                OS3Types.registerModelConnectorId = Nothing,
                OS3Types.registerModelConnector = Nothing,
                OS3Types.registerModelIsEnabled = Nothing
              }
          path2 = getRawEndpoint (bhRequestEndpoint (OS2Requests.registerModel os2Req))
          path3 = getRawEndpoint (bhRequestEndpoint (OS3Requests.registerModel os3Req))
          q2 = getRawEndpointQueries (bhRequestEndpoint (OS2Requests.registerModelWith True os2Req))
          q3 = getRawEndpointQueries (bhRequestEndpoint (OS3Requests.registerModelWith True os3Req))
      path2 `shouldBe` ["_plugins", "_ml", "models", "_register"]
      path3 `shouldBe` path2
      q2 `shouldBe` [("deploy", Just "true")]
      q3 `shouldBe` q2

    it "OS2/OS3 build the same registerAgent path (OS1 excluded: agents shipped in OS 2.12)" $ do
      let os2Req =
            OS2Types.RegisterAgentRequest
              { OS2Types.registerAgentName = "x",
                OS2Types.registerAgentType = OS2Types.AgentTypeFlow,
                OS2Types.registerAgentDescription = Nothing,
                OS2Types.registerAgentLlm = Nothing,
                OS2Types.registerAgentTools = Nothing,
                OS2Types.registerAgentParameters = Nothing,
                OS2Types.registerAgentMemory = Nothing,
                OS2Types.registerAgentAppType = Nothing
              }
          os3Req =
            OS3Types.RegisterAgentRequest
              { OS3Types.registerAgentName = "x",
                OS3Types.registerAgentType = OS3Types.AgentTypeFlow,
                OS3Types.registerAgentDescription = Nothing,
                OS3Types.registerAgentLlm = Nothing,
                OS3Types.registerAgentTools = Nothing,
                OS3Types.registerAgentParameters = Nothing,
                OS3Types.registerAgentMemory = Nothing,
                OS3Types.registerAgentAppType = Nothing
              }
          path2 = getRawEndpoint (bhRequestEndpoint (OS2Requests.registerAgent os2Req))
          path3 = getRawEndpoint (bhRequestEndpoint (OS3Requests.registerAgent os3Req))
      path2 `shouldBe` ["_plugins", "_ml", "agents", "_register"]
      path3 `shouldBe` path2

  -- =========================================================================
  -- Live integration tests (OS3 only; OS1/OS2 share identical code paths)
  --
  -- Strategy: hit each endpoint with a non-existent model id and assert
  -- @Left EsError@ (HTTP 4xx). This deterministically proves the endpoint
  -- path, method, request body shape, and error decoder are correct
  -- without depending on async task completion or a real model fixture.
  -- registerModel is the exception: a syntactically valid body returns
  -- @Right MLTaskAck@ synchronously (the async outcome is irrelevant).
  --
  -- Note on state: registerModel registers a model metadata entry on the
  -- cluster. The entry may be torn down via 'deleteModel' once undeployed;
  -- tests that only need a 404 use a non-existent id to avoid touching
  -- cluster state. Long-term accumulation from any untorn-down entries is
  -- recoverable via @make compose-detach-down@ which clears the docker
  -- volume.
  -- =========================================================================
  describe "ML Model API live integration" $ do
    os3It <- runIO os3OnlyIT

    os3It "getModel on a non-existent id returns Left (404)" $
      withTestEnv $ do
        resp <- OS3Client.getModel (OS3Types.ModelId "bloodhound-nonexistent-model-id")
        liftIO $
          case resp of
            Left _ -> pure ()
            Right r ->
              expectationFailure ("Expected Left for non-existent id, got Right: " <> show r)

    os3It "registerModel returns a task_id synchronously" $
      withTestEnv $ do
        -- ML Commons refuses registration when only_run_on_ml_node is
        -- true and no dedicated ML node is provisioned; relax it so the
        -- endpoint is exercised regardless of cluster topology.
        let mlSettings :: HashMap Text Value
            mlSettings = HashMap.fromList [("plugins.ml_commons.only_run_on_ml_node", Bool False)]
        _ <-
          performBHRequest $
            updateClusterSettings
              defaultClusterSettingsUpdate
                { clusterSettingsUpdatePersistent = Just mlSettings
                }
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let req =
              OS3Types.RegisterModelRequest
                { OS3Types.registerModelName = Text.pack ("bloodhound-test-reg-" <> suffix),
                  OS3Types.registerModelVersion = Just "1.0.0",
                  OS3Types.registerModelDescription = Nothing,
                  OS3Types.registerModelFormat = Just OS3Types.ModelFormatTorchScript,
                  OS3Types.registerModelFunctionName = Nothing,
                  OS3Types.registerModelContentHashValue = Nothing,
                  OS3Types.registerModelConfig = Nothing,
                  OS3Types.registerModelUrl = Nothing,
                  OS3Types.registerModelGroupId = Nothing,
                  OS3Types.registerModelConnectorId = Nothing,
                  OS3Types.registerModelConnector = Nothing,
                  OS3Types.registerModelIsEnabled = Nothing,
                  OS3Types.registerModelProvisionedBy = Nothing
                }
        resp <- OS3Client.registerModel req
        liftIO $
          case resp of
            Left e ->
              expectationFailure ("Expected Right MLTaskAck, got Left: " <> show e)
            Right ack ->
              OS3Types.mlTaskAckTaskId ack `shouldSatisfy` isJust

    os3It "deployModel on a non-existent id returns Left" $
      withTestEnv $ do
        resp <- OS3Client.deployModel (OS3Types.ModelId "bloodhound-nonexistent-model-id") Nothing
        liftIO $
          case resp of
            Left _ -> pure ()
            Right r ->
              expectationFailure ("Expected Left for non-existent id, got Right: " <> show r)

    os3It "predict on a non-existent id returns Left" $
      withTestEnv $ do
        resp <-
          OS3Client.predict
            (OS3Types.AlgorithmName "remote")
            (OS3Types.ModelId "bloodhound-nonexistent-model-id")
            (object ["parameters" .= object []])
        liftIO $
          case resp of
            Left _ -> pure ()
            Right r ->
              expectationFailure ("Expected Left for non-existent id, got Right: " <> show r)

    os3It "deleteModel on a non-existent id returns Left (404)" $
      withTestEnv $ do
        resp <- OS3Client.deleteModel (OS3Types.ModelId "bloodhound-nonexistent-model-id")
        liftIO $
          case resp of
            Left _ -> pure ()
            Right r ->
              expectationFailure ("Expected Left for non-existent id, got Right: " <> show r)

    os3It "undeployModel on a non-existent id returns Left" $
      withTestEnv $ do
        resp <- OS3Client.undeployModel (OS3Types.ModelId "bloodhound-nonexistent-model-id")
        liftIO $
          case resp of
            Left _ -> pure ()
            Right r ->
              expectationFailure ("Expected Left for non-existent id, got Right: " <> show r)

    os3It "updateModel on a non-existent id returns Left (404)" $
      withTestEnv $ do
        let updReq =
              OS3Types.UpdateModelRequest
                { OS3Types.updateModelName = Just "bloodhound-test-updated",
                  OS3Types.updateModelDescription = Nothing,
                  OS3Types.updateModelIsEnabled = Nothing,
                  OS3Types.updateModelGroupId = Nothing,
                  OS3Types.updateModelConfig = Nothing,
                  OS3Types.updateModelConnectorId = Nothing,
                  OS3Types.updateModelConnector = Nothing,
                  OS3Types.updateModelRateLimiter = Nothing,
                  OS3Types.updateModelGuardrails = Nothing,
                  OS3Types.updateModelInterface = Nothing
                }
        resp <- OS3Client.updateModel (OS3Types.ModelId "bloodhound-nonexistent-model-id") updReq
        liftIO $
          case resp of
            Left _ -> pure ()
            Right r ->
              expectationFailure ("Expected Left for non-existent id, got Right: " <> show r)

    os3It "searchModels returns a Right envelope on an empty cluster" $
      withTestEnv $ do
        resp <- OS3Client.searchModels Nothing
        liftIO $
          case resp of
            Left e ->
              expectationFailure ("Expected Right ModelSearchResponse, got Left: " <> show e)
            Right _ -> pure ()

    os3It "listModels returns a Right envelope on an empty cluster" $
      withTestEnv $ do
        resp <- OS3Client.listModels Nothing
        liftIO $
          case resp of
            Left e ->
              expectationFailure ("Expected Right ModelSearchResponse, got Left: " <> show e)
            Right _ -> pure ()

    os3It "getAgent on a non-existent id returns Left (404)" $
      withTestEnv $ do
        resp <- OS3Client.getAgent (OS3Types.AgentId "bloodhound-nonexistent-agent-id")
        liftIO $
          case resp of
            Left _ -> pure ()
            Right r ->
              expectationFailure ("Expected Left for non-existent id, got Right: " <> show r)

    os3It "deleteAgent on a non-existent id returns Left (404)" $
      withTestEnv $ do
        resp <- OS3Client.deleteAgent (OS3Types.AgentId "bloodhound-nonexistent-agent-id")
        liftIO $
          case resp of
            Left _ -> pure ()
            Right r ->
              expectationFailure ("Expected Left for non-existent id, got Right: " <> show r)

    os3It "searchAgents returns a Right envelope on an empty cluster" $
      withTestEnv $ do
        resp <- OS3Client.searchAgents Nothing
        liftIO $
          case resp of
            Left e ->
              expectationFailure ("Expected Right AgentSearchResponse, got Left: " <> show e)
            Right _ -> pure ()