From 59781fa61a7e40790cc14a9a4c5ec5dd744e15ff Mon Sep 17 00:00:00 2001 From: Paul Santus Date: Wed, 27 May 2026 10:30:18 +0200 Subject: [PATCH] feat(bedrock): add request_metadata support for per-request cost attribution Add request_metadata config option to BedrockModel that maps to the requestMetadata field in Converse/ConverseStream API requests. This enables per-request finops by attaching key-value tags (e.g. team, environment, feature) that appear in model invocation logs. Default behavior is unchanged (no metadata sent). When set, the dict is passed as-is to the Bedrock API. Ref: https://docs.aws.amazon.com/bedrock/latest/userguide/cost-mgmt-request-metadata.html --- strands-py/src/strands/models/bedrock.py | 9 +++++++++ .../tests/strands/models/test_bedrock.py | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/strands-py/src/strands/models/bedrock.py b/strands-py/src/strands/models/bedrock.py index 4cd6f7fbc4..c05bd1fae3 100644 --- a/strands-py/src/strands/models/bedrock.py +++ b/strands-py/src/strands/models/bedrock.py @@ -118,6 +118,9 @@ class BedrockConfig(BaseModelConfig, total=False): See https://docs.aws.amazon.com/bedrock/latest/userguide/structured-output.html temperature: Controls randomness in generation (higher = more random) top_p: Controls diversity via nucleus sampling (alternative to temperature) + request_metadata: Key-value metadata to attach to the request for cost tracking and attribution. + Appears in model invocation logs for per-request finops. Max 16 entries, keys/values max 256 chars. + See https://docs.aws.amazon.com/bedrock/latest/userguide/cost-mgmt-request-metadata.html use_native_token_count: Whether to use the native Bedrock CountTokens API. When True, count_tokens() calls the Bedrock API for accurate counts. When False (default), skips the API call and uses the local estimator. @@ -141,6 +144,7 @@ class BedrockConfig(BaseModelConfig, total=False): max_tokens: int | None model_id: str include_tool_result_status: Literal["auto"] | bool | None + request_metadata: dict[str, str] | None service_tier: str | None stop_sequences: list[str] | None streaming: bool | None @@ -333,6 +337,11 @@ def _format_request( ] if value is not None }, + **( + {"requestMetadata": self.config["request_metadata"]} + if self.config.get("request_metadata") + else {} + ), **( self.config["additional_args"] if "additional_args" in self.config and self.config["additional_args"] is not None diff --git a/strands-py/tests/strands/models/test_bedrock.py b/strands-py/tests/strands/models/test_bedrock.py index 319b5574fb..a5e43681d6 100644 --- a/strands-py/tests/strands/models/test_bedrock.py +++ b/strands-py/tests/strands/models/test_bedrock.py @@ -442,6 +442,25 @@ def test_format_request_with_service_tier(model, messages, model_id): assert tru_request == exp_request +def test_format_request_with_request_metadata(model, messages, model_id): + model.update_config(request_metadata={"team": "orchestrator", "environment": "prod"}) + tru_request = model._format_request(messages) + exp_request = { + "inferenceConfig": {}, + "modelId": model_id, + "messages": messages, + "requestMetadata": {"team": "orchestrator", "environment": "prod"}, + "system": [], + } + + assert tru_request == exp_request + + +def test_format_request_without_request_metadata(model, messages, model_id): + tru_request = model._format_request(messages) + assert "requestMetadata" not in tru_request + + def test_format_request_inference_config(model, messages, model_id, inference_config): model.update_config(**inference_config) tru_request = model._format_request(messages)