diff --git a/litellm/litellm_core_utils/secret_redaction.py b/litellm/litellm_core_utils/secret_redaction.py index 390abf41955..70b2cd08b4c 100644 --- a/litellm/litellm_core_utils/secret_redaction.py +++ b/litellm/litellm_core_utils/secret_redaction.py @@ -65,7 +65,7 @@ def _build_secret_patterns() -> "re.Pattern[str]": # private_key with PEM-aware value capture r"""private_key['\"]?\s*[:=]\s*['\"]?(?:-----BEGIN[A-Z \-]*PRIVATE KEY-----[\s\S]*?-----END[A-Z \-]*PRIVATE KEY-----|[^\s,'\"})\]{}>]+)""", r"(?:master_key|xai_key|database_url|db_url|connection_string|" - r"aws_secret_access_key|aws_session_token|aws_access_key_id|" + r"aws_secret_access_key|aws_session_token|aws_access_key_id|s3_secret_access_key|s3_access_key_id|" r"signing_key|encryption_key|" r"auth_token|access_token|refresh_token|" r"slack_webhook_url|webhook_url|" diff --git a/litellm/types/router.py b/litellm/types/router.py index fd426835d65..8ad01206811 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -305,6 +305,8 @@ class CredentialLiteLLMParams(BaseModel): s3_bucket_name: str | None = None s3_endpoint_url: str | None = None s3_region_name: str | None = None + s3_access_key_id: str | None = None + s3_secret_access_key: str | None = None s3_encryption_key_id: str | None = None s3_bucket_owner: str | None = None aws_batch_role_arn: str | None = None diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 142304223d0..e1d43b7fccb 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3840,6 +3840,9 @@ bedrock_batch_litellm_params: Final = ( "s3_endpoint_url", "s3_output_bucket_name", "s3_bucket_owner", + "s3_access_key_id", + "s3_secret_access_key", + "s3_encryption_key_id", "bedrock_tags", ) diff --git a/tests/e2e/coverage_registry/llm_conversational.yaml b/tests/e2e/coverage_registry/llm_conversational.yaml index 36fbd39154d..49d4d92ff0b 100644 --- a/tests/e2e/coverage_registry/llm_conversational.yaml +++ b/tests/e2e/coverage_registry/llm_conversational.yaml @@ -31,6 +31,7 @@ - {id: llm.chat_completions.bedrock_converse.thinking.nonstream.works, module: llm, tier: P1, subject_endpoint: chat_completions, route: bedrock_converse, capability: thinking, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Anthropic thinking on Bedrock"} - {id: llm.chat_completions.bedrock_converse.response_headers.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: bedrock_converse, capability: response_headers, streaming: nonstream, assertions: [works], source: "llms/bedrock/chat/converse_handler.py:248", rationale: "Bedrock request ids must surface as llm_provider-* response headers on /chat/completions so callers can correlate calls with AWS-side logs (#37003)", fail_before_fix: proven} - {id: llm.chat_completions.bedrock_converse.response_headers.stream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: bedrock_converse, capability: response_headers, streaming: stream, assertions: [works], source: "llms/bedrock/chat/converse_handler.py:154", rationale: "The llm_provider-* headers must also surface on streaming /chat/completions, where CustomStreamWrapper carries them instead of the nonstream setter"} +- {id: llm.chat_completions.bedrock_converse.batch_deployment.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: bedrock_converse, capability: batch_deployment, streaming: nonstream, assertions: [works], source: "types/utils.py bedrock_batch_litellm_params", rationale: "A deployment carrying the documented batch-only S3 keys (s3_access_key_id, s3_secret_access_key, s3_encryption_key_id) must still serve ordinary chat; unregistered keys fall into optional_params and are forwarded as additionalModelRequestFields, which Bedrock 400s and which puts the S3 secret in the request body and debug log (LIT-8290)", fail_before_fix: proven} - {id: llm.chat_completions.bedrock_invoke.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: bedrock_invoke, capability: basic, streaming: nonstream, assertions: [works], source: "proxy_server.py:8455", rationale: "Regional inference-profile ids (us.anthropic.*) over the invoke route, the deployment shape behind a customer timeout report on v1.90.0"} - {id: llm.chat_completions.bedrock_invoke.basic.stream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: bedrock_invoke, capability: basic, streaming: stream, assertions: [works], source: "proxy_server.py:8455", rationale: "Streaming with regional inference-profile ids over the invoke route"} - {id: llm.chat_completions.vertex.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: vertex, capability: basic, streaming: nonstream, assertions: [works], source: "proxy_server.py:8455", rationale: "P0 route; Vertex AI"} diff --git a/tests/e2e/coverage_registry/schema.py b/tests/e2e/coverage_registry/schema.py index fa6dad90126..8b0d38a083c 100644 --- a/tests/e2e/coverage_registry/schema.py +++ b/tests/e2e/coverage_registry/schema.py @@ -63,6 +63,7 @@ LlmRoute = Literal[ LlmCapability = Literal[ "assume_role", "basic", + "batch_deployment", "count_tokens", "govcloud_partition", "input_validation", diff --git a/tests/e2e/llm_translation/test_bedrock_provider_matrix_e2e.py b/tests/e2e/llm_translation/test_bedrock_provider_matrix_e2e.py index 3c6aaa75ab3..5f0a931109c 100644 --- a/tests/e2e/llm_translation/test_bedrock_provider_matrix_e2e.py +++ b/tests/e2e/llm_translation/test_bedrock_provider_matrix_e2e.py @@ -131,6 +131,50 @@ class TestBedrockResponseHeaders: _assert_request_id_header(result) +def _register_bedrock_batch_deployment(client: PassthroughClient, resources: ResourceManager) -> str: + model = f"e2e-bedrock-batch-chat-{unique_marker()}" + model_id = client.proxy.create_model( + model, + LiteLLMParamsBody( + model=CONVERSE_REGIONAL_BACKEND, + aws_access_key_id="os.environ/AWS_ACCESS_KEY_ID", + aws_secret_access_key="os.environ/AWS_SECRET_ACCESS_KEY", + aws_region_name="os.environ/AWS_REGION", + s3_bucket_name="os.environ/AWS_BATCH_S3_BUCKET", + s3_access_key_id="os.environ/AWS_ACCESS_KEY_ID", + s3_secret_access_key="os.environ/AWS_SECRET_ACCESS_KEY", + s3_encryption_key_id=f"alias/e2e-unused-{unique_marker()}", + aws_batch_role_arn="os.environ/AWS_BATCH_ROLE_ARN", + ), + ) + resources.defer(lambda: client.proxy.delete_model(model_id)) + return model + + +class TestBedrockBatchDeploymentServesChat: + @pytest.mark.covers( + "llm.chat_completions.bedrock_converse.batch_deployment.nonstream.works", + exercised_on=[], + ) + def test_batch_s3_keys_do_not_break_chat( + self, client: PassthroughClient, resources: ResourceManager + ) -> None: + model = _register_bedrock_batch_deployment(client, resources) + key = resources.key() + + result = client.proxy.transport.send( + "/chat/completions", + headers=client.proxy.transport.bearer(key), + json=ChatBody(model=model, messages=_prompt(), max_tokens=64), + ) + + assert result.ok, ( + f"chat on a batch-configured deployment failed: {result.status_code} {result.body[:300]}; " + "batch-only S3 keys were forwarded to Bedrock as additionalModelRequestFields" + ) + _assert_completion(ChatResponse.model_validate_json(result.body)) + + class TestBedrockInvokeRegionalModelIds: @pytest.mark.covers("llm.chat_completions.bedrock_invoke.basic.nonstream.works", exercised_on=[]) def test_invoke_regional_id_completes( diff --git a/tests/e2e/models.py b/tests/e2e/models.py index 9440505e58c..355329585fb 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -1011,6 +1011,7 @@ class LiteLLMParamsBody(BaseModel): s3_region_name: str | None = None s3_access_key_id: str | None = None s3_secret_access_key: str | None = None + s3_encryption_key_id: str | None = None aws_batch_role_arn: str | None = None aws_role_name: str | None = None aws_session_name: str | None = None diff --git a/tests/test_litellm/test_secret_redaction.py b/tests/test_litellm/test_secret_redaction.py index 85933fbf9e8..f58ade11d1c 100644 --- a/tests/test_litellm/test_secret_redaction.py +++ b/tests/test_litellm/test_secret_redaction.py @@ -629,6 +629,25 @@ def test_aws_credential_redaction_catches_quoted_values(): assert redact_string(safe) == safe +def test_bedrock_batch_s3_credential_redaction_in_deployment_dump(): + """The router logs each deployment's litellm_params at DEBUG. A Bedrock batch + deployment carries s3_secret_access_key there, which the aws_* key-name rule + did not cover, so the S3 secret was printed verbatim (LIT-8290).""" + cases = ( + "{'s3_secret_access_key': 'wJalrXUtnFEMIK7MDENGbPxRfiCYEXAMPLEKEY'}", + "s3_secret_access_key=wJalrXUtnFEMIK7MDENGbPxRfiCYEXAMPLEKEY", + "{'s3_access_key_id': 'not-an-akia-shaped-value'}", + ) + for secret_line in cases: + result = redact_string(secret_line) + assert "REDACTED" in result, f"S3 credential redaction missed: {secret_line!r}" + assert "wJalrXUtnFEMIK7MDENGbPxRfiCYEXAMPLEKEY" not in result + assert "not-an-akia-shaped-value" not in result + + safe = "'s3_bucket_name': 'my-batch-bucket'" + assert redact_string(safe) == safe + + @pytest.mark.parametrize( "extra", ( diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 3eba1bcfced..1e59f4d878e 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -4682,6 +4682,33 @@ def test_bedrock_batch_params_never_reach_the_provider(): ) +def test_documented_batch_s3_credentials_never_reach_the_provider(): + """The Bedrock batch docs tell users to put s3_access_key_id, s3_secret_access_key + and s3_encryption_key_id on the deployment. Left unregistered they are swept into + additionalModelRequestFields, Bedrock 400s ordinary chat on that deployment with + `s3_secret_access_key: Extra inputs are not permitted`, and the S3 secret is sent + to the provider and printed in the debug log (LIT-8290). + """ + configured = { + "s3_access_key_id": "configured-access-key-id", + "s3_secret_access_key": "configured-secret-access-key", + "s3_encryption_key_id": "arn:aws:kms:us-east-1:000000000000:key/configured", + } + kwargs = {"a_real_provider_specific_param": 1, **configured} + + non_default = get_non_default_completion_params(dict(kwargs)) + + assert non_default == {"a_real_provider_specific_param": 1}, ( + "documented batch S3 credentials leaked into the provider params: " + f"{sorted(set(non_default) - {'a_real_provider_specific_param'})}" + ) + + batch_params = dict(GenericLiteLLMParams(**kwargs)) + assert {field: batch_params.get(field) for field in configured} == configured, ( + "registering these must not strip them from the batch path" + ) + + def test_client_side_timeout_marker_never_reaches_the_provider(): """The proxy stamps kwargs["client_side_timeout"] = True whenever a request carries a caller-supplied timeout (body timeout / request_timeout / stream_timeout or the diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index d9c7e370d4f..0b09f3654dc 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -31245,6 +31245,8 @@ export interface components { regional_processing_uplift_multiplier_us?: number | null; /** Rpm */ rpm?: number | null; + /** S3 Access Key Id */ + s3_access_key_id?: string | null; /** S3 Bucket Name */ s3_bucket_name?: string | null; /** S3 Bucket Owner */ @@ -31257,6 +31259,8 @@ export interface components { s3_output_bucket_name?: string | null; /** S3 Region Name */ s3_region_name?: string | null; + /** S3 Secret Access Key */ + s3_secret_access_key?: string | null; /** Search Context Cost Per Query */ search_context_cost_per_query?: { [key: string]: unknown; @@ -42045,6 +42049,8 @@ export interface components { regional_processing_uplift_multiplier_us?: number | null; /** Rpm */ rpm?: number | null; + /** S3 Access Key Id */ + s3_access_key_id?: string | null; /** S3 Bucket Name */ s3_bucket_name?: string | null; /** S3 Bucket Owner */ @@ -42057,6 +42063,8 @@ export interface components { s3_output_bucket_name?: string | null; /** S3 Region Name */ s3_region_name?: string | null; + /** S3 Secret Access Key */ + s3_secret_access_key?: string | null; /** Search Context Cost Per Query */ search_context_cost_per_query?: { [key: string]: unknown;