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 ()