Skip to content

Commit da15434

Browse files
authored
Fix bedrock stalled stream protection not working with custom http client, and support additional_model_request_fields (#1877)
Supports additional_model_request_fields, to be able to enable thinking tokens from claude models. Also fixes the following error, which is caused by the stall-stream config somehow not working with our custom new http client: ``` BamlClientError: Something went wrong with the LLM client: DispatchFailure( DispatchFailure { source: ConnectorError { kind: Timeout, source: ThroughputBelowMinimum { expected: Throughput { bytes_read: 1, per_time_elapsed: 1s, }, actual: Throughput { bytes_read: 0, per_time_elapsed: 1s, }, }, connection: Unknown, }, }, ) ``` <!-- ELLIPSIS_HIDDEN --> ---- > [!IMPORTANT] > Fixes stalled stream protection with custom HTTP client and adds support for `additional_model_request_fields` in AWS Bedrock client. > > - **Behavior**: > - Fixes stalled stream protection issue with custom HTTP client in `AwsClient` by disabling `StalledStreamProtectionConfig`. > - Adds support for `additional_model_request_fields` in `UnresolvedAwsBedrock` and `ResolvedAwsBedrock`. > - **Functions**: > - Updates `chat_anyhow()` in `AwsClient` to handle multiple text blocks. > - Adds `without_meta()` to `UnresolvedAwsBedrock` to strip metadata. > - **Integration Tests**: > - Adds `TestAwsClaude37` function in `aws.baml` to test stalled stream protection. > - **Client Code**: > - Updates client code in Go, Python, Ruby, and TypeScript to support new `additional_model_request_fields` feature. > > <sup>This description was created by </sup>[<img alt="Ellipsis" src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=BoundaryML%2Fbaml&utm_source=github&utm_medium=referral)<sup> for e87724c. You can [customize](https://app.ellipsis.dev/BoundaryML/settings/summaries) this summary. It will automatically update as commits are pushed.</sup> <!-- ELLIPSIS_HIDDEN -->
1 parent 332492c commit da15434

43 files changed

Lines changed: 2361 additions & 656 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

engine/Cargo.lock

Lines changed: 467 additions & 128 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

engine/baml-lib/llm-client/src/clients/aws_bedrock.rs

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,16 @@ use crate::{
55
UnresolvedAllowedRoleMetadata, UnresolvedFinishReasonFilter, UnresolvedRolesSelection,
66
};
77
use anyhow::Result;
8+
use indexmap::IndexMap;
89
use secrecy::SecretString;
910

10-
use baml_types::{ApiKeyWithProvenance, EvaluationContext, GetEnvVar, StringOr};
11+
use baml_types::{ApiKeyWithProvenance, EvaluationContext, GetEnvVar, StringOr, UnresolvedValue};
12+
use serde_json::Value;
1113

1214
use super::helpers::{Error, PropertyHandler};
1315

1416
#[derive(Debug, Clone)]
15-
pub struct UnresolvedAwsBedrock {
17+
pub struct UnresolvedAwsBedrock<Meta> {
1618
model: Option<StringOr>,
1719
region: Option<StringOr>,
1820
access_key_id: Option<StringOr>,
@@ -24,6 +26,7 @@ pub struct UnresolvedAwsBedrock {
2426
supported_request_modes: SupportedRequestModes,
2527
inference_config: Option<UnresolvedInferenceConfiguration>,
2628
finish_reason_filter: UnresolvedFinishReasonFilter,
29+
additional_model_request_fields: Option<IndexMap<String, (Meta, UnresolvedValue<Meta>)>>,
2730
}
2831

2932
#[derive(Debug, Clone)]
@@ -76,6 +79,7 @@ pub struct ResolvedAwsBedrock {
7679
pub allowed_role_metadata: AllowedRoleMetadata,
7780
pub supported_request_modes: SupportedRequestModes,
7881
pub finish_reason_filter: FinishReasonFilter,
82+
pub additional_model_request_fields: Option<IndexMap<String, Value>>,
7983
}
8084

8185
impl std::fmt::Debug for ResolvedAwsBedrock {
@@ -92,6 +96,10 @@ impl std::fmt::Debug for ResolvedAwsBedrock {
9296
.field("allowed_role_metadata", &self.allowed_role_metadata)
9397
.field("supported_request_modes", &self.supported_request_modes)
9498
.field("finish_reason_filter", &self.finish_reason_filter)
99+
.field(
100+
"additional_model_request_fields",
101+
&self.additional_model_request_fields,
102+
)
95103
.finish()
96104
}
97105
}
@@ -122,7 +130,33 @@ impl ResolvedAwsBedrock {
122130
}
123131
}
124132

125-
impl UnresolvedAwsBedrock {
133+
impl<Meta: Clone> UnresolvedAwsBedrock<Meta> {
134+
pub fn without_meta(&self) -> UnresolvedAwsBedrock<()> {
135+
UnresolvedAwsBedrock {
136+
model: self.model.clone(),
137+
region: self.region.clone(),
138+
access_key_id: self.access_key_id.clone(),
139+
secret_access_key: self.secret_access_key.clone(),
140+
session_token: self.session_token.clone(),
141+
profile: self.profile.clone(),
142+
role_selection: self.role_selection.clone(),
143+
allowed_role_metadata: self.allowed_role_metadata.clone(),
144+
supported_request_modes: self.supported_request_modes.clone(),
145+
inference_config: self.inference_config.clone(),
146+
finish_reason_filter: self.finish_reason_filter.clone(),
147+
additional_model_request_fields: self.additional_model_request_fields.as_ref().map(
148+
|fields| {
149+
fields
150+
.iter()
151+
.map(|(k, (_, v))| (k.clone(), ((), v.without_meta())))
152+
.collect::<IndexMap<_, _>>()
153+
},
154+
),
155+
}
156+
}
157+
}
158+
159+
impl<Meta: Clone> UnresolvedAwsBedrock<Meta> {
126160
pub fn required_env_vars(&self) -> HashSet<String> {
127161
let mut env_vars = HashSet::new();
128162
if let Some(m) = self.model.as_ref() {
@@ -309,6 +343,17 @@ impl UnresolvedAwsBedrock {
309343
}
310344
}
311345

346+
let additional_model_request_fields = self
347+
.additional_model_request_fields
348+
.as_ref()
349+
.map(|fields| {
350+
fields
351+
.iter()
352+
.map(|(k, (_, v))| Ok((k.clone(), v.resolve_serde::<serde_json::Value>(ctx)?)))
353+
.collect::<Result<IndexMap<_, _>>>()
354+
})
355+
.transpose()?;
356+
312357
Ok(ResolvedAwsBedrock {
313358
model: model.resolve(ctx)?,
314359
region,
@@ -325,12 +370,11 @@ impl UnresolvedAwsBedrock {
325370
.map(|c| c.resolve(ctx))
326371
.transpose()?,
327372
finish_reason_filter: self.finish_reason_filter.resolve(ctx)?,
373+
additional_model_request_fields,
328374
})
329375
}
330376

331-
pub fn create_from<Meta: Clone>(
332-
mut properties: PropertyHandler<Meta>,
333-
) -> Result<Self, Vec<Error<Meta>>> {
377+
pub fn create_from(mut properties: PropertyHandler<Meta>) -> Result<Self, Vec<Error<Meta>>> {
334378
let model = {
335379
// Add AWS Bedrock-specific validation logic here
336380
let model_id = properties.ensure_string("model_id", false);
@@ -374,6 +418,9 @@ impl UnresolvedAwsBedrock {
374418
let role_selection = properties.ensure_roles_selection();
375419
let allowed_metadata = properties.ensure_allowed_metadata();
376420
let supported_request_modes = properties.ensure_supported_request_modes();
421+
let additional_model_request_fields = properties
422+
.ensure_map("additional_model_request_fields", false)
423+
.map(|(_, map, _)| map);
377424

378425
let inference_config = {
379426
let mut inference_config = UnresolvedInferenceConfiguration {
@@ -452,6 +499,7 @@ impl UnresolvedAwsBedrock {
452499
supported_request_modes,
453500
inference_config,
454501
finish_reason_filter,
502+
additional_model_request_fields,
455503
})
456504
}
457505
}

engine/baml-lib/llm-client/src/clients/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ pub mod vertex;
2323
pub enum UnresolvedClientProperty<Meta> {
2424
OpenAI(openai::UnresolvedOpenAI<Meta>),
2525
Anthropic(anthropic::UnresolvedAnthropic<Meta>),
26-
AWSBedrock(aws_bedrock::UnresolvedAwsBedrock),
26+
AWSBedrock(aws_bedrock::UnresolvedAwsBedrock<Meta>),
2727
Vertex(vertex::UnresolvedVertex<Meta>),
2828
GoogleAI(google_ai::UnresolvedGoogleAI<Meta>),
2929
RoundRobin(round_robin::UnresolvedRoundRobin<Meta>),
@@ -106,7 +106,7 @@ impl<Meta: Clone> UnresolvedClientProperty<Meta> {
106106
UnresolvedClientProperty::Anthropic(a.without_meta())
107107
}
108108
UnresolvedClientProperty::AWSBedrock(a) => {
109-
UnresolvedClientProperty::AWSBedrock(a.clone())
109+
UnresolvedClientProperty::AWSBedrock(a.without_meta())
110110
}
111111
UnresolvedClientProperty::Vertex(v) => {
112112
UnresolvedClientProperty::Vertex(v.without_meta())

engine/baml-runtime/Cargo.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@ tempfile = "3.19.0"
109109

110110

111111
[target.'cfg(target_arch = "wasm32")'.dependencies]
112-
aws-config = { version = "1.5.3", default-features = false, features = [] }
113-
aws-sdk-bedrockruntime = { version = "1.37.0", default-features = false, features = [
112+
aws-config = { version = "1.6.2", default-features = false, features = [] }
113+
aws-sdk-bedrockruntime = { version = "1.85.0", default-features = false, features = [
114114
] }
115115
colored = { version = "2.1.0", default-features = false, features = [
116116
"no-color",
@@ -145,8 +145,8 @@ web-sys = { version = "0.3.69", features = [
145145
wasmtimer = "0.4.1"
146146

147147
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
148-
aws-config = "1.5.3"
149-
aws-sdk-bedrockruntime = "1.37.0"
148+
aws-config = "1.6.2"
149+
aws-sdk-bedrockruntime = "1.85.0"
150150
axum = "0.7.5"
151151
axum-extra = { version = "0.9.3", features = ["erased-json", "typed-header"] }
152152
criterion = "0.5.1"

engine/baml-runtime/src/internal/llm_client/primitive/aws/aws_client.rs

Lines changed: 93 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::collections::HashMap;
22
use std::sync::Arc;
3+
use std::time::Duration;
34

45
use aws_config::Region;
56
use aws_config::{identity::IdentityCache, retry::RetryConfig, BehaviorVersion, ConfigLoader};
@@ -10,7 +11,7 @@ use aws_credential_types::{
1011
},
1112
Credentials,
1213
};
13-
use aws_sdk_bedrockruntime::config::Intercept;
14+
use aws_sdk_bedrockruntime::config::{Intercept, StalledStreamProtectionConfig};
1415
use aws_sdk_bedrockruntime::Client as BedrockRuntimeClient;
1516
use aws_sdk_bedrockruntime::{self as bedrock, operation::converse::ConverseOutput};
1617

@@ -19,6 +20,7 @@ use aws_smithy_json::serialize::JsonObjectWriter;
1920
use aws_smithy_runtime_api::client::result::SdkError;
2021
use aws_smithy_runtime_api::http::Headers;
2122
use aws_smithy_types::Blob;
23+
use aws_smithy_types::Document;
2224
use baml_types::tracing::events::{
2325
ContentId, FunctionId, HTTPBody, HTTPRequest, HTTPResponse, HttpRequestId, TraceData,
2426
TraceEvent, TraceLevel,
@@ -92,6 +94,37 @@ fn resolve_properties(
9294
Ok(props)
9395
}
9496

97+
// Helper function to convert serde_json::Value to aws_smithy_types::Document
98+
fn serde_json_to_aws_document(value: serde_json::Value) -> Document {
99+
match value {
100+
serde_json::Value::Null => Document::Null,
101+
serde_json::Value::Bool(b) => Document::Bool(b),
102+
serde_json::Value::Number(n) => {
103+
if n.is_i64() {
104+
Document::Number(aws_smithy_types::Number::NegInt(n.as_i64().unwrap()))
105+
} else if n.is_u64() {
106+
Document::Number(aws_smithy_types::Number::PosInt(n.as_u64().unwrap()))
107+
} else {
108+
// Fallback to f64
109+
Document::Number(aws_smithy_types::Number::Float(
110+
n.as_f64().unwrap_or(f64::NAN),
111+
))
112+
}
113+
}
114+
serde_json::Value::String(s) => Document::String(s),
115+
serde_json::Value::Array(arr) => {
116+
Document::Array(arr.into_iter().map(serde_json_to_aws_document).collect())
117+
}
118+
serde_json::Value::Object(map) => {
119+
let converted_map: HashMap<String, Document> = map
120+
.into_iter()
121+
.map(|(k, v)| (k, serde_json_to_aws_document(v)))
122+
.collect();
123+
Document::Object(converted_map)
124+
}
125+
}
126+
}
127+
95128
#[derive(Debug)]
96129
struct CollectorInterceptor {
97130
span_id: Option<Uuid>,
@@ -392,37 +425,57 @@ impl AwsClient {
392425
let bedrock_config = aws_sdk_bedrockruntime::config::Builder::from(&config)
393426
// To support HTTPS_PROXY https://github.com/awslabs/aws-sdk-rust/issues/169
394427
.http_client(http_client)
428+
// Adding a custom http client (above) breaks the stalled stream protection for some reason. If a bedrock request takes longer than 5s (the default grace period, it makes it error out), so we disable it.
429+
.stalled_stream_protection(StalledStreamProtectionConfig::disabled())
395430
.interceptor(CollectorInterceptor::new(span_id, http_request_id.clone()))
396431
.build();
397432
Ok(BedrockRuntimeClient::from_conf(bedrock_config))
398433
}
399434

400-
async fn chat_anyhow<'r>(&self, response: &'r ConverseOutput) -> Result<&'r String> {
435+
async fn chat_anyhow<'r>(&self, response: &'r ConverseOutput) -> Result<String> {
401436
let Some(bedrock::types::ConverseOutput::Message(ref message)) = response.output else {
402437
anyhow::bail!(
403438
"Expected message output in response, but is type {}",
404439
"unknown"
405440
);
406441
};
407-
let content = message
408-
.content
409-
.first()
410-
.context("Expected message output to have content")?;
411-
let bedrock::types::ContentBlock::Text(ref content) = content else {
412-
anyhow::bail!(
413-
"Expected message output to be text, got {}",
414-
match content {
415-
bedrock::types::ContentBlock::Image(_) => "image",
416-
bedrock::types::ContentBlock::GuardContent(_) => "guardContent",
417-
bedrock::types::ContentBlock::ToolResult(_) => "toolResult",
418-
bedrock::types::ContentBlock::ToolUse(_) => "toolUse",
419-
bedrock::types::ContentBlock::Text(_) => "text",
420-
_ => "unknown",
421-
}
422-
);
423-
};
442+
// Try to extract text from all content blocks
443+
let mut extracted_text = String::new();
444+
let mut has_text = false;
424445

425-
Ok(content)
446+
if message.content.is_empty() {
447+
anyhow::bail!("Expected message output to have content, but content is empty");
448+
}
449+
450+
for content_block in &message.content {
451+
if let bedrock::types::ContentBlock::Text(text) = content_block {
452+
has_text = true;
453+
extracted_text.push_str(text);
454+
}
455+
}
456+
457+
// If we found at least one text block, return the concatenated text
458+
if has_text {
459+
let content = extracted_text;
460+
return Ok(content);
461+
}
462+
463+
// If we didn't find any text blocks, return an error with details about the content
464+
anyhow::bail!(
465+
"Expected message output to contain at least one text block, but found none. Content: {:?}",
466+
message.content.iter().map(|block| match block {
467+
bedrock::types::ContentBlock::Image(_) => "image",
468+
bedrock::types::ContentBlock::GuardContent(_) => "guardContent",
469+
bedrock::types::ContentBlock::ToolResult(_) => "toolResult",
470+
bedrock::types::ContentBlock::ToolUse(_) => "toolUse",
471+
bedrock::types::ContentBlock::Text(_) => "text",
472+
bedrock::types::ContentBlock::ReasoningContent(_) => "reasoningContent",
473+
bedrock::types::ContentBlock::CachePoint(_) => "cachePoint",
474+
bedrock::types::ContentBlock::Document(_) => "document",
475+
bedrock::types::ContentBlock::Video(_) => "video",
476+
_ => "unknown",
477+
}).collect::<Vec<_>>()
478+
);
426479
}
427480

428481
fn build_request(
@@ -460,8 +513,23 @@ impl AwsClient {
460513
.build()
461514
});
462515

516+
let additional_fields_doc = self
517+
.properties
518+
.additional_model_request_fields
519+
.as_ref()
520+
.map(|map| {
521+
// Convert IndexMap<String, serde_json::Value> to serde_json::Value::Object
522+
let json_map: serde_json::Map<String, serde_json::Value> =
523+
map.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
524+
let json_value = serde_json::Value::Object(json_map);
525+
// Convert serde_json::Value to aws_smithy_types::Document
526+
serde_json_to_aws_document(json_value)
527+
})
528+
.unwrap_or_else(|| Document::Object(HashMap::new())); // Default to empty object
529+
463530
bedrock::operation::converse::ConverseInput::builder()
464531
.set_inference_config(inference_config)
532+
.set_additional_model_request_fields(Some(additional_fields_doc))
465533
.set_model_id(Some(self.properties.model.clone()))
466534
.set_system(system_message)
467535
.set_messages(Some(converse_messages))
@@ -596,12 +664,15 @@ impl WithStreamChat for AwsClient {
596664
}
597665
};
598666

667+
let additional_model_request_fields = request.additional_model_request_fields;
668+
599669
let request = aws_client
600670
.converse_stream()
601671
.set_model_id(request.model_id)
602672
.set_inference_config(request.inference_config)
603673
.set_system(request.system)
604-
.set_messages(request.messages);
674+
.set_messages(request.messages)
675+
.set_additional_model_request_fields(additional_model_request_fields);
605676

606677
let system_start = SystemTime::now();
607678
let instant_start = Instant::now();
@@ -894,6 +965,7 @@ impl WithChat for AwsClient {
894965
let request = aws_client
895966
.converse()
896967
.set_model_id(request.model_id)
968+
.set_additional_model_request_fields(request.additional_model_request_fields)
897969
.set_inference_config(request.inference_config)
898970
.set_system(request.system)
899971
.set_messages(request.messages);

integ-tests/baml_src/test-files/providers/aws.baml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ function TestAwsInferenceProfile(input: string) -> string {
6666
"#
6767
}
6868

69+
// slow on purpose to try and trigger the stalled stream protection (which should be disabled)
70+
function TestAwsClaude37(input: string) -> string {
71+
client AwsBedrockClaude37Client
72+
prompt #"
73+
Write 12 haikus. Number them.
74+
"#
75+
}
76+
6977
test TestName {
7078
functions [TestAwsInferenceProfile]
7179
args {
@@ -75,6 +83,20 @@ test TestName {
7583
}
7684
}
7785

86+
client<llm> AwsBedrockClaude37Client {
87+
provider "aws-bedrock"
88+
options {
89+
model "arn:aws:bedrock:us-east-1:404337120808:inference-profile/us.anthropic.claude-3-7-sonnet-20250219-v1:0"
90+
additional_model_request_fields {
91+
thinking {
92+
type "enabled"
93+
budget_tokens 1030
94+
}
95+
}
96+
}
97+
}
98+
99+
78100

79101
client<llm> AwsBedrockInferenceProfileClient {
80102
provider "aws-bedrock"

0 commit comments

Comments
 (0)